codeshift 0.2.0__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.
- codeshift/__init__.py +8 -0
- codeshift/analyzer/__init__.py +5 -0
- codeshift/analyzer/risk_assessor.py +388 -0
- codeshift/api/__init__.py +1 -0
- codeshift/api/auth.py +182 -0
- codeshift/api/config.py +73 -0
- codeshift/api/database.py +215 -0
- codeshift/api/main.py +103 -0
- codeshift/api/models/__init__.py +55 -0
- codeshift/api/models/auth.py +108 -0
- codeshift/api/models/billing.py +92 -0
- codeshift/api/models/migrate.py +42 -0
- codeshift/api/models/usage.py +116 -0
- codeshift/api/routers/__init__.py +5 -0
- codeshift/api/routers/auth.py +440 -0
- codeshift/api/routers/billing.py +395 -0
- codeshift/api/routers/migrate.py +304 -0
- codeshift/api/routers/usage.py +291 -0
- codeshift/api/routers/webhooks.py +289 -0
- codeshift/cli/__init__.py +5 -0
- codeshift/cli/commands/__init__.py +7 -0
- codeshift/cli/commands/apply.py +352 -0
- codeshift/cli/commands/auth.py +842 -0
- codeshift/cli/commands/diff.py +221 -0
- codeshift/cli/commands/scan.py +368 -0
- codeshift/cli/commands/upgrade.py +436 -0
- codeshift/cli/commands/upgrade_all.py +518 -0
- codeshift/cli/main.py +221 -0
- codeshift/cli/quota.py +210 -0
- codeshift/knowledge/__init__.py +50 -0
- codeshift/knowledge/cache.py +167 -0
- codeshift/knowledge/generator.py +231 -0
- codeshift/knowledge/models.py +151 -0
- codeshift/knowledge/parser.py +270 -0
- codeshift/knowledge/sources.py +388 -0
- codeshift/knowledge_base/__init__.py +17 -0
- codeshift/knowledge_base/loader.py +102 -0
- codeshift/knowledge_base/models.py +110 -0
- codeshift/migrator/__init__.py +23 -0
- codeshift/migrator/ast_transforms.py +256 -0
- codeshift/migrator/engine.py +395 -0
- codeshift/migrator/llm_migrator.py +320 -0
- codeshift/migrator/transforms/__init__.py +19 -0
- codeshift/migrator/transforms/fastapi_transformer.py +174 -0
- codeshift/migrator/transforms/pandas_transformer.py +236 -0
- codeshift/migrator/transforms/pydantic_v1_to_v2.py +637 -0
- codeshift/migrator/transforms/requests_transformer.py +218 -0
- codeshift/migrator/transforms/sqlalchemy_transformer.py +175 -0
- codeshift/scanner/__init__.py +6 -0
- codeshift/scanner/code_scanner.py +352 -0
- codeshift/scanner/dependency_parser.py +473 -0
- codeshift/utils/__init__.py +5 -0
- codeshift/utils/api_client.py +266 -0
- codeshift/utils/cache.py +318 -0
- codeshift/utils/config.py +71 -0
- codeshift/utils/llm_client.py +221 -0
- codeshift/validator/__init__.py +6 -0
- codeshift/validator/syntax_checker.py +183 -0
- codeshift/validator/test_runner.py +224 -0
- codeshift-0.2.0.dist-info/METADATA +326 -0
- codeshift-0.2.0.dist-info/RECORD +65 -0
- codeshift-0.2.0.dist-info/WHEEL +5 -0
- codeshift-0.2.0.dist-info/entry_points.txt +2 -0
- codeshift-0.2.0.dist-info/licenses/LICENSE +21 -0
- codeshift-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
"""Authentication commands for Codeshift CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
import webbrowser
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
import httpx
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
15
|
+
from rich.prompt import Confirm, Prompt
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
# Config directory for storing credentials
|
|
21
|
+
CONFIG_DIR = Path.home() / ".config" / "codeshift"
|
|
22
|
+
CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_api_url() -> str:
|
|
26
|
+
"""Get the API URL from environment or default."""
|
|
27
|
+
return os.environ.get("CODESHIFT_API_URL", "https://py-resolve.replit.app")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_credentials() -> dict[str, Any] | None:
|
|
31
|
+
"""Load saved credentials from disk."""
|
|
32
|
+
if not CREDENTIALS_FILE.exists():
|
|
33
|
+
return None
|
|
34
|
+
try:
|
|
35
|
+
return cast(dict[str, Any], json.loads(CREDENTIALS_FILE.read_text()))
|
|
36
|
+
except (OSError, json.JSONDecodeError):
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save_credentials(credentials: dict) -> None:
|
|
41
|
+
"""Save credentials to disk."""
|
|
42
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
|
|
44
|
+
# Set restrictive permissions
|
|
45
|
+
CREDENTIALS_FILE.write_text(json.dumps(credentials, indent=2))
|
|
46
|
+
os.chmod(CREDENTIALS_FILE, 0o600)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def delete_credentials() -> None:
|
|
50
|
+
"""Delete saved credentials."""
|
|
51
|
+
if CREDENTIALS_FILE.exists():
|
|
52
|
+
CREDENTIALS_FILE.unlink()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_api_key() -> str | None:
|
|
56
|
+
"""Get API key from environment or saved credentials."""
|
|
57
|
+
# Check environment first
|
|
58
|
+
api_key = os.environ.get("PYRESOLVE_API_KEY")
|
|
59
|
+
if api_key:
|
|
60
|
+
return api_key
|
|
61
|
+
|
|
62
|
+
# Check saved credentials
|
|
63
|
+
creds = load_credentials()
|
|
64
|
+
if creds:
|
|
65
|
+
return creds.get("api_key")
|
|
66
|
+
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def make_authenticated_request(
|
|
71
|
+
method: str,
|
|
72
|
+
endpoint: str,
|
|
73
|
+
**kwargs: Any,
|
|
74
|
+
) -> httpx.Response:
|
|
75
|
+
"""Make an authenticated request to the API."""
|
|
76
|
+
api_key = get_api_key()
|
|
77
|
+
api_url = get_api_url()
|
|
78
|
+
|
|
79
|
+
headers = kwargs.pop("headers", {})
|
|
80
|
+
if api_key:
|
|
81
|
+
headers["X-API-Key"] = api_key
|
|
82
|
+
|
|
83
|
+
url = f"{api_url}{endpoint}"
|
|
84
|
+
|
|
85
|
+
with httpx.Client(timeout=30) as client:
|
|
86
|
+
response = client.request(method, url, headers=headers, **kwargs)
|
|
87
|
+
|
|
88
|
+
return response
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@click.command()
|
|
92
|
+
@click.option("--email", "-e", help="Email address for login")
|
|
93
|
+
@click.option("--password", "-p", help="Password for login", hide_input=True)
|
|
94
|
+
@click.option("--api-key", "-k", help="Use an existing API key")
|
|
95
|
+
@click.option("--device", "-d", is_flag=True, help="Use device code flow (for browsers)")
|
|
96
|
+
def login(
|
|
97
|
+
email: str | None,
|
|
98
|
+
password: str | None,
|
|
99
|
+
api_key: str | None,
|
|
100
|
+
device: bool,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Login to PyResolve to enable cloud features.
|
|
103
|
+
|
|
104
|
+
\b
|
|
105
|
+
Authentication methods:
|
|
106
|
+
1. Email/password: codeshift login -e user@example.com -p yourpassword
|
|
107
|
+
2. API key: codeshift login -k pyr_xxxxx
|
|
108
|
+
3. Device flow: codeshift login --device
|
|
109
|
+
|
|
110
|
+
Your credentials are stored in ~/.config/codeshift/credentials.json
|
|
111
|
+
|
|
112
|
+
Don't have an account? Run: codeshift register
|
|
113
|
+
"""
|
|
114
|
+
# Check if already logged in
|
|
115
|
+
existing = load_credentials()
|
|
116
|
+
if existing:
|
|
117
|
+
if not Confirm.ask("[yellow]You are already logged in. Do you want to re-authenticate?[/]"):
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
# Option 1: Use provided API key
|
|
121
|
+
if api_key:
|
|
122
|
+
_login_with_api_key(api_key)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
# Option 2: Device code flow
|
|
126
|
+
if device:
|
|
127
|
+
_login_with_device_code()
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
# Option 3: Email/password
|
|
131
|
+
if not email:
|
|
132
|
+
email = Prompt.ask("Email")
|
|
133
|
+
|
|
134
|
+
if not password:
|
|
135
|
+
password = Prompt.ask("Password", password=True)
|
|
136
|
+
|
|
137
|
+
assert email is not None
|
|
138
|
+
assert password is not None
|
|
139
|
+
_login_with_password(email, password)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@click.command()
|
|
143
|
+
@click.option("--email", "-e", help="Email address for registration")
|
|
144
|
+
@click.option("--password", "-p", help="Password (min 8 characters)", hide_input=True)
|
|
145
|
+
@click.option("--name", "-n", help="Your full name (optional)")
|
|
146
|
+
def register(
|
|
147
|
+
email: str | None,
|
|
148
|
+
password: str | None,
|
|
149
|
+
name: str | None,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Create a new PyResolve account.
|
|
152
|
+
|
|
153
|
+
\b
|
|
154
|
+
Example:
|
|
155
|
+
codeshift register -e user@example.com -p yourpassword
|
|
156
|
+
|
|
157
|
+
Your credentials are stored in ~/.config/codeshift/credentials.json
|
|
158
|
+
"""
|
|
159
|
+
# Check if already logged in
|
|
160
|
+
existing = load_credentials()
|
|
161
|
+
if existing:
|
|
162
|
+
if not Confirm.ask(
|
|
163
|
+
"[yellow]You are already logged in. Do you want to create a new account?[/]"
|
|
164
|
+
):
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
if not email:
|
|
168
|
+
email = Prompt.ask("Email")
|
|
169
|
+
|
|
170
|
+
if not password:
|
|
171
|
+
password = Prompt.ask("Password (min 8 characters)", password=True)
|
|
172
|
+
password_confirm = Prompt.ask("Confirm password", password=True)
|
|
173
|
+
if password != password_confirm:
|
|
174
|
+
console.print("[red]Passwords do not match[/]")
|
|
175
|
+
raise SystemExit(1)
|
|
176
|
+
|
|
177
|
+
if len(password) < 8:
|
|
178
|
+
console.print("[red]Password must be at least 8 characters[/]")
|
|
179
|
+
raise SystemExit(1)
|
|
180
|
+
|
|
181
|
+
if not name:
|
|
182
|
+
name = Prompt.ask("Full name (optional)", default="")
|
|
183
|
+
|
|
184
|
+
assert email is not None
|
|
185
|
+
assert password is not None
|
|
186
|
+
_register_account(email, password, name if name else None)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _register_account(email: str, password: str, full_name: str | None) -> None:
|
|
190
|
+
"""Register a new account."""
|
|
191
|
+
api_url = get_api_url()
|
|
192
|
+
|
|
193
|
+
with Progress(
|
|
194
|
+
SpinnerColumn(),
|
|
195
|
+
TextColumn("[progress.description]{task.description}"),
|
|
196
|
+
console=console,
|
|
197
|
+
) as progress:
|
|
198
|
+
task = progress.add_task("Creating account...", total=None)
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
payload = {"email": email, "password": password}
|
|
202
|
+
if full_name:
|
|
203
|
+
payload["full_name"] = full_name
|
|
204
|
+
|
|
205
|
+
response = httpx.post(
|
|
206
|
+
f"{api_url}/auth/register",
|
|
207
|
+
json=payload,
|
|
208
|
+
timeout=30,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
if response.status_code == 200:
|
|
212
|
+
data = response.json()
|
|
213
|
+
|
|
214
|
+
# Save credentials
|
|
215
|
+
save_credentials(
|
|
216
|
+
{
|
|
217
|
+
"api_key": data["api_key"],
|
|
218
|
+
"user_id": data["user"]["id"],
|
|
219
|
+
"email": data["user"]["email"],
|
|
220
|
+
"tier": data["user"].get("tier", "free"),
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
progress.update(task, completed=True)
|
|
225
|
+
|
|
226
|
+
console.print(
|
|
227
|
+
Panel(
|
|
228
|
+
f"[green]Account created successfully![/]\n\n"
|
|
229
|
+
f"Email: [cyan]{data['user']['email']}[/]\n"
|
|
230
|
+
f"Tier: [cyan]{data['user'].get('tier', 'free')}[/]\n\n"
|
|
231
|
+
f"[dim]You are now logged in and ready to use PyResolve.[/]",
|
|
232
|
+
title="Registration Successful",
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
elif response.status_code == 409:
|
|
236
|
+
console.print(
|
|
237
|
+
"[red]An account with this email already exists.[/]\n"
|
|
238
|
+
"Run [cyan]codeshift login[/] to sign in."
|
|
239
|
+
)
|
|
240
|
+
raise SystemExit(1)
|
|
241
|
+
elif response.status_code == 422:
|
|
242
|
+
detail = response.json().get("detail", [])
|
|
243
|
+
if isinstance(detail, list) and detail:
|
|
244
|
+
msg = detail[0].get("msg", "Invalid input")
|
|
245
|
+
else:
|
|
246
|
+
msg = str(detail)
|
|
247
|
+
console.print(f"[red]Validation error: {msg}[/]")
|
|
248
|
+
raise SystemExit(1)
|
|
249
|
+
else:
|
|
250
|
+
console.print(f"[red]Registration failed: {response.text}[/]")
|
|
251
|
+
raise SystemExit(1)
|
|
252
|
+
except httpx.RequestError as e:
|
|
253
|
+
console.print(f"[red]Connection error: {e}[/]")
|
|
254
|
+
raise SystemExit(1) from e
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _login_with_api_key(api_key: str) -> None:
|
|
258
|
+
"""Authenticate with an API key."""
|
|
259
|
+
api_url = get_api_url()
|
|
260
|
+
|
|
261
|
+
with Progress(
|
|
262
|
+
SpinnerColumn(),
|
|
263
|
+
TextColumn("[progress.description]{task.description}"),
|
|
264
|
+
console=console,
|
|
265
|
+
) as progress:
|
|
266
|
+
task = progress.add_task("Verifying API key...", total=None)
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
response = httpx.get(
|
|
270
|
+
f"{api_url}/auth/me",
|
|
271
|
+
headers={"X-API-Key": api_key},
|
|
272
|
+
timeout=30,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if response.status_code == 200:
|
|
276
|
+
user = response.json()
|
|
277
|
+
|
|
278
|
+
# Save credentials
|
|
279
|
+
save_credentials(
|
|
280
|
+
{
|
|
281
|
+
"api_key": api_key,
|
|
282
|
+
"user_id": user.get("id"),
|
|
283
|
+
"email": user.get("email"),
|
|
284
|
+
"tier": user.get("tier", "free"),
|
|
285
|
+
}
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
progress.update(task, completed=True)
|
|
289
|
+
|
|
290
|
+
console.print(
|
|
291
|
+
Panel(
|
|
292
|
+
f"[green]Successfully logged in![/]\n\n"
|
|
293
|
+
f"Email: [cyan]{user.get('email')}[/]\n"
|
|
294
|
+
f"Tier: [cyan]{user.get('tier', 'free')}[/]",
|
|
295
|
+
title="Login Successful",
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
elif response.status_code == 401:
|
|
299
|
+
console.print("[red]Invalid API key[/]")
|
|
300
|
+
raise SystemExit(1)
|
|
301
|
+
else:
|
|
302
|
+
console.print(f"[red]Login failed: {response.text}[/]")
|
|
303
|
+
raise SystemExit(1)
|
|
304
|
+
except httpx.RequestError as e:
|
|
305
|
+
console.print(f"[red]Connection error: {e}[/]")
|
|
306
|
+
raise SystemExit(1) from e
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _login_with_password(email: str, password: str) -> None:
|
|
310
|
+
"""Authenticate with email and password."""
|
|
311
|
+
api_url = get_api_url()
|
|
312
|
+
|
|
313
|
+
with Progress(
|
|
314
|
+
SpinnerColumn(),
|
|
315
|
+
TextColumn("[progress.description]{task.description}"),
|
|
316
|
+
console=console,
|
|
317
|
+
) as progress:
|
|
318
|
+
task = progress.add_task("Authenticating...", total=None)
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
response = httpx.post(
|
|
322
|
+
f"{api_url}/auth/login",
|
|
323
|
+
json={"email": email, "password": password},
|
|
324
|
+
timeout=30,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if response.status_code == 200:
|
|
328
|
+
data = response.json()
|
|
329
|
+
|
|
330
|
+
# Save credentials
|
|
331
|
+
save_credentials(
|
|
332
|
+
{
|
|
333
|
+
"api_key": data["api_key"],
|
|
334
|
+
"user_id": data["user"]["id"],
|
|
335
|
+
"email": data["user"]["email"],
|
|
336
|
+
"tier": data["user"].get("tier", "free"),
|
|
337
|
+
}
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
progress.update(task, completed=True)
|
|
341
|
+
|
|
342
|
+
console.print(
|
|
343
|
+
Panel(
|
|
344
|
+
f"[green]Successfully logged in![/]\n\n"
|
|
345
|
+
f"Email: [cyan]{data['user']['email']}[/]\n"
|
|
346
|
+
f"Tier: [cyan]{data['user'].get('tier', 'free')}[/]",
|
|
347
|
+
title="Login Successful",
|
|
348
|
+
)
|
|
349
|
+
)
|
|
350
|
+
elif response.status_code == 401:
|
|
351
|
+
console.print("[red]Invalid email or password[/]")
|
|
352
|
+
raise SystemExit(1)
|
|
353
|
+
else:
|
|
354
|
+
console.print(f"[red]Login failed: {response.text}[/]")
|
|
355
|
+
raise SystemExit(1)
|
|
356
|
+
except httpx.RequestError as e:
|
|
357
|
+
console.print(f"[red]Connection error: {e}[/]")
|
|
358
|
+
raise SystemExit(1) from e
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _login_with_device_code() -> None:
|
|
362
|
+
"""Authenticate using device code flow."""
|
|
363
|
+
api_url = get_api_url()
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
# Request device code
|
|
367
|
+
response = httpx.post(
|
|
368
|
+
f"{api_url}/auth/device/code",
|
|
369
|
+
json={"client_id": "codeshift-cli"},
|
|
370
|
+
timeout=30,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
if response.status_code != 200:
|
|
374
|
+
console.print(f"[red]Failed to initiate device flow: {response.text}[/]")
|
|
375
|
+
raise SystemExit(1)
|
|
376
|
+
|
|
377
|
+
data = response.json()
|
|
378
|
+
device_code = data["device_code"]
|
|
379
|
+
user_code = data["user_code"]
|
|
380
|
+
verification_uri = data["verification_uri"]
|
|
381
|
+
expires_in = data.get("expires_in", 900)
|
|
382
|
+
interval = data.get("interval", 5)
|
|
383
|
+
|
|
384
|
+
# Show code to user
|
|
385
|
+
console.print(
|
|
386
|
+
Panel(
|
|
387
|
+
f"[bold]To authenticate, visit:[/]\n\n"
|
|
388
|
+
f" [cyan]{verification_uri}[/]\n\n"
|
|
389
|
+
f"[bold]And enter this code:[/]\n\n"
|
|
390
|
+
f" [green bold]{user_code}[/]\n\n"
|
|
391
|
+
f"[dim]This code expires in {expires_in // 60} minutes.[/]",
|
|
392
|
+
title="Device Authentication",
|
|
393
|
+
)
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
# Try to open browser
|
|
397
|
+
if Confirm.ask("Open browser?", default=True):
|
|
398
|
+
webbrowser.open(verification_uri)
|
|
399
|
+
|
|
400
|
+
# Poll for completion
|
|
401
|
+
with Progress(
|
|
402
|
+
SpinnerColumn(),
|
|
403
|
+
TextColumn("[progress.description]{task.description}"),
|
|
404
|
+
console=console,
|
|
405
|
+
) as progress:
|
|
406
|
+
task = progress.add_task("Waiting for authentication...", total=None)
|
|
407
|
+
|
|
408
|
+
start_time = time.time()
|
|
409
|
+
while time.time() - start_time < expires_in:
|
|
410
|
+
time.sleep(interval)
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
response = httpx.post(
|
|
414
|
+
f"{api_url}/auth/device/token",
|
|
415
|
+
json={
|
|
416
|
+
"device_code": device_code,
|
|
417
|
+
"client_id": "codeshift-cli",
|
|
418
|
+
},
|
|
419
|
+
timeout=30,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
if response.status_code == 200:
|
|
423
|
+
data = response.json()
|
|
424
|
+
|
|
425
|
+
# Save credentials
|
|
426
|
+
save_credentials(
|
|
427
|
+
{
|
|
428
|
+
"api_key": data["api_key"],
|
|
429
|
+
"user_id": data["user"]["id"],
|
|
430
|
+
"email": data["user"]["email"],
|
|
431
|
+
"tier": data["user"].get("tier", "free"),
|
|
432
|
+
}
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
progress.update(task, completed=True)
|
|
436
|
+
|
|
437
|
+
console.print(
|
|
438
|
+
Panel(
|
|
439
|
+
f"[green]Successfully logged in![/]\n\n"
|
|
440
|
+
f"Email: [cyan]{data['user']['email']}[/]\n"
|
|
441
|
+
f"Tier: [cyan]{data['user'].get('tier', 'free')}[/]",
|
|
442
|
+
title="Login Successful",
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
return
|
|
446
|
+
elif response.status_code == 428:
|
|
447
|
+
# Authorization pending, continue polling
|
|
448
|
+
continue
|
|
449
|
+
elif response.status_code == 403:
|
|
450
|
+
console.print("[red]Authorization denied[/]")
|
|
451
|
+
raise SystemExit(1)
|
|
452
|
+
else:
|
|
453
|
+
console.print(f"[red]Authentication failed: {response.text}[/]")
|
|
454
|
+
raise SystemExit(1)
|
|
455
|
+
except httpx.RequestError:
|
|
456
|
+
# Network error, retry
|
|
457
|
+
continue
|
|
458
|
+
|
|
459
|
+
console.print("[red]Device code expired. Please try again.[/]")
|
|
460
|
+
raise SystemExit(1)
|
|
461
|
+
|
|
462
|
+
except httpx.RequestError as e:
|
|
463
|
+
console.print(f"[red]Connection error: {e}[/]")
|
|
464
|
+
raise SystemExit(1) from e
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
@click.command()
|
|
468
|
+
def logout() -> None:
|
|
469
|
+
"""Logout from PyResolve and remove saved credentials."""
|
|
470
|
+
creds = load_credentials()
|
|
471
|
+
|
|
472
|
+
if not creds:
|
|
473
|
+
console.print("[yellow]Not logged in[/]")
|
|
474
|
+
return
|
|
475
|
+
|
|
476
|
+
# Revoke the API key on the server
|
|
477
|
+
api_key = creds.get("api_key")
|
|
478
|
+
if api_key:
|
|
479
|
+
try:
|
|
480
|
+
api_url = get_api_url()
|
|
481
|
+
httpx.post(
|
|
482
|
+
f"{api_url}/auth/logout",
|
|
483
|
+
headers={"X-API-Key": api_key},
|
|
484
|
+
timeout=30,
|
|
485
|
+
)
|
|
486
|
+
# Ignore errors - just try to revoke
|
|
487
|
+
except httpx.RequestError:
|
|
488
|
+
pass
|
|
489
|
+
|
|
490
|
+
# Delete local credentials
|
|
491
|
+
delete_credentials()
|
|
492
|
+
|
|
493
|
+
console.print("[green]Successfully logged out[/]")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@click.command()
|
|
497
|
+
def whoami() -> None:
|
|
498
|
+
"""Show current authentication status and user info."""
|
|
499
|
+
creds = load_credentials()
|
|
500
|
+
|
|
501
|
+
if not creds:
|
|
502
|
+
console.print(
|
|
503
|
+
Panel(
|
|
504
|
+
"[yellow]Not logged in[/]\n\n" "Run [cyan]codeshift login[/] to authenticate.",
|
|
505
|
+
title="Authentication Status",
|
|
506
|
+
)
|
|
507
|
+
)
|
|
508
|
+
return
|
|
509
|
+
|
|
510
|
+
# Try to get fresh user info from API
|
|
511
|
+
api_key = creds.get("api_key")
|
|
512
|
+
if api_key:
|
|
513
|
+
try:
|
|
514
|
+
api_url = get_api_url()
|
|
515
|
+
response = httpx.get(
|
|
516
|
+
f"{api_url}/auth/me",
|
|
517
|
+
headers={"X-API-Key": api_key},
|
|
518
|
+
timeout=30,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
if response.status_code == 200:
|
|
522
|
+
user = response.json()
|
|
523
|
+
|
|
524
|
+
# Update cached credentials
|
|
525
|
+
creds["email"] = user.get("email")
|
|
526
|
+
creds["tier"] = user.get("tier", "free")
|
|
527
|
+
creds["user_id"] = user.get("id")
|
|
528
|
+
save_credentials(creds)
|
|
529
|
+
|
|
530
|
+
console.print(
|
|
531
|
+
Panel(
|
|
532
|
+
f"[green]Logged in[/]\n\n"
|
|
533
|
+
f"Email: [cyan]{user.get('email')}[/]\n"
|
|
534
|
+
f"Tier: [cyan]{user.get('tier', 'free')}[/]\n"
|
|
535
|
+
f"User ID: [dim]{user.get('id')}[/]",
|
|
536
|
+
title="Authentication Status",
|
|
537
|
+
)
|
|
538
|
+
)
|
|
539
|
+
return
|
|
540
|
+
except httpx.RequestError:
|
|
541
|
+
pass
|
|
542
|
+
|
|
543
|
+
# Fall back to cached info
|
|
544
|
+
console.print(
|
|
545
|
+
Panel(
|
|
546
|
+
f"[green]Logged in[/] [dim](cached)[/]\n\n"
|
|
547
|
+
f"Email: [cyan]{creds.get('email', 'unknown')}[/]\n"
|
|
548
|
+
f"Tier: [cyan]{creds.get('tier', 'free')}[/]",
|
|
549
|
+
title="Authentication Status",
|
|
550
|
+
)
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
@click.command()
|
|
555
|
+
def quota() -> None:
|
|
556
|
+
"""Show current usage quota and limits."""
|
|
557
|
+
api_key = get_api_key()
|
|
558
|
+
|
|
559
|
+
if not api_key:
|
|
560
|
+
console.print(
|
|
561
|
+
Panel(
|
|
562
|
+
"[yellow]Not logged in[/]\n\n"
|
|
563
|
+
"Run [cyan]codeshift login[/] to authenticate and view quota.\n\n"
|
|
564
|
+
"[dim]Free tier limits apply for unauthenticated usage.[/]",
|
|
565
|
+
title="Usage Quota",
|
|
566
|
+
)
|
|
567
|
+
)
|
|
568
|
+
return
|
|
569
|
+
|
|
570
|
+
try:
|
|
571
|
+
api_url = get_api_url()
|
|
572
|
+
response = httpx.get(
|
|
573
|
+
f"{api_url}/usage/quota",
|
|
574
|
+
headers={"X-API-Key": api_key},
|
|
575
|
+
timeout=30,
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
if response.status_code == 200:
|
|
579
|
+
data = response.json()
|
|
580
|
+
|
|
581
|
+
# Build progress bars
|
|
582
|
+
files_bar = _progress_bar(
|
|
583
|
+
data["files_migrated"],
|
|
584
|
+
data["files_limit"],
|
|
585
|
+
data["files_percentage"],
|
|
586
|
+
)
|
|
587
|
+
llm_bar = _progress_bar(
|
|
588
|
+
data["llm_calls"],
|
|
589
|
+
data["llm_calls_limit"],
|
|
590
|
+
data["llm_calls_percentage"],
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
table = Table(show_header=True, header_style="bold")
|
|
594
|
+
table.add_column("Resource")
|
|
595
|
+
table.add_column("Used")
|
|
596
|
+
table.add_column("Limit")
|
|
597
|
+
table.add_column("Progress", width=20)
|
|
598
|
+
|
|
599
|
+
table.add_row(
|
|
600
|
+
"File Migrations",
|
|
601
|
+
str(data["files_migrated"]),
|
|
602
|
+
str(data["files_limit"]),
|
|
603
|
+
files_bar,
|
|
604
|
+
)
|
|
605
|
+
table.add_row(
|
|
606
|
+
"LLM Calls",
|
|
607
|
+
str(data["llm_calls"]),
|
|
608
|
+
str(data["llm_calls_limit"]),
|
|
609
|
+
llm_bar,
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
console.print(
|
|
613
|
+
Panel(
|
|
614
|
+
table,
|
|
615
|
+
title=f"Usage Quota - {data['tier'].title()} Tier ({data['billing_period']})",
|
|
616
|
+
)
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
# Show upgrade prompt if near limit
|
|
620
|
+
if data["files_percentage"] > 80 or data["llm_calls_percentage"] > 80:
|
|
621
|
+
if data["tier"] == "free":
|
|
622
|
+
console.print(
|
|
623
|
+
"\n[yellow]Running low on quota?[/] "
|
|
624
|
+
"Run [cyan]codeshift upgrade-plan[/] to see upgrade options."
|
|
625
|
+
)
|
|
626
|
+
elif response.status_code == 401:
|
|
627
|
+
console.print("[red]Invalid credentials. Please run [cyan]codeshift login[/] again.[/]")
|
|
628
|
+
raise SystemExit(1)
|
|
629
|
+
else:
|
|
630
|
+
console.print(f"[red]Failed to get quota: {response.text}[/]")
|
|
631
|
+
raise SystemExit(1)
|
|
632
|
+
|
|
633
|
+
except httpx.RequestError as e:
|
|
634
|
+
console.print(f"[red]Connection error: {e}[/]")
|
|
635
|
+
# Show offline fallback
|
|
636
|
+
creds = load_credentials()
|
|
637
|
+
if creds:
|
|
638
|
+
console.print("\n[dim]Showing cached information:[/]")
|
|
639
|
+
console.print(f" Tier: [cyan]{creds.get('tier', 'free')}[/]")
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _progress_bar(current: int, total: int, percentage: float) -> str:
|
|
643
|
+
"""Generate a text-based progress bar."""
|
|
644
|
+
width = 15
|
|
645
|
+
filled = int(width * percentage / 100)
|
|
646
|
+
empty = width - filled
|
|
647
|
+
|
|
648
|
+
if percentage >= 90:
|
|
649
|
+
color = "red"
|
|
650
|
+
elif percentage >= 70:
|
|
651
|
+
color = "yellow"
|
|
652
|
+
else:
|
|
653
|
+
color = "green"
|
|
654
|
+
|
|
655
|
+
bar = f"[{color}]{'█' * filled}{'░' * empty}[/] {percentage:.0f}%"
|
|
656
|
+
return bar
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
@click.command("upgrade-plan")
|
|
660
|
+
@click.option("--tier", "-t", type=click.Choice(["pro", "unlimited"]), help="Tier to upgrade to")
|
|
661
|
+
def upgrade_plan(tier: str | None) -> None:
|
|
662
|
+
"""Show available plans or upgrade to a paid tier.
|
|
663
|
+
|
|
664
|
+
\b
|
|
665
|
+
Examples:
|
|
666
|
+
codeshift upgrade-plan # Show all plans
|
|
667
|
+
codeshift upgrade-plan --tier pro # Upgrade to Pro tier
|
|
668
|
+
"""
|
|
669
|
+
api_key = get_api_key()
|
|
670
|
+
api_url = get_api_url()
|
|
671
|
+
|
|
672
|
+
# If tier specified and logged in, initiate checkout
|
|
673
|
+
if tier and api_key:
|
|
674
|
+
_initiate_upgrade(api_url, api_key, tier)
|
|
675
|
+
return
|
|
676
|
+
|
|
677
|
+
# Otherwise show available tiers
|
|
678
|
+
try:
|
|
679
|
+
response = httpx.get(f"{api_url}/billing/tiers", timeout=30)
|
|
680
|
+
|
|
681
|
+
if response.status_code == 200:
|
|
682
|
+
tiers_data = response.json()
|
|
683
|
+
|
|
684
|
+
console.print("\n[bold]Available Plans[/]\n")
|
|
685
|
+
|
|
686
|
+
for t in tiers_data:
|
|
687
|
+
if t["name"] == "enterprise":
|
|
688
|
+
price = "Custom"
|
|
689
|
+
elif t["price_monthly"] == 0:
|
|
690
|
+
price = "Free"
|
|
691
|
+
else:
|
|
692
|
+
price = f"${t['price_monthly'] / 100:.0f}/mo"
|
|
693
|
+
|
|
694
|
+
console.print(f"[bold cyan]{t['display_name']}[/] - {price}")
|
|
695
|
+
console.print(f" Files: {t['files_per_month']:,}/mo")
|
|
696
|
+
console.print(f" LLM Calls: {t['llm_calls_per_month']:,}/mo")
|
|
697
|
+
for feature in t["features"]:
|
|
698
|
+
console.print(f" • {feature}")
|
|
699
|
+
console.print()
|
|
700
|
+
|
|
701
|
+
if api_key:
|
|
702
|
+
console.print(
|
|
703
|
+
"[green]To upgrade, run:[/]\n"
|
|
704
|
+
" [cyan]codeshift upgrade-plan --tier pro[/]\n"
|
|
705
|
+
" [cyan]codeshift upgrade-plan --tier unlimited[/]"
|
|
706
|
+
)
|
|
707
|
+
else:
|
|
708
|
+
console.print("[yellow]Login first to upgrade:[/]\n" " [cyan]codeshift login[/]")
|
|
709
|
+
else:
|
|
710
|
+
console.print("[red]Failed to load pricing information[/]")
|
|
711
|
+
|
|
712
|
+
except httpx.RequestError as e:
|
|
713
|
+
console.print(f"[red]Connection error: {e}[/]")
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _initiate_upgrade(api_url: str, api_key: str, tier: str) -> None:
|
|
717
|
+
"""Create checkout session and open in browser."""
|
|
718
|
+
with Progress(
|
|
719
|
+
SpinnerColumn(),
|
|
720
|
+
TextColumn("[progress.description]{task.description}"),
|
|
721
|
+
console=console,
|
|
722
|
+
) as progress:
|
|
723
|
+
task = progress.add_task("Creating checkout session...", total=None)
|
|
724
|
+
|
|
725
|
+
try:
|
|
726
|
+
response = httpx.post(
|
|
727
|
+
f"{api_url}/billing/checkout",
|
|
728
|
+
headers={"X-API-Key": api_key},
|
|
729
|
+
json={
|
|
730
|
+
"tier": tier,
|
|
731
|
+
"success_url": "https://codeshift.dev/upgrade/success",
|
|
732
|
+
"cancel_url": "https://codeshift.dev/upgrade/cancel",
|
|
733
|
+
},
|
|
734
|
+
timeout=30,
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
if response.status_code == 200:
|
|
738
|
+
data = response.json()
|
|
739
|
+
checkout_url = data["checkout_url"]
|
|
740
|
+
|
|
741
|
+
progress.update(task, completed=True)
|
|
742
|
+
|
|
743
|
+
console.print(
|
|
744
|
+
Panel(
|
|
745
|
+
f"[green]Opening checkout in your browser...[/]\n\n"
|
|
746
|
+
f"Upgrading to: [cyan]{tier.title()}[/]\n\n"
|
|
747
|
+
f"[dim]If the browser doesn't open, visit:[/]\n"
|
|
748
|
+
f"[link={checkout_url}]{checkout_url[:60]}...[/]",
|
|
749
|
+
title="Checkout",
|
|
750
|
+
)
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
# Open browser
|
|
754
|
+
webbrowser.open(checkout_url)
|
|
755
|
+
|
|
756
|
+
console.print(
|
|
757
|
+
"\n[dim]After completing payment, your account will be "
|
|
758
|
+
"automatically upgraded.[/]\n"
|
|
759
|
+
"[dim]Run [cyan]codeshift whoami[/] to verify your new tier.[/]"
|
|
760
|
+
)
|
|
761
|
+
elif response.status_code == 401:
|
|
762
|
+
console.print("[red]Session expired. Please run [cyan]codeshift login[/] again.[/]")
|
|
763
|
+
raise SystemExit(1)
|
|
764
|
+
elif response.status_code == 500:
|
|
765
|
+
detail = response.json().get("detail", "Unknown error")
|
|
766
|
+
if "not configured" in detail.lower():
|
|
767
|
+
console.print(
|
|
768
|
+
"[yellow]Stripe payments are not yet configured.[/]\n"
|
|
769
|
+
"Please visit [cyan]https://codeshift.dev/pricing[/] to upgrade."
|
|
770
|
+
)
|
|
771
|
+
else:
|
|
772
|
+
console.print(f"[red]Checkout failed: {detail}[/]")
|
|
773
|
+
raise SystemExit(1)
|
|
774
|
+
else:
|
|
775
|
+
console.print(f"[red]Failed to create checkout: {response.text}[/]")
|
|
776
|
+
raise SystemExit(1)
|
|
777
|
+
|
|
778
|
+
except httpx.RequestError as e:
|
|
779
|
+
console.print(f"[red]Connection error: {e}[/]")
|
|
780
|
+
raise SystemExit(1) from e
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
@click.command("billing")
|
|
784
|
+
def billing() -> None:
|
|
785
|
+
"""Open Stripe billing portal to manage your subscription."""
|
|
786
|
+
api_key = get_api_key()
|
|
787
|
+
|
|
788
|
+
if not api_key:
|
|
789
|
+
console.print(
|
|
790
|
+
"[yellow]Not logged in.[/]\n" "Run [cyan]codeshift login[/] to authenticate first."
|
|
791
|
+
)
|
|
792
|
+
raise SystemExit(1)
|
|
793
|
+
|
|
794
|
+
api_url = get_api_url()
|
|
795
|
+
|
|
796
|
+
with Progress(
|
|
797
|
+
SpinnerColumn(),
|
|
798
|
+
TextColumn("[progress.description]{task.description}"),
|
|
799
|
+
console=console,
|
|
800
|
+
) as progress:
|
|
801
|
+
task = progress.add_task("Opening billing portal...", total=None)
|
|
802
|
+
|
|
803
|
+
try:
|
|
804
|
+
response = httpx.get(
|
|
805
|
+
f"{api_url}/billing/portal",
|
|
806
|
+
headers={"X-API-Key": api_key},
|
|
807
|
+
timeout=30,
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
if response.status_code == 200:
|
|
811
|
+
data = response.json()
|
|
812
|
+
portal_url = data["portal_url"]
|
|
813
|
+
|
|
814
|
+
progress.update(task, completed=True)
|
|
815
|
+
|
|
816
|
+
console.print(
|
|
817
|
+
Panel(
|
|
818
|
+
"[green]Opening billing portal in your browser...[/]\n\n"
|
|
819
|
+
"You can:\n"
|
|
820
|
+
" • Update payment method\n"
|
|
821
|
+
" • View invoices\n"
|
|
822
|
+
" • Change or cancel subscription",
|
|
823
|
+
title="Billing Portal",
|
|
824
|
+
)
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
webbrowser.open(portal_url)
|
|
828
|
+
elif response.status_code == 400:
|
|
829
|
+
console.print(
|
|
830
|
+
"[yellow]No billing account found.[/]\n"
|
|
831
|
+
"Run [cyan]codeshift upgrade-plan --tier pro[/] to subscribe first."
|
|
832
|
+
)
|
|
833
|
+
elif response.status_code == 401:
|
|
834
|
+
console.print("[red]Session expired. Please run [cyan]codeshift login[/] again.[/]")
|
|
835
|
+
raise SystemExit(1)
|
|
836
|
+
else:
|
|
837
|
+
console.print(f"[red]Failed to open billing portal: {response.text}[/]")
|
|
838
|
+
raise SystemExit(1)
|
|
839
|
+
|
|
840
|
+
except httpx.RequestError as e:
|
|
841
|
+
console.print(f"[red]Connection error: {e}[/]")
|
|
842
|
+
raise SystemExit(1) from e
|