payd-labs-sentinel-cli 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .venv/
6
+ venv/
7
+ *.egg
8
+
9
+ # Node
10
+ node_modules/
11
+ dist/
12
+
13
+ # Environment
14
+ .env
15
+ .env.local
16
+ .env.*.local
17
+
18
+ # IDE
19
+ .idea/
20
+ .vscode/
21
+ *.swp
22
+ *.swo
23
+ *~
24
+
25
+ # OS
26
+ .DS_Store
27
+ Thumbs.db
28
+
29
+ # Data
30
+ *.db
31
+ /data/
32
+
33
+ # Docker
34
+ docker-compose.override.yml
@@ -0,0 +1,210 @@
1
+ Metadata-Version: 2.4
2
+ Name: payd-labs-sentinel-cli
3
+ Version: 0.3.0
4
+ Summary: CLI and MCP server for the Sentinel DevOps portal - manage deployments, services, and projects
5
+ Project-URL: Homepage, https://sentinel.paydlabs.com
6
+ Project-URL: Repository, https://github.com/getpayd-tech/payd-labs-sentinel-v1
7
+ Author-email: Payd Labs <dev@payd.money>
8
+ License-Expression: MIT
9
+ Keywords: cli,deployment,devops,docker,mcp
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Software Development :: Build Tools
16
+ Classifier: Topic :: System :: Systems Administration
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: httpx>=0.28.0
19
+ Requires-Dist: mcp>=1.0.0
20
+ Requires-Dist: rich>=13.0.0
21
+ Requires-Dist: typer>=0.15.0
22
+ Description-Content-Type: text/markdown
23
+
24
+ # sentinel-cli
25
+
26
+ CLI and MCP server for the [Sentinel](https://sentinel.paydlabs.com) DevOps portal. Manage deployments end-to-end from the terminal or via AI agents.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ python3.12 -m pip install payd-labs-sentinel-cli
32
+ ```
33
+
34
+ Requires Python 3.12+.
35
+
36
+ ## Quick start
37
+
38
+ ```bash
39
+ payd-sentinel login # one-time OTP via Payd Auth, caches token at ~/.sentinel/
40
+
41
+ # End-to-end bootstrap of a new service (one command):
42
+ payd-sentinel bootstrap \
43
+ --name my-app --type fastapi --domain my-app.paydlabs.com \
44
+ --repo https://github.com/getpayd-tech/my-app \
45
+ --create-db \
46
+ --env SECRET_KEY="$(openssl rand -hex 32)" \
47
+ --env APP_ENV=production \
48
+ --deploy
49
+ ```
50
+
51
+ Runs 7 steps: project create, env set, database create, Caddy route, server provision, write workflow to local git repo + set GitHub secret via `gh`, first deploy.
52
+
53
+ ## Command reference
54
+
55
+ ### Everyday ops
56
+
57
+ ```bash
58
+ payd-sentinel status # All projects + their latest deploy
59
+ payd-sentinel projects # List projects
60
+ payd-sentinel services # List containers
61
+ payd-sentinel deploy <project> # Trigger deploy
62
+ payd-sentinel deploy <project> --tag v1.2.3
63
+ payd-sentinel rollback <project> <deploy-id>
64
+ payd-sentinel deployments [--project X]
65
+ payd-sentinel logs <container> [--tail 100] [--since 1h]
66
+ payd-sentinel restart|stop|start <container>
67
+ payd-sentinel audit [--action X] [--limit 30]
68
+ ```
69
+
70
+ `payd-sentinel deploy <project> --tag <sha>` is authoritative for Sentinel-generated
71
+ single-container and blended projects, where the project `ghcr_image` maps to
72
+ one image or the generated `-api` and `-ui` images. For parameterized custom
73
+ multi-image compose stacks, put a shared `*IMAGE_TAG` variable in the compose
74
+ `image:` lines, for example `CONNECT_IMAGE_TAG`; Sentinel updates that variable
75
+ in the compose file directory's `.env` before `docker compose pull`. For custom
76
+ edge/router stacks, set `--deploy-config` with image prefixes and the edge
77
+ service so Sentinel can assert the live service/image map before reporting
78
+ success.
79
+
80
+ Example:
81
+
82
+ ```bash
83
+ payd-sentinel project update payd-connect-v2-sandbox \
84
+ --deploy-config '{"compose_source":"webhook_bundle","image_tag_variables":["CONNECT_IMAGE_TAG"],"project_image_prefixes":["ghcr.io/getpayd-tech/payd-connect-v2-sandbox-"],"edge_service":"payd-connect-v2-sandbox"}'
85
+ ```
86
+
87
+ ### Projects
88
+
89
+ ```bash
90
+ payd-sentinel project create <name> --type fastapi --domain X --repo URL
91
+ payd-sentinel project show <name>
92
+ payd-sentinel project update <name> --domain new --custom-domains
93
+ payd-sentinel project delete <name>
94
+ payd-sentinel project scan # Auto-discover /apps/
95
+ payd-sentinel project provision <name> # Write compose + .env + Caddy
96
+ payd-sentinel project service-key <name> # Generate API key for custom-domains API
97
+ ```
98
+
99
+ ### Environment variables
100
+
101
+ ```bash
102
+ payd-sentinel env list <project> [--reveal]
103
+ payd-sentinel env set <project> KEY=VAL KEY2=VAL2 ...
104
+ payd-sentinel env unset <project> KEY1 KEY2
105
+ ```
106
+
107
+ ### Database (managed PostgreSQL)
108
+
109
+ ```bash
110
+ payd-sentinel db list
111
+ payd-sentinel db create <name> [--password PW]
112
+ payd-sentinel db tables <db>
113
+ payd-sentinel db query <db> "SELECT * FROM ..."
114
+ ```
115
+
116
+ ### Domains + TLS
117
+
118
+ ```bash
119
+ payd-sentinel domain list
120
+ payd-sentinel domain add <domain> --upstream container:port [--tls auto|cloudflare_dns|on_demand|off]
121
+ payd-sentinel domain remove <domain>
122
+ payd-sentinel domain reload
123
+ payd-sentinel domain tls status|enable|disable
124
+ payd-sentinel custom-domain list [--project X]
125
+ payd-sentinel custom-domain remove <domain>
126
+ ```
127
+
128
+ ### Security (fail2ban + SSH auth log)
129
+
130
+ ```bash
131
+ payd-sentinel security banned [--jail sshd]
132
+ payd-sentinel security ban <ip> [--jail sshd]
133
+ payd-sentinel security unban <ip> [--jail sshd]
134
+ payd-sentinel security activity [--tail 50]
135
+ payd-sentinel security auth [--tail 50] [--type success|failure|info]
136
+ payd-sentinel security ip <ip> # Full history (fail2ban + SSH)
137
+ ```
138
+
139
+ ### Repo setup (close the loop on new services)
140
+
141
+ ```bash
142
+ # End-to-end (recommended for new services):
143
+ payd-sentinel bootstrap --name X --type T --domain D --repo URL [...]
144
+
145
+ # For existing Sentinel projects that need the workflow added to their repo:
146
+ cd my-existing-repo
147
+ payd-sentinel repo setup <project>
148
+ # -> fetches generated workflow YAML from Sentinel
149
+ # -> writes .github/workflows/deploy.yml
150
+ # -> runs `gh secret set SENTINEL_WEBHOOK_SECRET ...`
151
+ # -> commits + pushes
152
+ # Flags: --no-secret, --no-commit, --message "msg"
153
+ ```
154
+
155
+ ### Interactive wizard
156
+
157
+ ```bash
158
+ payd-sentinel init # prompts for each field, runs the 9-step wizard
159
+ ```
160
+
161
+ ## Auth
162
+
163
+ Run `payd-sentinel login` once. Tokens are cached at `~/.sentinel/credentials.json` with auto-refresh.
164
+
165
+ Or set `SENTINEL_TOKEN` env var with a valid admin JWT to skip the login flow.
166
+
167
+ Override the API URL: `SENTINEL_URL=http://localhost:8000 payd-sentinel projects`
168
+
169
+ ## MCP Server (for Claude Code / AI agents)
170
+
171
+ The package includes an MCP server that exposes 30 tools for AI agents.
172
+
173
+ Add to your Claude Code settings:
174
+
175
+ ```json
176
+ {
177
+ "mcpServers": {
178
+ "sentinel": {
179
+ "command": "sentinel-mcp"
180
+ }
181
+ }
182
+ }
183
+ ```
184
+
185
+ ### Available tools
186
+
187
+ **Projects**: `sentinel_list_projects`, `sentinel_create_project`, `sentinel_update_project`, `sentinel_delete_project`, `sentinel_scan_projects`, `sentinel_provision_project`, `sentinel_project_status`, `sentinel_generate_service_key`, `sentinel_get_workflow`
188
+
189
+ **Deployments**: `sentinel_list_deployments`, `sentinel_deploy`, `sentinel_rollback`
190
+
191
+ **Services**: `sentinel_list_services`, `sentinel_restart_service`, `sentinel_stop_service`, `sentinel_start_service`, `sentinel_get_logs`
192
+
193
+ **Env**: `sentinel_list_env`, `sentinel_set_env`, `sentinel_unset_env`
194
+
195
+ **Database**: `sentinel_list_databases`, `sentinel_create_database`, `sentinel_list_tables`, `sentinel_db_query`
196
+
197
+ **Domains**: `sentinel_list_domains`, `sentinel_add_domain`, `sentinel_remove_domain`, `sentinel_reload_caddy`, `sentinel_list_custom_domains`
198
+
199
+ **Audit**: `sentinel_audit_log`
200
+
201
+ The MCP server reads auth from `~/.sentinel/credentials.json` (run `payd-sentinel login` first) or `SENTINEL_TOKEN` env var.
202
+
203
+ ## What is Sentinel?
204
+
205
+ Sentinel is a self-hosted DevOps portal for managing Docker container deployments behind Caddy reverse proxy. It provides webhook-based deploys, automatic health checks with rollback, custom domain management with on-demand TLS, fail2ban monitoring, and a web UI.
206
+
207
+ [sentinel.paydlabs.com](https://sentinel.paydlabs.com) | [GitHub](https://github.com/getpayd-tech/payd-labs-sentinel-v1) | [Self-hosting guide](https://github.com/getpayd-tech/payd-labs-sentinel-v1/blob/main/SELFHOST.md)
208
+
209
+
210
+ Legacy alias: `sentinel` remains available for backwards compatibility.
@@ -0,0 +1,187 @@
1
+ # sentinel-cli
2
+
3
+ CLI and MCP server for the [Sentinel](https://sentinel.paydlabs.com) DevOps portal. Manage deployments end-to-end from the terminal or via AI agents.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ python3.12 -m pip install payd-labs-sentinel-cli
9
+ ```
10
+
11
+ Requires Python 3.12+.
12
+
13
+ ## Quick start
14
+
15
+ ```bash
16
+ payd-sentinel login # one-time OTP via Payd Auth, caches token at ~/.sentinel/
17
+
18
+ # End-to-end bootstrap of a new service (one command):
19
+ payd-sentinel bootstrap \
20
+ --name my-app --type fastapi --domain my-app.paydlabs.com \
21
+ --repo https://github.com/getpayd-tech/my-app \
22
+ --create-db \
23
+ --env SECRET_KEY="$(openssl rand -hex 32)" \
24
+ --env APP_ENV=production \
25
+ --deploy
26
+ ```
27
+
28
+ Runs 7 steps: project create, env set, database create, Caddy route, server provision, write workflow to local git repo + set GitHub secret via `gh`, first deploy.
29
+
30
+ ## Command reference
31
+
32
+ ### Everyday ops
33
+
34
+ ```bash
35
+ payd-sentinel status # All projects + their latest deploy
36
+ payd-sentinel projects # List projects
37
+ payd-sentinel services # List containers
38
+ payd-sentinel deploy <project> # Trigger deploy
39
+ payd-sentinel deploy <project> --tag v1.2.3
40
+ payd-sentinel rollback <project> <deploy-id>
41
+ payd-sentinel deployments [--project X]
42
+ payd-sentinel logs <container> [--tail 100] [--since 1h]
43
+ payd-sentinel restart|stop|start <container>
44
+ payd-sentinel audit [--action X] [--limit 30]
45
+ ```
46
+
47
+ `payd-sentinel deploy <project> --tag <sha>` is authoritative for Sentinel-generated
48
+ single-container and blended projects, where the project `ghcr_image` maps to
49
+ one image or the generated `-api` and `-ui` images. For parameterized custom
50
+ multi-image compose stacks, put a shared `*IMAGE_TAG` variable in the compose
51
+ `image:` lines, for example `CONNECT_IMAGE_TAG`; Sentinel updates that variable
52
+ in the compose file directory's `.env` before `docker compose pull`. For custom
53
+ edge/router stacks, set `--deploy-config` with image prefixes and the edge
54
+ service so Sentinel can assert the live service/image map before reporting
55
+ success.
56
+
57
+ Example:
58
+
59
+ ```bash
60
+ payd-sentinel project update payd-connect-v2-sandbox \
61
+ --deploy-config '{"compose_source":"webhook_bundle","image_tag_variables":["CONNECT_IMAGE_TAG"],"project_image_prefixes":["ghcr.io/getpayd-tech/payd-connect-v2-sandbox-"],"edge_service":"payd-connect-v2-sandbox"}'
62
+ ```
63
+
64
+ ### Projects
65
+
66
+ ```bash
67
+ payd-sentinel project create <name> --type fastapi --domain X --repo URL
68
+ payd-sentinel project show <name>
69
+ payd-sentinel project update <name> --domain new --custom-domains
70
+ payd-sentinel project delete <name>
71
+ payd-sentinel project scan # Auto-discover /apps/
72
+ payd-sentinel project provision <name> # Write compose + .env + Caddy
73
+ payd-sentinel project service-key <name> # Generate API key for custom-domains API
74
+ ```
75
+
76
+ ### Environment variables
77
+
78
+ ```bash
79
+ payd-sentinel env list <project> [--reveal]
80
+ payd-sentinel env set <project> KEY=VAL KEY2=VAL2 ...
81
+ payd-sentinel env unset <project> KEY1 KEY2
82
+ ```
83
+
84
+ ### Database (managed PostgreSQL)
85
+
86
+ ```bash
87
+ payd-sentinel db list
88
+ payd-sentinel db create <name> [--password PW]
89
+ payd-sentinel db tables <db>
90
+ payd-sentinel db query <db> "SELECT * FROM ..."
91
+ ```
92
+
93
+ ### Domains + TLS
94
+
95
+ ```bash
96
+ payd-sentinel domain list
97
+ payd-sentinel domain add <domain> --upstream container:port [--tls auto|cloudflare_dns|on_demand|off]
98
+ payd-sentinel domain remove <domain>
99
+ payd-sentinel domain reload
100
+ payd-sentinel domain tls status|enable|disable
101
+ payd-sentinel custom-domain list [--project X]
102
+ payd-sentinel custom-domain remove <domain>
103
+ ```
104
+
105
+ ### Security (fail2ban + SSH auth log)
106
+
107
+ ```bash
108
+ payd-sentinel security banned [--jail sshd]
109
+ payd-sentinel security ban <ip> [--jail sshd]
110
+ payd-sentinel security unban <ip> [--jail sshd]
111
+ payd-sentinel security activity [--tail 50]
112
+ payd-sentinel security auth [--tail 50] [--type success|failure|info]
113
+ payd-sentinel security ip <ip> # Full history (fail2ban + SSH)
114
+ ```
115
+
116
+ ### Repo setup (close the loop on new services)
117
+
118
+ ```bash
119
+ # End-to-end (recommended for new services):
120
+ payd-sentinel bootstrap --name X --type T --domain D --repo URL [...]
121
+
122
+ # For existing Sentinel projects that need the workflow added to their repo:
123
+ cd my-existing-repo
124
+ payd-sentinel repo setup <project>
125
+ # -> fetches generated workflow YAML from Sentinel
126
+ # -> writes .github/workflows/deploy.yml
127
+ # -> runs `gh secret set SENTINEL_WEBHOOK_SECRET ...`
128
+ # -> commits + pushes
129
+ # Flags: --no-secret, --no-commit, --message "msg"
130
+ ```
131
+
132
+ ### Interactive wizard
133
+
134
+ ```bash
135
+ payd-sentinel init # prompts for each field, runs the 9-step wizard
136
+ ```
137
+
138
+ ## Auth
139
+
140
+ Run `payd-sentinel login` once. Tokens are cached at `~/.sentinel/credentials.json` with auto-refresh.
141
+
142
+ Or set `SENTINEL_TOKEN` env var with a valid admin JWT to skip the login flow.
143
+
144
+ Override the API URL: `SENTINEL_URL=http://localhost:8000 payd-sentinel projects`
145
+
146
+ ## MCP Server (for Claude Code / AI agents)
147
+
148
+ The package includes an MCP server that exposes 30 tools for AI agents.
149
+
150
+ Add to your Claude Code settings:
151
+
152
+ ```json
153
+ {
154
+ "mcpServers": {
155
+ "sentinel": {
156
+ "command": "sentinel-mcp"
157
+ }
158
+ }
159
+ }
160
+ ```
161
+
162
+ ### Available tools
163
+
164
+ **Projects**: `sentinel_list_projects`, `sentinel_create_project`, `sentinel_update_project`, `sentinel_delete_project`, `sentinel_scan_projects`, `sentinel_provision_project`, `sentinel_project_status`, `sentinel_generate_service_key`, `sentinel_get_workflow`
165
+
166
+ **Deployments**: `sentinel_list_deployments`, `sentinel_deploy`, `sentinel_rollback`
167
+
168
+ **Services**: `sentinel_list_services`, `sentinel_restart_service`, `sentinel_stop_service`, `sentinel_start_service`, `sentinel_get_logs`
169
+
170
+ **Env**: `sentinel_list_env`, `sentinel_set_env`, `sentinel_unset_env`
171
+
172
+ **Database**: `sentinel_list_databases`, `sentinel_create_database`, `sentinel_list_tables`, `sentinel_db_query`
173
+
174
+ **Domains**: `sentinel_list_domains`, `sentinel_add_domain`, `sentinel_remove_domain`, `sentinel_reload_caddy`, `sentinel_list_custom_domains`
175
+
176
+ **Audit**: `sentinel_audit_log`
177
+
178
+ The MCP server reads auth from `~/.sentinel/credentials.json` (run `payd-sentinel login` first) or `SENTINEL_TOKEN` env var.
179
+
180
+ ## What is Sentinel?
181
+
182
+ Sentinel is a self-hosted DevOps portal for managing Docker container deployments behind Caddy reverse proxy. It provides webhook-based deploys, automatic health checks with rollback, custom domain management with on-demand TLS, fail2ban monitoring, and a web UI.
183
+
184
+ [sentinel.paydlabs.com](https://sentinel.paydlabs.com) | [GitHub](https://github.com/getpayd-tech/payd-labs-sentinel-v1) | [Self-hosting guide](https://github.com/getpayd-tech/payd-labs-sentinel-v1/blob/main/SELFHOST.md)
185
+
186
+
187
+ Legacy alias: `sentinel` remains available for backwards compatibility.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "payd-labs-sentinel-cli"
7
+ version = "0.3.0"
8
+ description = "CLI and MCP server for the Sentinel DevOps portal - manage deployments, services, and projects"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.12"
12
+ authors = [
13
+ { name = "Payd Labs", email = "dev@payd.money" },
14
+ ]
15
+ keywords = ["devops", "deployment", "docker", "mcp", "cli"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "Topic :: Software Development :: Build Tools",
21
+ "Topic :: System :: Systems Administration",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ ]
25
+ dependencies = [
26
+ "typer>=0.15.0",
27
+ "httpx>=0.28.0",
28
+ "rich>=13.0.0",
29
+ "mcp>=1.0.0",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://sentinel.paydlabs.com"
34
+ Repository = "https://github.com/getpayd-tech/payd-labs-sentinel-v1"
35
+
36
+ [project.scripts]
37
+ sentinel = "sentinel_cli.cli:app"
38
+ payd-sentinel = "sentinel_cli.cli:app"
39
+ sentinel-mcp = "sentinel_cli.mcp_server:main"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["sentinel_cli"]
@@ -0,0 +1 @@
1
+ """Sentinel CLI and MCP server."""
@@ -0,0 +1,168 @@
1
+ """Authentication - OTP login, token caching, and auto-refresh."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import json
6
+ import logging
7
+ import os
8
+ import time
9
+
10
+ import httpx
11
+ from rich.console import Console
12
+ from rich.prompt import Prompt
13
+
14
+ from sentinel_cli.config import CREDENTIALS_DIR, CREDENTIALS_FILE
15
+
16
+ logger = logging.getLogger(__name__)
17
+ console = Console()
18
+
19
+
20
+ def _decode_jwt_payload(token: str) -> dict:
21
+ """Decode the payload segment of a JWT without signature verification."""
22
+ parts = token.split(".")
23
+ if len(parts) != 3:
24
+ raise ValueError("Invalid JWT format")
25
+ payload = parts[1]
26
+ payload += "=" * (4 - len(payload) % 4)
27
+ return json.loads(base64.urlsafe_b64decode(payload))
28
+
29
+
30
+ def save_credentials(auth_token: str, refresh_token: str) -> None:
31
+ """Persist tokens to ~/.sentinel/credentials.json with restricted permissions."""
32
+ claims = _decode_jwt_payload(auth_token)
33
+ expires_at = claims.get("exp", int(time.time()) + 3600)
34
+
35
+ CREDENTIALS_DIR.mkdir(parents=True, exist_ok=True)
36
+ CREDENTIALS_DIR.chmod(0o700)
37
+
38
+ data = {
39
+ "auth_token": auth_token,
40
+ "refresh_token": refresh_token,
41
+ "expires_at": expires_at,
42
+ "username": claims.get("username", ""),
43
+ }
44
+ CREDENTIALS_FILE.write_text(json.dumps(data, indent=2))
45
+ CREDENTIALS_FILE.chmod(0o600)
46
+
47
+
48
+ def load_credentials() -> dict | None:
49
+ """Load cached credentials from disk. Returns None if missing or corrupt."""
50
+ if not CREDENTIALS_FILE.exists():
51
+ return None
52
+ try:
53
+ return json.loads(CREDENTIALS_FILE.read_text())
54
+ except (json.JSONDecodeError, OSError):
55
+ return None
56
+
57
+
58
+ def get_valid_token(base_url: str) -> str | None:
59
+ """Return a valid auth token, refreshing if needed.
60
+
61
+ Checks (in order):
62
+ 1. SENTINEL_TOKEN env var
63
+ 2. Cached credentials with auto-refresh
64
+ Returns None if no valid token is available.
65
+ """
66
+ env_token = os.environ.get("SENTINEL_TOKEN")
67
+ if env_token:
68
+ return env_token
69
+
70
+ creds = load_credentials()
71
+ if not creds:
72
+ return None
73
+
74
+ now = time.time()
75
+ if creds["expires_at"] > now + 60:
76
+ return creds["auth_token"]
77
+
78
+ # Token expired or near expiry - try refresh
79
+ try:
80
+ new_auth, new_refresh = _refresh_token(base_url, creds["refresh_token"])
81
+ save_credentials(new_auth, new_refresh)
82
+ return new_auth
83
+ except Exception as exc:
84
+ logger.debug("Token refresh failed: %s", exc)
85
+ return None
86
+
87
+
88
+ def _refresh_token(base_url: str, refresh_tok: str) -> tuple[str, str]:
89
+ """Exchange a refresh token for new auth + refresh tokens."""
90
+ with httpx.Client(timeout=30.0) as client:
91
+ resp = client.post(
92
+ f"{base_url}/api/v1/auth/refresh",
93
+ json={"refresh_token": refresh_tok},
94
+ )
95
+ resp.raise_for_status()
96
+ data = resp.json()
97
+ auth_token = data.get("authToken") or data.get("access_token")
98
+ refresh_token = data.get("refreshToken") or data.get("refresh_token")
99
+ if not auth_token or not refresh_token:
100
+ raise ValueError("Missing tokens in refresh response")
101
+ return auth_token, refresh_token
102
+
103
+
104
+ def interactive_login(base_url: str) -> None:
105
+ """Run the full OTP login flow interactively."""
106
+ username = Prompt.ask("[bold]Username[/bold]")
107
+ password = Prompt.ask("[bold]Password[/bold]", password=True)
108
+
109
+ with httpx.Client(timeout=30.0) as client:
110
+ # Step 1: Login
111
+ resp = client.post(
112
+ f"{base_url}/api/v1/auth/login",
113
+ json={"username": username, "password": password},
114
+ )
115
+ if resp.status_code >= 400:
116
+ detail = resp.json().get("detail", "Login failed")
117
+ console.print(f"[red]Login failed:[/red] {detail}")
118
+ raise SystemExit(1)
119
+
120
+ data = resp.json()
121
+ session_token = data.get("sessionToken", "")
122
+
123
+ # Step 2: Request OTP
124
+ resp = client.post(
125
+ f"{base_url}/api/v1/auth/request-otp",
126
+ headers={"x-session-token": session_token},
127
+ content=b"",
128
+ )
129
+ if resp.status_code >= 400:
130
+ detail = resp.json().get("detail", "OTP request failed")
131
+ console.print(f"[red]OTP request failed:[/red] {detail}")
132
+ raise SystemExit(1)
133
+
134
+ otp_data = resp.json()
135
+ new_session = otp_data.get("sessionToken")
136
+ if new_session:
137
+ session_token = new_session
138
+
139
+ console.print("[dim]OTP sent. Check your phone/email.[/dim]")
140
+
141
+ # Step 3: Verify OTP
142
+ otp_code = Prompt.ask("[bold]OTP Code[/bold]")
143
+ resp = client.post(
144
+ f"{base_url}/api/v1/auth/verify-otp",
145
+ json={"otp": otp_code},
146
+ headers={"x-session-token": session_token},
147
+ )
148
+ if resp.status_code >= 400:
149
+ detail = resp.json().get("detail", "OTP verification failed")
150
+ console.print(f"[red]OTP verification failed:[/red] {detail}")
151
+ raise SystemExit(1)
152
+
153
+ data = resp.json()
154
+ auth_token = data.get("authToken") or data.get("access_token")
155
+ refresh_token = data.get("refreshToken") or data.get("refresh_token")
156
+
157
+ if not auth_token:
158
+ console.print("[red]No auth token in response[/red]")
159
+ raise SystemExit(1)
160
+
161
+ # Verify admin status
162
+ claims = _decode_jwt_payload(auth_token)
163
+ if not claims.get("is_admin"):
164
+ console.print("[red]Account is not an admin[/red]")
165
+ raise SystemExit(1)
166
+
167
+ save_credentials(auth_token, refresh_token or "")
168
+ console.print(f"[green]Logged in as {claims.get('username', username)}[/green]")