@@ -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+
58139def 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
147262def 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+
194414def 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