gravi-cli 0.1.1__py3-none-any.whl

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.
gravi_cli/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """
2
+ Gravi CLI - Command-line tool for Gravitate infrastructure management.
3
+
4
+ This package provides CLI access to Gravitate's mom infrastructure,
5
+ allowing developers to authenticate, manage tokens, and access instance
6
+ configurations and credentials.
7
+ """
8
+
9
+ __version__ = "0.1.1"
gravi_cli/api.py ADDED
@@ -0,0 +1,49 @@
1
+ """
2
+ Public Python API for gravi_cli.
3
+
4
+ This module provides the programmatic interface for using gravi_cli
5
+ as a library in Python scripts and applications.
6
+
7
+ Example usage:
8
+ from gravi_cli.api import get_instance_config, get_instance_token
9
+
10
+ # Get database credentials
11
+ config = get_instance_config("prod")
12
+ db_url = config["config"]["database_url"]
13
+
14
+ # Get ServiceNow access token
15
+ token_response = get_instance_token("dev")
16
+ sn_token = token_response["access_token"]
17
+ """
18
+
19
+ # Re-export the main API functions from auth module
20
+ from .auth import (
21
+ get_mom_token,
22
+ get_instance_config,
23
+ get_instance_token,
24
+ )
25
+
26
+ # Re-export exceptions for library users
27
+ from .exceptions import (
28
+ GraviError,
29
+ NotAuthenticatedError,
30
+ InvalidTokenError,
31
+ APIError,
32
+ RateLimitError,
33
+ ConfigError,
34
+ )
35
+
36
+ # Public API
37
+ __all__ = [
38
+ # Authentication functions
39
+ "get_mom_token",
40
+ "get_instance_config",
41
+ "get_instance_token",
42
+ # Exceptions
43
+ "GraviError",
44
+ "NotAuthenticatedError",
45
+ "InvalidTokenError",
46
+ "APIError",
47
+ "RateLimitError",
48
+ "ConfigError",
49
+ ]
gravi_cli/auth.py ADDED
@@ -0,0 +1,139 @@
1
+ """
2
+ Authentication and token management for gravi CLI.
3
+
4
+ Handles token refresh, auto-renewal, and provides the main API
5
+ for accessing mom and instance credentials.
6
+ """
7
+
8
+ import os
9
+ from datetime import datetime, timedelta, UTC
10
+
11
+ from .client import MomClient
12
+ from .config import load_config, save_config, config_exists
13
+ from .exceptions import NotAuthenticatedError, InvalidTokenError
14
+
15
+
16
+ def get_mom_url() -> str:
17
+ """
18
+ Get mom URL from environment or use default.
19
+
20
+ Returns:
21
+ Mom API base URL
22
+
23
+ Priority:
24
+ 1. GRAVI_MOM_URL environment variable
25
+ 2. Default: https://mom.gravitate.energy/api
26
+ """
27
+ return os.getenv("GRAVI_MOM_URL", "https://mom.gravitate.energy/api")
28
+
29
+
30
+ def get_mom_token() -> str:
31
+ """
32
+ Get valid Mom access token, refreshing if necessary.
33
+
34
+ This function:
35
+ 1. Checks for GRAVI_REFRESH_TOKEN environment variable (CI/CD usage)
36
+ 2. Falls back to config file refresh token
37
+ 3. Refreshes the access token using the refresh token
38
+ 4. Auto-renews refresh token if <7 days remaining (saves to config)
39
+ 5. Returns fresh access token (in-memory only, not persisted)
40
+
41
+ Returns:
42
+ Valid mom access token
43
+
44
+ Raises:
45
+ NotAuthenticatedError: If not logged in or token expired
46
+ InvalidTokenError: If refresh token is invalid or revoked
47
+ """
48
+ # Check for CI/CD environment variable first
49
+ refresh_token = os.getenv("GRAVI_REFRESH_TOKEN")
50
+
51
+ # Fall back to config file if not in CI/CD
52
+ if not refresh_token:
53
+ if not config_exists():
54
+ raise NotAuthenticatedError("Please run 'gravi login' first")
55
+
56
+ config = load_config()
57
+ refresh_token = config.refresh_token
58
+ else:
59
+ # CI/CD mode - load config if it exists (for potential auto-renewal)
60
+ config = None
61
+ if config_exists():
62
+ config = load_config()
63
+
64
+ if not refresh_token:
65
+ raise NotAuthenticatedError("Please run 'gravi login' first")
66
+
67
+ # Always get fresh access token from refresh token
68
+ mom_url = get_mom_url()
69
+ client = MomClient(mom_url)
70
+
71
+ try:
72
+ response = client.refresh_token(refresh_token)
73
+ except InvalidTokenError:
74
+ raise NotAuthenticatedError("Refresh token expired or revoked. Please run 'gravi login' again")
75
+
76
+ # Check if refresh token was renewed (auto-renewal if <7 days remaining)
77
+ if "refresh_token" in response and config:
78
+ config.refresh_token = response["refresh_token"]
79
+ config.refresh_token_expires_at = datetime.now(UTC) + timedelta(seconds=response["refresh_expires_in"])
80
+ save_config(config)
81
+
82
+ # Return access token (in-memory only, not persisted)
83
+ return response["access_token"]
84
+
85
+
86
+ def get_instance_config(instance_key: str) -> dict:
87
+ """
88
+ Get instance configuration (credentials, URLs, etc.).
89
+
90
+ Args:
91
+ instance_key: Instance identifier (e.g., "dev", "prod")
92
+
93
+ Returns:
94
+ Instance configuration dictionary:
95
+ {
96
+ "instance_key": "dev",
97
+ "name": "Development Environment",
98
+ "api_url": "https://dev-api.gravitate.com",
99
+ "config": {
100
+ "database_url": "postgresql://...",
101
+ "redis_url": "redis://...",
102
+ ...
103
+ }
104
+ }
105
+
106
+ Raises:
107
+ NotAuthenticatedError: If not logged in or token expired
108
+ InvalidTokenError: If token is invalid or revoked
109
+ APIError: If API request fails
110
+ """
111
+ mom_url = get_mom_url()
112
+ mom_token = get_mom_token()
113
+ client = MomClient(mom_url)
114
+ return client.get_instance_config(instance_key, mom_token)
115
+
116
+
117
+ def get_instance_token(instance_key: str) -> dict:
118
+ """
119
+ Get instance access token/credentials.
120
+
121
+ Args:
122
+ instance_key: Instance identifier (e.g., "dev", "prod")
123
+
124
+ Returns:
125
+ Instance-specific credentials (format varies by instance type)
126
+ Examples:
127
+ - ServiceNow OAuth: {"access_token": "...", "token_type": "Bearer", "expires_in": 3600}
128
+ - API Key based: {"api_key": "...", "environment": "production"}
129
+ - Custom format: Any JSON structure the instance provides
130
+
131
+ Raises:
132
+ NotAuthenticatedError: If not logged in or token expired
133
+ InvalidTokenError: If token is invalid or revoked
134
+ APIError: If API request fails
135
+ """
136
+ mom_url = get_mom_url()
137
+ mom_token = get_mom_token()
138
+ client = MomClient(mom_url)
139
+ return client.get_instance_token(instance_key, mom_token)
gravi_cli/cli.py ADDED
@@ -0,0 +1,401 @@
1
+ """
2
+ Main CLI interface for gravi.
3
+
4
+ Implements all CLI commands using Click framework.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import json
10
+ import time
11
+ import socket
12
+ import shlex
13
+ import webbrowser
14
+ from datetime import datetime, UTC
15
+
16
+ import click
17
+
18
+ from . import __version__
19
+ from .auth import get_mom_url, get_mom_token, get_instance_config, get_instance_token
20
+ from .client import MomClient
21
+ from .config import (
22
+ Config,
23
+ load_config,
24
+ save_config,
25
+ delete_config,
26
+ config_exists,
27
+ get_config_path,
28
+ )
29
+ from .exceptions import (
30
+ NotAuthenticatedError,
31
+ InvalidTokenError,
32
+ APIError,
33
+ RateLimitError,
34
+ ConfigError,
35
+ )
36
+
37
+
38
+ @click.group()
39
+ @click.version_option(version=__version__, prog_name="gravi")
40
+ def main():
41
+ """Gravi CLI - Gravitate infrastructure management tool."""
42
+ pass
43
+
44
+
45
+ @main.command()
46
+ @click.option(
47
+ "--mom-url",
48
+ envvar="GRAVI_MOM_URL",
49
+ default="https://mom.gravitate.energy/api",
50
+ help="Mom API URL (default: https://mom.gravitate.energy/api)",
51
+ )
52
+ def login(mom_url):
53
+ """Authenticate with mom via browser."""
54
+ client = MomClient(mom_url)
55
+
56
+ # Check if already logged in
57
+ if config_exists():
58
+ try:
59
+ config = load_config()
60
+ click.echo(f"Already logged in as {config.user_email}")
61
+ if click.confirm("Do you want to log in again?", default=False):
62
+ delete_config()
63
+ else:
64
+ return
65
+ except Exception:
66
+ # Config corrupted, proceed with login
67
+ pass
68
+
69
+ # Step 1: Initiate device authorization
70
+ click.echo("Initiating authentication...")
71
+
72
+ # Get device name from hostname with fallback
73
+ try:
74
+ device_name = socket.gethostname()
75
+ if not device_name or device_name == "localhost":
76
+ device_name = "unknown-device"
77
+ except Exception:
78
+ device_name = "unknown-device"
79
+
80
+ try:
81
+ device_auth = client.initiate_device_auth(device_name=device_name)
82
+ except APIError as e:
83
+ click.echo(f"Error: {e}", err=True)
84
+ sys.exit(1)
85
+
86
+ # Step 2: Display instructions
87
+ click.echo("\n" + "=" * 60)
88
+ click.echo("Please authorize this CLI tool:")
89
+ click.echo(f" 1. Opening browser to: {device_auth['verification_uri_complete']}")
90
+ click.echo(f" 2. Or manually visit: {device_auth['verification_uri']}")
91
+ click.echo(f" and enter code: {device_auth['user_code']}")
92
+ click.echo("=" * 60 + "\n")
93
+
94
+ # Step 3: Open browser automatically
95
+ try:
96
+ webbrowser.open(device_auth["verification_uri_complete"])
97
+ except Exception:
98
+ click.echo("Could not open browser automatically. Please visit the URL manually.")
99
+
100
+ # Step 4: Poll for authorization
101
+ device_code = device_auth["device_code"]
102
+ interval = device_auth["interval"]
103
+ expires_in = device_auth["expires_in"]
104
+
105
+ click.echo("Waiting for authorization...", nl=False)
106
+
107
+ start_time = time.time()
108
+ while time.time() - start_time < expires_in:
109
+ time.sleep(interval)
110
+
111
+ try:
112
+ result = client.poll_device_auth(device_code)
113
+ except APIError as e:
114
+ if "expired" in str(e).lower():
115
+ click.echo(" ✗")
116
+ click.echo("\nError: Authorization timed out. Please try again.", err=True)
117
+ sys.exit(1)
118
+ # Continue polling on other errors
119
+ click.echo(".", nl=False)
120
+ continue
121
+
122
+ if result.get("error"):
123
+ if result["error"] == "expired_token":
124
+ click.echo(" ✗")
125
+ click.echo("\nError: Authorization timed out. Please try again.", err=True)
126
+ sys.exit(1)
127
+ # Continue polling for other errors
128
+ click.echo(".", nl=False)
129
+ continue
130
+
131
+ if result.get("authorized"):
132
+ click.echo(" ✓")
133
+
134
+ # Step 5: Extract credentials from response
135
+ user_email = result["user_email"]
136
+ token_id = result["token_id"]
137
+ refresh_token = result["refresh_token"]
138
+ refresh_expires_in = result["refresh_expires_in"]
139
+ device_name = result["device_name"] # Final device name (may have been edited by user)
140
+
141
+ # Save config (without mom_url - it's runtime only)
142
+ config = Config(
143
+ user_email=user_email,
144
+ refresh_token=refresh_token,
145
+ refresh_token_expires_at=datetime.now(UTC) + timedelta(seconds=refresh_expires_in),
146
+ token_id=token_id,
147
+ device_name=device_name,
148
+ )
149
+
150
+ try:
151
+ save_config(config)
152
+ except ConfigError as e:
153
+ click.echo(f"\nError saving config: {e}", err=True)
154
+ sys.exit(1)
155
+
156
+ click.echo(f"\n✓ Successfully logged in as {user_email}")
157
+ click.echo(f" Credentials saved to: {get_config_path()}")
158
+ return
159
+
160
+ click.echo(".", nl=False)
161
+
162
+ # Timeout
163
+ click.echo(" ✗")
164
+ click.echo("\nError: Authorization timed out or cancelled. Please try again.", err=True)
165
+ sys.exit(1)
166
+
167
+
168
+ @main.command()
169
+ def logout():
170
+ """Clear local credentials and revoke token on backend."""
171
+ try:
172
+ config = load_config()
173
+
174
+ # Try to revoke token on backend
175
+ try:
176
+ mom_token = get_mom_token()
177
+ mom_url = get_mom_url()
178
+ client = MomClient(mom_url)
179
+ # Delete this specific token by token_id
180
+ client.delete_cli_token(config.token_id, mom_token)
181
+ click.echo("✓ Token revoked on server")
182
+ except Exception as e:
183
+ click.echo(f"⚠ Could not revoke token on server: {e}")
184
+ click.echo(" (Will still clear local credentials)")
185
+
186
+ # Delete local config file
187
+ config_path = get_config_path()
188
+ if delete_config():
189
+ click.echo(f"✓ Local credentials cleared from {config_path}")
190
+ else:
191
+ click.echo("No local credentials found")
192
+
193
+ except FileNotFoundError:
194
+ click.echo("✓ Logged out (no existing session)")
195
+ except Exception as e:
196
+ click.echo(f"Error: {e}", err=True)
197
+ sys.exit(1)
198
+
199
+
200
+ @main.command()
201
+ def status():
202
+ """Show login status and token expiry."""
203
+ try:
204
+ config = load_config()
205
+
206
+ mom_url = get_mom_url()
207
+
208
+ click.echo("Gravi CLI Status")
209
+ click.echo("=" * 40)
210
+ click.echo(f"User: {config.user_email}")
211
+ click.echo(f"Mom URL: {mom_url}")
212
+ click.echo(f"Device: {config.device_name}")
213
+ click.echo(f"Token ID: {config.token_id}")
214
+
215
+ # Calculate expiry
216
+ # Handle both string (from config file) and datetime object
217
+ if isinstance(config.refresh_token_expires_at, str):
218
+ expires_at = datetime.fromisoformat(config.refresh_token_expires_at.replace("Z", "+00:00"))
219
+ else:
220
+ expires_at = config.refresh_token_expires_at
221
+
222
+ time_remaining = expires_at - datetime.now(UTC)
223
+ days_remaining = time_remaining.days
224
+
225
+ if days_remaining < 0:
226
+ click.echo("Token: ✗ EXPIRED")
227
+ click.echo("Action: Run 'gravi login' to re-authenticate")
228
+ elif days_remaining < 2:
229
+ click.echo(f"Token: ⚠ Expires in {days_remaining} day(s)")
230
+ click.echo("Action: Will auto-renew on next use")
231
+ else:
232
+ click.echo(f"Token: ✓ Valid for {days_remaining} more days")
233
+
234
+ except FileNotFoundError:
235
+ click.echo("Not logged in. Run 'gravi login' to authenticate.")
236
+ except Exception as e:
237
+ click.echo(f"Error: {e}", err=True)
238
+
239
+
240
+ @main.command()
241
+ def whoami():
242
+ """Show current user info and mom URL."""
243
+ try:
244
+ config = load_config()
245
+
246
+ # Get fresh access token to verify we're still authenticated
247
+ mom_token = get_mom_token()
248
+ mom_url = get_mom_url()
249
+
250
+ click.echo(f"Logged in as: {config.user_email}")
251
+ click.echo(f"Mom URL: {mom_url}")
252
+ click.echo(f"Device: {config.device_name}")
253
+
254
+ except FileNotFoundError:
255
+ click.echo("Not logged in. Run 'gravi login' to authenticate.")
256
+ except NotAuthenticatedError:
257
+ click.echo("Session expired. Run 'gravi login' to re-authenticate.")
258
+ except Exception as e:
259
+ click.echo(f"Error: {e}", err=True)
260
+
261
+
262
+ @main.group()
263
+ def tokens():
264
+ """Manage CLI authorization tokens."""
265
+ pass
266
+
267
+
268
+ @tokens.command("list")
269
+ def tokens_list():
270
+ """List all authorized CLI tokens."""
271
+ try:
272
+ config = load_config()
273
+ mom_token = get_mom_token()
274
+ mom_url = get_mom_url()
275
+ client = MomClient(mom_url)
276
+
277
+ response = client.list_cli_tokens(mom_token)
278
+
279
+ if not response["tokens"]:
280
+ click.echo("No active tokens found.")
281
+ return
282
+
283
+ click.echo("Active CLI Tokens:")
284
+ click.echo("=" * 80)
285
+
286
+ for token in response["tokens"]:
287
+ is_current = token["id"] == config.token_id
288
+ marker = "→" if is_current else " "
289
+
290
+ click.echo(f"{marker} ID: {token['id']}")
291
+ click.echo(f" Device: {token['device_name']}")
292
+ click.echo(f" Type: {token['token_type']}")
293
+ click.echo(f" Created: {token['created_at']}")
294
+ click.echo(f" Last used: {token.get('last_used_at', 'Never')}")
295
+ click.echo(f" Expires in: {token['days_until_expiry']} days")
296
+ if is_current:
297
+ click.echo(" (Current session)")
298
+ click.echo()
299
+
300
+ except NotAuthenticatedError as e:
301
+ click.echo(f"Error: {e}", err=True)
302
+ sys.exit(1)
303
+ except Exception as e:
304
+ click.echo(f"Error: {e}", err=True)
305
+ sys.exit(1)
306
+
307
+
308
+ @tokens.command("revoke")
309
+ @click.argument("token_id", required=False)
310
+ @click.option("--all", is_flag=True, help="Revoke all tokens")
311
+ def tokens_revoke(token_id, all):
312
+ """Revoke a CLI token by ID, or all tokens."""
313
+ try:
314
+ config = load_config()
315
+ mom_token = get_mom_token()
316
+ mom_url = get_mom_url()
317
+ client = MomClient(mom_url)
318
+
319
+ if all:
320
+ if not click.confirm(
321
+ "Are you sure you want to revoke ALL tokens? This will log you out everywhere.",
322
+ default=False,
323
+ ):
324
+ click.echo("Cancelled.")
325
+ return
326
+
327
+ # Get all tokens and revoke them
328
+ response = client.list_cli_tokens(mom_token)
329
+ for token in response["tokens"]:
330
+ client.delete_cli_token(token["id"], mom_token)
331
+ click.echo(f"✓ Revoked token: {token['device_name']}")
332
+
333
+ # Clear local config
334
+ delete_config()
335
+ click.echo("\n✓ All tokens revoked. You are now logged out.")
336
+
337
+ elif token_id:
338
+ client.delete_cli_token(token_id, mom_token)
339
+ click.echo(f"✓ Token {token_id} revoked")
340
+
341
+ # If revoking current token, clear local config
342
+ # Compare as strings to handle both string and ObjectId types
343
+ if str(token_id) == str(config.token_id):
344
+ delete_config()
345
+ click.echo("✓ Current session ended. Run 'gravi login' to re-authenticate.")
346
+
347
+ else:
348
+ click.echo("Error: Specify a token ID or use --all flag", err=True)
349
+ click.echo("Usage: gravi tokens revoke <token_id>")
350
+ click.echo(" gravi tokens revoke --all")
351
+ sys.exit(1)
352
+
353
+ except NotAuthenticatedError as e:
354
+ click.echo(f"Error: {e}", err=True)
355
+ sys.exit(1)
356
+ except Exception as e:
357
+ click.echo(f"Error: {e}", err=True)
358
+ sys.exit(1)
359
+
360
+
361
+ @main.command()
362
+ @click.argument("instance_key")
363
+ @click.option(
364
+ "--format",
365
+ type=click.Choice(["json", "env"]),
366
+ default="json",
367
+ help="Output format (default: json)",
368
+ )
369
+ def config(instance_key, format):
370
+ """Get instance configuration (credentials, URLs, etc.)."""
371
+ try:
372
+ config_data = get_instance_config(instance_key)
373
+
374
+ if format == "json":
375
+ click.echo(json.dumps(config_data, indent=2))
376
+ elif format == "env":
377
+ # Flatten config for environment variables
378
+ click.echo(f"# Environment variables for {instance_key}")
379
+ click.echo(f"export INSTANCE_KEY={shlex.quote(config_data['instance_key'])}")
380
+ click.echo(f"export INSTANCE_NAME={shlex.quote(config_data['name'])}")
381
+ click.echo(f"export API_URL={shlex.quote(config_data['api_url'])}")
382
+
383
+ for key, value in config_data.get("config", {}).items():
384
+ env_key = key.upper()
385
+ # Quote values to handle special characters and spaces
386
+ click.echo(f"export {env_key}={shlex.quote(str(value))}")
387
+
388
+ except NotAuthenticatedError as e:
389
+ click.echo(f"Error: {e}", err=True)
390
+ sys.exit(1)
391
+ except Exception as e:
392
+ click.echo(f"Error: {e}", err=True)
393
+ sys.exit(1)
394
+
395
+
396
+ # Add missing import for timedelta
397
+ from datetime import timedelta
398
+
399
+
400
+ if __name__ == "__main__":
401
+ main()