@@ -2,7 +2,11 @@ package cmd
22
33import (
44 "context"
5+ "crypto/rand"
6+ "encoding/hex"
57 "fmt"
8+ "net"
9+ "net/http"
610 "os"
711 "strings"
812 "time"
@@ -15,6 +19,8 @@ import (
1519 "golang.org/x/term"
1620)
1721
22+ var noBrowser bool
23+
1824var authCmd = & cobra.Command {
1925 Use : "auth" ,
2026 Short : "Manage Supermodel API authentication" ,
@@ -48,16 +54,161 @@ var authOpenCmd = &cobra.Command{
4854}
4955
5056func init () {
57+ authLoginCmd .Flags ().BoolVar (& noBrowser , "no-browser" , false , "Skip browser-based login and paste API key manually" )
5158 authCmd .AddCommand (authLoginCmd , authStatusCmd , authLogoutCmd , authOpenCmd )
5259 rootCmd .AddCommand (authCmd )
5360}
5461
62+ const callbackTimeout = 2 * time .Minute
63+
64+ const successHTML = `<!DOCTYPE html>
65+ <html>
66+ <head><title>Uncompact</title>
67+ <style>
68+ body { font-family: system-ui, sans-serif; display: flex; justify-content: center;
69+ align-items: center; min-height: 100vh; margin: 0; background: #0f172a; color: #e2e8f0; }
70+ .card { text-align: center; padding: 2rem; }
71+ h1 { color: #5eead4; margin-bottom: 0.5rem; }
72+ p { color: #94a3b8; }
73+ </style>
74+ </head>
75+ <body>
76+ <div class="card">
77+ <h1>Authenticated</h1>
78+ <p>You can close this tab and return to your terminal.</p>
79+ </div>
80+ </body>
81+ </html>`
82+
83+ func generateState () (string , error ) {
84+ b := make ([]byte , 16 )
85+ if _ , err := rand .Read (b ); err != nil {
86+ return "" , err
87+ }
88+ return hex .EncodeToString (b ), nil
89+ }
90+
5591func authLoginHandler (cmd * cobra.Command , args []string ) error {
5692 cfg , err := config .Load (apiKey )
5793 if err != nil {
5894 return err
5995 }
6096
97+ isTTY := term .IsTerminal (int (os .Stdin .Fd ()))
98+
99+ if noBrowser || ! isTTY {
100+ return authLoginManual (cfg )
101+ }
102+
103+ key , err := authLoginBrowser (cfg )
104+ if err != nil {
105+ fmt .Fprintf (os .Stderr , "[uncompact] Browser login failed: %v\n " , err )
106+ fmt .Println ()
107+ fmt .Println ("Falling back to manual login..." )
108+ fmt .Println ()
109+ return authLoginManual (cfg )
110+ }
111+
112+ return saveAndCacheKey (cfg , key )
113+ }
114+
115+ // authLoginBrowser starts a localhost callback server, opens the dashboard,
116+ // and waits for the key to arrive via redirect.
117+ func authLoginBrowser (cfg * config.Config ) (string , error ) {
118+ state , err := generateState ()
119+ if err != nil {
120+ return "" , fmt .Errorf ("generating state: %w" , err )
121+ }
122+
123+ listener , err := net .Listen ("tcp" , "127.0.0.1:0" )
124+ if err != nil {
125+ return "" , fmt .Errorf ("starting callback server: %w" , err )
126+ }
127+ port := listener .Addr ().(* net.TCPAddr ).Port
128+
129+ type callbackResult struct {
130+ key string
131+ err error
132+ }
133+ resultCh := make (chan callbackResult , 1 )
134+
135+ mux := http .NewServeMux ()
136+ mux .HandleFunc ("/callback" , func (w http.ResponseWriter , r * http.Request ) {
137+ if r .URL .Query ().Get ("state" ) != state {
138+ http .Error (w , "Invalid state parameter" , http .StatusForbidden )
139+ resultCh <- callbackResult {err : fmt .Errorf ("state mismatch (possible CSRF)" )}
140+ return
141+ }
142+
143+ key := r .URL .Query ().Get ("key" )
144+ if key == "" {
145+ errMsg := r .URL .Query ().Get ("error" )
146+ if errMsg == "" {
147+ errMsg = "no key received"
148+ }
149+ http .Error (w , errMsg , http .StatusBadRequest )
150+ resultCh <- callbackResult {err : fmt .Errorf ("dashboard returned error: %s" , errMsg )}
151+ return
152+ }
153+
154+ w .Header ().Set ("Content-Type" , "text/html; charset=utf-8" )
155+ w .WriteHeader (http .StatusOK )
156+ fmt .Fprint (w , successHTML )
157+
158+ resultCh <- callbackResult {key : key }
159+ })
160+
161+ server := & http.Server {Handler : mux }
162+ go func () {
163+ if err := server .Serve (listener ); err != nil && err != http .ErrServerClosed {
164+ resultCh <- callbackResult {err : fmt .Errorf ("callback server error: %w" , err )}
165+ }
166+ }()
167+ defer func () {
168+ ctx , cancel := context .WithTimeout (context .Background (), 2 * time .Second )
169+ defer cancel ()
170+ _ = server .Shutdown (ctx )
171+ }()
172+
173+ dashURL := fmt .Sprintf ("%s?port=%d&state=%s" , config .DashboardCLIAuthURL , port , state )
174+ fmt .Println ("Opening your browser to sign in..." )
175+ fmt .Printf (" %s\n \n " , dashURL )
176+ fmt .Println ("Waiting for authentication (this will timeout in 2 minutes)..." )
177+
178+ if err := browser .OpenURL (dashURL ); err != nil {
179+ return "" , fmt .Errorf ("opening browser: %w" , err )
180+ }
181+
182+ select {
183+ case result := <- resultCh :
184+ if result .err != nil {
185+ return "" , result .err
186+ }
187+ fmt .Println ()
188+ fmt .Print ("Validating key... " )
189+
190+ ctx , cancel := context .WithTimeout (context .Background (), 10 * time .Second )
191+ defer cancel ()
192+
193+ testClient := api .New (cfg .BaseURL , result .key , false , nil )
194+ identity , err := testClient .ValidateKey (ctx )
195+ if err != nil {
196+ fmt .Println ("✗" )
197+ return "" , fmt .Errorf ("key validation failed: %w" , err )
198+ }
199+ fmt .Println ("✓" )
200+ if identity != "" {
201+ fmt .Printf ("Authenticated as: %s\n " , identity )
202+ }
203+ return result .key , nil
204+
205+ case <- time .After (callbackTimeout ):
206+ return "" , fmt .Errorf ("timed out waiting for browser callback" )
207+ }
208+ }
209+
210+ // authLoginManual is the original paste-based login flow.
211+ func authLoginManual (cfg * config.Config ) error {
61212 fmt .Println ("Uncompact uses the Supermodel Public API." )
62213 fmt .Println ()
63214 fmt .Println ("1. Opening your browser to the Supermodel dashboard..." )
@@ -79,7 +230,6 @@ func authLoginHandler(cmd *cobra.Command, args []string) error {
79230 }
80231 key = strings .TrimSpace (string (b ))
81232 } else {
82- // Non-interactive fallback (e.g. piped input in CI)
83233 var raw string
84234 if _ , err := fmt .Fscanln (os .Stdin , & raw ); err != nil {
85235 return fmt .Errorf ("reading API key: %w" , err )
@@ -90,7 +240,6 @@ func authLoginHandler(cmd *cobra.Command, args []string) error {
90240 return fmt .Errorf ("API key cannot be empty" )
91241 }
92242
93- // Validate the key
94243 fmt .Print ("Validating key... " )
95244 ctx , cancel := context .WithTimeout (context .Background (), 10 * time .Second )
96245 defer cancel ()
@@ -101,7 +250,7 @@ func authLoginHandler(cmd *cobra.Command, args []string) error {
101250 fmt .Println ("✗" )
102251 if strings .Contains (err .Error (), "402" ) {
103252 fmt .Println ()
104- fmt .Println ("⚠️ SUBSCRIPTION REQUIRED" )
253+ fmt .Println ("SUBSCRIPTION REQUIRED" )
105254 fmt .Println (" The API key is valid, but your account requires an active subscription." )
106255 fmt .Printf (" Please visit: %s\n " , config .DashboardURL )
107256 fmt .Println ()
@@ -115,26 +264,28 @@ func authLoginHandler(cmd *cobra.Command, args []string) error {
115264 fmt .Printf ("Authenticated as: %s\n " , identity )
116265 }
117266
118- // Warn if environment variable is masking
267+ return saveAndCacheKey (cfg , key )
268+ }
269+
270+ // saveAndCacheKey encrypts and saves the key, then updates the auth cache.
271+ func saveAndCacheKey (cfg * config.Config , key string ) error {
119272 if os .Getenv (config .EnvAPIKey ) != "" {
120273 fmt .Println ()
121- fmt .Printf ("⚠️ WARNING : The environment variable %s is currently set.\n " , config .EnvAPIKey )
122- fmt .Println (" It will continue to override the API key you just saved to the config file." )
123- fmt .Println (" To use the new key, you must unset the environment variable or update it." )
274+ fmt .Printf ("NOTE : The environment variable %s is currently set.\n " , config .EnvAPIKey )
275+ fmt .Println (" It will continue to override the API key saved to the config file." )
276+ fmt .Println (" To use the new key, unset the environment variable or update it." )
124277 }
125278
126- // Save
127279 cfg .APIKey = key
128280 if err := config .Save (cfg ); err != nil {
129281 return fmt .Errorf ("saving config: %w" , err )
130282 }
131283
132- // Cache the auth status
133284 dbPath , err := config .DBPath ()
134285 if err == nil {
135286 if store , err := cache .Open (dbPath ); err == nil {
136287 defer store .Close ()
137- _ = store .SetAuthStatus (cfg .APIKeyHash (), identity )
288+ _ = store .SetAuthStatus (cfg .APIKeyHash (), "ok" )
138289 }
139290 }
140291
0 commit comments