Skip to content

Commit 72797d7

Browse files
committed
feat(cli): Add devsper cloud import-keys command to sync credentials
1 parent d839c0b commit 72797d7

2 files changed

Lines changed: 506 additions & 111 deletions

File tree

devsper/cli/commands/cloud.py

Lines changed: 270 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ def _builder_from_args(
3434
) -> PlatformAPIRequestBuilder:
3535
cs = CredentialStore()
3636
base = (api_url or _default_api_url() or "http://localhost:8080").rstrip("/")
37-
org_slug = (org or os.environ.get("DEVSPER_PLATFORM_ORG") or cs.get("platform", "org") or "").strip()
37+
org_slug = (
38+
org or os.environ.get("DEVSPER_PLATFORM_ORG") or cs.get("platform", "org") or ""
39+
).strip()
3840
tok = (
3941
token
4042
or os.environ.get("DEVSPER_PLATFORM_TOKEN")
@@ -55,52 +57,149 @@ def _pick_org_slug(orgs: list[dict[str, Any]], explicit: str | None) -> str:
5557
return ""
5658

5759

60+
def _do_browser_login(api_url: str) -> dict[str, Any] | None:
61+
import http.server
62+
import socketserver
63+
import webbrowser
64+
import urllib.parse
65+
import json
66+
67+
# We will run a local server
68+
class RequestHandler(http.server.SimpleHTTPRequestHandler):
69+
def do_OPTIONS(self):
70+
self.send_response(200)
71+
self.send_header("Access-Control-Allow-Origin", "*")
72+
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
73+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
74+
self.end_headers()
75+
76+
def do_POST(self):
77+
if self.path == "/callback":
78+
content_length = int(self.headers.get("Content-Length", 0))
79+
post_data = self.rfile.read(content_length)
80+
try:
81+
data = json.loads(post_data.decode("utf-8"))
82+
self.server.login_data = data
83+
self.send_response(200)
84+
self.send_header("Access-Control-Allow-Origin", "*")
85+
self.end_headers()
86+
self.wfile.write(b'{"status":"ok"}')
87+
except Exception as e:
88+
self.send_response(400)
89+
self.send_header("Access-Control-Allow-Origin", "*")
90+
self.end_headers()
91+
self.wfile.write(b'{"error":"bad_request"}')
92+
else:
93+
self.send_response(404)
94+
self.end_headers()
95+
96+
def log_message(self, format, *args):
97+
pass
98+
99+
with socketserver.TCPServer(("127.0.0.1", 0), RequestHandler) as httpd:
100+
port = httpd.server_address[1]
101+
httpd.login_data = None
102+
103+
# Open browser to the web app
104+
web_url = api_url.replace(":8080", ":5173") # Hacky local replacement
105+
if "api." in web_url:
106+
web_url = web_url.replace("api.", "app.")
107+
108+
login_url = f"{web_url}/cli-login?port={port}"
109+
110+
from rich.panel import Panel
111+
from rich.align import Align
112+
113+
console.print()
114+
console.print(
115+
Panel(
116+
f"Please authenticate in your browser.\n\n"
117+
f"[cyan underline]{login_url}[/]\n\n"
118+
"[dim]If your browser does not open automatically, click the link above.[/dim]",
119+
title="[bold blue]☁️ Devsper Cloud Login[/bold blue]",
120+
border_style="blue",
121+
expand=False,
122+
)
123+
)
124+
console.print()
125+
126+
webbrowser.open(login_url)
127+
128+
with console.status(
129+
"[bold cyan]Waiting for authentication in browser...[/bold cyan]",
130+
spinner="dots",
131+
):
132+
# Wait for the callback (blocking)
133+
while httpd.login_data is None:
134+
httpd.handle_request()
135+
136+
return httpd.login_data
137+
138+
58139
def cmd_cloud_login(args: Any) -> int:
59-
api_url = (getattr(args, "api_url", None) or "").strip().rstrip("/") or _default_api_url() or "http://localhost:8080"
140+
api_url = (
141+
(getattr(args, "api_url", None) or "").strip().rstrip("/")
142+
or _default_api_url()
143+
or "http://localhost:8080"
144+
)
60145
email = (getattr(args, "email", None) or "").strip()
61-
if not email:
62-
console.print("[red]--email is required.[/red]")
63-
return 1
64-
password = getattr(args, "password", None) or ""
65-
if not password:
66-
password = getpass.getpass("Password: ")
67146

68-
try:
69-
with httpx.Client(timeout=60.0) as client:
70-
r = client.post(
71-
f"{api_url}/auth/login",
72-
json={"email": email, "password": password},
73-
headers={"Content-Type": "application/json"},
74-
)
75-
except httpx.RequestError as e:
76-
console.print(f"[red]Could not reach platform API:[/red] {e}")
77-
return 1
147+
access = ""
148+
refresh = ""
149+
150+
if not email:
151+
# Browser login flow
152+
data = _do_browser_login(api_url)
153+
if not data or not data.get("token"):
154+
console.print("[red]Browser login failed or cancelled.[/red]")
155+
return 1
156+
access = data["token"]
157+
refresh = data.get("refresh_token", "")
158+
else:
159+
password = getattr(args, "password", None) or ""
160+
if not password:
161+
password = getpass.getpass("Password: ")
78162

79-
if r.status_code == 403:
80163
try:
81-
err = r.json().get("error", "")
82-
except Exception:
83-
err = ""
84-
if err == "email_not_verified":
164+
with httpx.Client(timeout=60.0) as client:
165+
r = client.post(
166+
f"{api_url}/auth/login",
167+
json={"email": email, "password": password},
168+
headers={"Content-Type": "application/json"},
169+
)
170+
except httpx.RequestError as e:
171+
console.print(f"[red]Could not reach platform API:[/red] {e}")
172+
return 1
173+
174+
if r.status_code == 403:
175+
try:
176+
err = r.json().get("error", "")
177+
except Exception:
178+
err = ""
179+
if err == "email_not_verified":
180+
console.print(
181+
"[yellow]Email not verified.[/yellow] Open Mailhog at http://localhost:8025 (local compose) "
182+
"and complete verification, or set EMAIL_VERIFICATION_ENABLED=false for local dev."
183+
)
184+
return 1
185+
if r.status_code != 200:
85186
console.print(
86-
"[yellow]Email not verified.[/yellow] Open Mailhog at http://localhost:8025 (local compose) "
87-
"and complete verification, or set EMAIL_VERIFICATION_ENABLED=false for local dev."
187+
f"[red]Login failed[/red] HTTP {r.status_code}: {r.text[:500]}"
88188
)
89189
return 1
90-
if r.status_code != 200:
91-
console.print(f"[red]Login failed[/red] HTTP {r.status_code}: {r.text[:500]}")
92-
return 1
93190

94-
data = r.json()
95-
if data.get("mfa_required"):
96-
console.print("[red]This account has MFA enabled.[/red] Use a token from the web app or add MFA support to the CLI.")
97-
return 1
191+
data = r.json()
192+
if data.get("mfa_required"):
193+
console.print(
194+
"[red]This account has MFA enabled.[/red] Use a token from the web app or add MFA support to the CLI."
195+
)
196+
return 1
98197

99-
access = (data.get("access_token") or "").strip()
100-
refresh = (data.get("refresh_token") or "").strip()
101-
if not access:
102-
console.print("[red]No access_token in response.[/red]")
103-
return 1
198+
access = (data.get("access_token") or "").strip()
199+
refresh = (data.get("refresh_token") or "").strip()
200+
if not access:
201+
console.print("[red]No access_token in response.[/red]")
202+
return 1
104203

105204
try:
106205
with httpx.Client(timeout=60.0) as client:
@@ -122,7 +221,9 @@ def cmd_cloud_login(args: Any) -> int:
122221
orgs = []
123222
org_slug = _pick_org_slug(orgs, getattr(args, "org", None))
124223
if not org_slug:
125-
console.print("[red]No org slug available. Create an org via the API or pass --org.[/red]")
224+
console.print(
225+
"[red]No org slug available. Create an org via the API or pass --org.[/red]"
226+
)
126227
return 1
127228

128229
cs = CredentialStore()
@@ -140,17 +241,48 @@ def cmd_cloud_login(args: Any) -> int:
140241
os.environ["DEVSPER_PLATFORM_ORG"] = org_slug
141242
os.environ["DEVSPER_PLATFORM_TOKEN"] = access
142243

143-
console.print(f"[green]Logged in.[/green] api={api_url} org={org_slug}")
244+
from rich.panel import Panel
245+
246+
console.print()
247+
user_email = me_body.get("email") or me_body.get("name") or "User"
248+
console.print(
249+
Panel(
250+
f"Successfully authenticated as [bold cyan]{user_email}[/bold cyan]!\n\n"
251+
f"[dim]API URL:[/dim] {api_url}\n"
252+
f"[dim]Organization:[/dim] [bold green]{org_slug}[/bold green]",
253+
title="[bold green]✅ Login Complete[/bold green]",
254+
border_style="green",
255+
expand=False,
256+
)
257+
)
258+
console.print()
144259
return 0
145260

146261

147262
def cmd_cloud_logout(_args: Any) -> int:
148263
cs = CredentialStore()
149264
for key in ("api_url", "org", "token", "refresh_token"):
150265
cs.delete("platform", key)
151-
for env in ("DEVSPER_PLATFORM_API_URL", "DEVSPER_PLATFORM_ORG", "DEVSPER_PLATFORM_TOKEN"):
266+
for env in (
267+
"DEVSPER_PLATFORM_API_URL",
268+
"DEVSPER_PLATFORM_ORG",
269+
"DEVSPER_PLATFORM_TOKEN",
270+
):
152271
os.environ.pop(env, None)
153-
console.print("[dim]Cloud credentials cleared from keyring.[/dim]")
272+
273+
from rich.panel import Panel
274+
275+
console.print()
276+
console.print(
277+
Panel(
278+
"You have been successfully logged out.\n\n"
279+
"[dim]Cloud credentials cleared from keychain.[/dim]",
280+
title="[bold yellow]👋 Logged Out[/bold yellow]",
281+
border_style="yellow",
282+
expand=False,
283+
)
284+
)
285+
console.print()
154286
return 0
155287

156288

@@ -162,7 +294,9 @@ def _load_json_file(path: str) -> dict[str, Any]:
162294
return data
163295

164296

165-
def _build_manifest_and_config(args: Any) -> tuple[dict[str, Any], dict[str, Any], str | None]:
297+
def _build_manifest_and_config(
298+
args: Any,
299+
) -> tuple[dict[str, Any], dict[str, Any], str | None]:
166300
manifest: dict[str, Any] = {}
167301
config: dict[str, Any] = {}
168302
manifest_version: str | None = None
@@ -191,6 +325,92 @@ def _build_manifest_and_config(args: Any) -> tuple[dict[str, Any], dict[str, Any
191325
return manifest, config, manifest_version
192326

193327

328+
def cmd_cloud_import_keys(args: Any) -> int:
329+
from devsper.credentials import list_credentials, get_credential
330+
331+
api = _builder_from_args(
332+
getattr(args, "api_url", None),
333+
getattr(args, "org", None),
334+
getattr(args, "token", None),
335+
)
336+
if not api.enabled():
337+
console.print(
338+
"[red]Platform not configured.[/red] Run [bold]devsper cloud login[/bold] or set "
339+
"DEVSPER_PLATFORM_API_URL + DEVSPER_PLATFORM_ORG + DEVSPER_PLATFORM_TOKEN."
340+
)
341+
return 1
342+
343+
if not api.org_slug:
344+
console.print(
345+
"[red]No org specified.[/red] Run login again or set DEVSPER_PLATFORM_ORG."
346+
)
347+
return 1
348+
349+
creds = list_credentials()
350+
if not creds:
351+
console.print(
352+
"No local credentials found. Use [bold]devsper credentials set[/bold] first."
353+
)
354+
return 0
355+
356+
target_provider = getattr(args, "provider", None)
357+
if target_provider:
358+
target_provider = target_provider.strip().lower()
359+
360+
success_count = 0
361+
from rich.panel import Panel
362+
363+
console.print(
364+
f"Importing local credentials to platform org: [bold cyan]{api.org_slug}[/bold cyan]\n"
365+
)
366+
367+
for c in creds:
368+
provider = c["provider"]
369+
key = c["key"]
370+
371+
if target_provider and provider != target_provider:
372+
continue
373+
374+
if key not in ("api_key", "token"):
375+
# Only push the main API key / token to the platform for now
376+
continue
377+
378+
val = get_credential(provider, key)
379+
if not val:
380+
continue
381+
382+
try:
383+
# The platform API expects PUT /orgs/{slug}/provider-keys/{provider} with {"key": val}
384+
api.request(
385+
"PUT",
386+
f"/orgs/{api.org_slug}/provider-keys/{provider}",
387+
json_body={"key": val},
388+
)
389+
console.print(f"✅ [green]Successfully imported {provider} ({key})[/green]")
390+
success_count += 1
391+
except Exception as e:
392+
console.print(f"❌ [red]Failed to import {provider}: {e}[/red]")
393+
394+
if success_count > 0:
395+
console.print()
396+
console.print(
397+
Panel(
398+
f"Successfully synced {success_count} provider key(s) to Devsper Cloud.\n"
399+
"Your backend workers can now use these keys for LLM completions.",
400+
title="[bold green]Import Complete[/bold green]",
401+
expand=False,
402+
)
403+
)
404+
else:
405+
if target_provider:
406+
console.print(
407+
f"No suitable credentials found for provider '{target_provider}'."
408+
)
409+
else:
410+
console.print("No supported provider keys found to import.")
411+
return 0
412+
413+
194414
def cmd_cloud_run(args: Any) -> int:
195415
task = (getattr(args, "task", None) or "").strip()
196416
if not task:
@@ -241,7 +461,11 @@ def cmd_cloud_run(args: Any) -> int:
241461

242462
if no_wait:
243463
if json_out:
244-
print(json.dumps({"run_id": run_id, "status": created.get("status", "pending")}))
464+
print(
465+
json.dumps(
466+
{"run_id": run_id, "status": created.get("status", "pending")}
467+
)
468+
)
245469
else:
246470
console.print(f"[green]Queued[/green] run_id={run_id}")
247471
return 0
@@ -262,7 +486,9 @@ def cmd_cloud_run(args: Any) -> int:
262486

263487
status = str(final.get("status") or "")
264488
if json_out:
265-
print(json.dumps({"run_id": run_id, "status": status, "raw": final}, default=str))
489+
print(
490+
json.dumps({"run_id": run_id, "status": status, "raw": final}, default=str)
491+
)
266492
else:
267493
console.print(f"run_id={run_id} status={status}")
268494
result = final.get("result")

0 commit comments

Comments
 (0)