22
33import { Command , Flags } from '@oclif/core' ;
44import { printHeader , printSuccess , printError , printKV } from '../../utils/format.js' ;
5- import { writeAuthConfig } from '../../utils/auth-config.js' ;
5+ import { writeAuthConfig , readAuthConfig } from '../../utils/auth-config.js' ;
66import { ObjectStackClient } from '@objectstack/client' ;
77import * as readline from 'node:readline/promises' ;
88import { stdin as input , stdout as output } from 'node:process' ;
@@ -35,7 +35,7 @@ async function promptPassword(promptText: string): Promise<string> {
3535
3636 const handler = ( char : string ) => {
3737 switch ( char ) {
38- case '\u0003 ' : // Ctrl+C
38+ case '' : // Ctrl+C
3939 cleanup ( ) ;
4040 process . kill ( process . pid , 'SIGINT' ) ;
4141 break ;
@@ -44,7 +44,7 @@ async function promptPassword(promptText: string): Promise<string> {
4444 cleanup ( ) ;
4545 resolve ( chars . join ( '' ) ) ;
4646 break ;
47- case '\u007f ' : // Backspace
47+ case ' ' : // Backspace
4848 if ( chars . length > 0 ) {
4949 chars . pop ( ) ;
5050 process . stdout . clearLine ( 0 ) ;
@@ -63,13 +63,24 @@ async function promptPassword(promptText: string): Promise<string> {
6363 } ) ;
6464}
6565
66+ /**
67+ * Open a URL in the system default browser (cross-platform).
68+ */
69+ async function openBrowser ( url : string ) : Promise < void > {
70+ const { exec } = await import ( 'node:child_process' ) ;
71+ const platform = process . platform ;
72+ const cmd = platform === 'darwin' ? `open "${ url } "` : platform === 'win32' ? `start "" "${ url } "` : `xdg-open "${ url } "` ;
73+ exec ( cmd , ( ) => { /* best-effort */ } ) ;
74+ }
75+
6676export default class AuthLogin extends Command {
6777 static override description = 'Authenticate and store session credentials' ;
6878
6979 static override examples = [
7080 '$ os auth login' ,
7181 '$ os auth login --url https://api.example.com' ,
7282 '$ os auth login --email user@example.com --password mypassword' ,
83+ '$ os auth login --no-browser' ,
7384 ] ;
7485
7586 static override flags = {
@@ -81,11 +92,20 @@ export default class AuthLogin extends Command {
8192 } ) ,
8293 email : Flags . string ( {
8394 char : 'e' ,
84- description : 'Email address' ,
95+ description : 'Email address (skips browser flow) ' ,
8596 } ) ,
8697 password : Flags . string ( {
8798 char : 'p' ,
88- description : 'Password' ,
99+ description : 'Password (skips browser flow)' ,
100+ } ) ,
101+ 'no-browser' : Flags . boolean ( {
102+ description : 'Print the login URL without opening a browser' ,
103+ default : false ,
104+ } ) ,
105+ force : Flags . boolean ( {
106+ char : 'f' ,
107+ description : 'Force login even if already authenticated' ,
108+ default : false ,
89109 } ) ,
90110 json : Flags . boolean ( {
91111 description : 'Output as JSON' ,
@@ -96,93 +116,192 @@ export default class AuthLogin extends Command {
96116 const { flags } = await this . parse ( AuthLogin ) ;
97117
98118 try {
119+ // --- Already logged in guard ---
120+ if ( ! flags . force ) {
121+ try {
122+ const existing = await readAuthConfig ( ) ;
123+ if ( existing ?. token ) {
124+ if ( flags . json ) {
125+ console . log ( JSON . stringify ( { success : false , error : 'Already logged in' , email : existing . email } ) ) ;
126+ } else {
127+ printSuccess ( `Already logged in as ${ existing . email || existing . userId } ` ) ;
128+ console . log ( '' ) ;
129+ console . log ( ' Run `os auth logout` first to switch accounts, or use --force to re-authenticate.' ) ;
130+ console . log ( '' ) ;
131+ }
132+ return ;
133+ }
134+ } catch {
135+ // No credentials stored — proceed with login
136+ }
137+ }
138+
99139 if ( ! flags . json ) {
100140 printHeader ( 'ObjectStack Login' ) ;
101141 printKV ( 'Server' , flags . url ) ;
102142 console . log ( '' ) ;
103143 }
104144
105- // Prompt for credentials if not provided
145+ const client = new ObjectStackClient ( { baseUrl : flags . url } ) ;
146+
147+ // --- CI / non-interactive path: email + password flags provided ---
148+ if ( flags . email && flags . password ) {
149+ await this . loginWithPassword ( client , flags . url , flags . email , flags . password , flags . json ) ;
150+ return ;
151+ }
152+
153+ // --- Interactive TTY: prompt style ---
154+ if ( process . stdin . isTTY && ! flags . email && ! flags . password ) {
155+ // Default: browser-based device flow
156+ await this . loginWithBrowser ( client , flags . url , flags [ 'no-browser' ] , flags . json ) ;
157+ return ;
158+ }
159+
160+ // --- Non-TTY fallback: prompt for email/password ---
161+ const rl = readline . createInterface ( { input, output } ) ;
106162 let email = flags . email ;
107163 let password = flags . password ;
108164
109- if ( ! email || ! password ) {
110- const rl = readline . createInterface ( { input, output } ) ;
111-
112- if ( ! email ) {
113- email = await rl . question ( 'Email: ' ) ;
114- }
165+ if ( ! email ) email = await rl . question ( 'Email: ' ) ;
166+ rl . close ( ) ;
167+ if ( ! password ) password = await promptPassword ( 'Password: ' ) ;
115168
116- rl . close ( ) ;
169+ if ( ! email || ! password ) throw new Error ( 'Email and password are required' ) ;
117170
118- if ( ! password ) {
119- password = await promptPassword ( 'Password: ' ) ;
120- }
171+ await this . loginWithPassword ( client , flags . url , email , password , flags . json ) ;
172+ } catch ( error : any ) {
173+ if ( flags . json ) {
174+ console . log ( JSON . stringify ( { success : false , error : error . message } , null , 2 ) ) ;
175+ this . exit ( 1 ) ;
121176 }
177+ printError ( error . message || String ( error ) ) ;
178+ this . exit ( 1 ) ;
179+ }
180+ }
122181
123- if ( ! email || ! password ) {
124- throw new Error ( 'Email and password are required' ) ;
125- }
182+ private async loginWithPassword (
183+ client : ObjectStackClient ,
184+ url : string ,
185+ email : string ,
186+ password : string ,
187+ jsonOutput ?: boolean ,
188+ ) : Promise < void > {
189+ const response = await client . auth . login ( { type : 'email' , email, password } ) ;
126190
127- // Create client and authenticate
128- const client = new ObjectStackClient ( {
129- baseUrl : flags . url ,
130- } ) ;
191+ if ( ! response . data ?. token && ! response . data ?. user ) {
192+ throw new Error ( 'Login failed: Invalid response from server' ) ;
193+ }
131194
132- const response = await client . auth . login ( {
133- type : 'email' ,
134- email,
135- password,
136- } ) ;
195+ const token = response . data ?. token || ( response as any ) . token ;
196+ const user = response . data ?. user ;
137197
138- // Check if login was successful
139- if ( ! response . data ?. token && ! response . data ?. user ) {
140- throw new Error ( 'Login failed: Invalid response from server' ) ;
141- }
198+ if ( ! token ) throw new Error ( 'Login failed: No token received from server' ) ;
142199
143- // Extract token - it might be in different locations depending on the auth system
144- const token = response . data ?. token || ( response as any ) . token ;
145- const user = response . data ?. user ;
200+ await writeAuthConfig ( {
201+ url,
202+ token,
203+ email : user ?. email || email ,
204+ userId : user ?. id ,
205+ createdAt : new Date ( ) . toISOString ( ) ,
206+ } ) ;
146207
147- if ( ! token ) {
148- throw new Error ( 'Login failed: No token received from server' ) ;
149- }
208+ if ( jsonOutput ) {
209+ console . log ( JSON . stringify ( { success : true , email : user ?. email || email , userId : user ?. id } , null , 2 ) ) ;
210+ } else {
211+ printSuccess ( 'Authentication successful' ) ;
212+ printKV ( 'Email' , user ?. email || email ) ;
213+ if ( user ?. id ) printKV ( 'User ID' , user . id ) ;
214+ console . log ( '' ) ;
215+ console . log ( ' Credentials stored in ~/.objectstack/credentials.json' ) ;
216+ console . log ( '' ) ;
217+ }
218+ }
150219
151- // Store credentials
152- await writeAuthConfig ( {
153- url : flags . url ,
154- token,
155- email : user ?. email || email ,
156- userId : user ?. id ,
157- createdAt : new Date ( ) . toISOString ( ) ,
158- } ) ;
220+ private async loginWithBrowser (
221+ _client : ObjectStackClient ,
222+ url : string ,
223+ noBrowser : boolean ,
224+ jsonOutput ?: boolean ,
225+ ) : Promise < void > {
226+ // Request a device code from the server
227+ const res = await globalThis . fetch ( `${ url } /api/v1/auth/device/request` , {
228+ method : 'POST' ,
229+ headers : { 'Content-Type' : 'application/json' } ,
230+ body : '{}' ,
231+ } ) ;
232+ if ( ! res . ok ) throw new Error ( `Device request failed: ${ res . status } ` ) ;
233+ const resJson = await res . json ( ) as any ;
234+ const deviceData = resJson ?. data ?? resJson ;
235+ const { code, verificationUrl, expiresAt, interval = 2 } = deviceData ?? { } ;
159236
160- if ( flags . json ) {
161- console . log ( JSON . stringify ( {
162- success : true ,
163- email : user ?. email || email ,
237+ if ( ! code || ! verificationUrl ) {
238+ throw new Error ( 'Server did not return a device code. Try `os auth login --email <email> --password <password>` instead.' ) ;
239+ }
240+
241+ if ( jsonOutput ) {
242+ console . log ( JSON . stringify ( { code, verificationUrl, expiresAt } ) ) ;
243+ } else {
244+ console . log ( ' Open the following URL to log in:' ) ;
245+ console . log ( '' ) ;
246+ console . log ( ` ${ verificationUrl } ` ) ;
247+ console . log ( '' ) ;
248+ }
249+
250+ if ( ! noBrowser && ! jsonOutput ) {
251+ await openBrowser ( verificationUrl ) ;
252+ console . log ( ' (Browser opened automatically. Press Ctrl+C to cancel.)' ) ;
253+ console . log ( '' ) ;
254+ }
255+
256+ // Poll for approval
257+ const pollMs = interval * 1000 ;
258+ const expiryTime = new Date ( expiresAt ) . getTime ( ) ;
259+ let spinner = 0 ;
260+ const spinChars = [ '⠋' , '⠙' , '⠹' , '⠸' , '⠼' , '⠴' , '⠦' , '⠧' , '⠇' , '⠏' ] ;
261+
262+ while ( Date . now ( ) < expiryTime ) {
263+ await new Promise ( r => setTimeout ( r , pollMs ) ) ;
264+
265+ const pollRes = await globalThis . fetch ( `${ url } /api/v1/auth/device/token?code=${ encodeURIComponent ( code ) } ` ) ;
266+ const pollJson = await pollRes . json ( ) as any ;
267+ const pollData = pollJson ?. data ?? pollJson ;
268+
269+ if ( pollData ?. status === 'approved' ) {
270+ const { token, user } = pollData ;
271+
272+ if ( ! jsonOutput ) process . stdout . write ( '\r\x1b[K' ) ; // clear spinner line
273+
274+ await writeAuthConfig ( {
275+ url,
276+ token,
277+ email : user ?. email ,
164278 userId : user ?. id ,
165- } , null , 2 ) ) ;
166- } else {
167- printSuccess ( 'Authentication successful' ) ;
168- printKV ( 'Email' , user ?. email || email ) ;
169- if ( user ?. id ) {
170- printKV ( 'User ID' , user . id ) ;
279+ createdAt : new Date ( ) . toISOString ( ) ,
280+ } ) ;
281+
282+ if ( jsonOutput ) {
283+ console . log ( JSON . stringify ( { success : true , email : user ?. email , userId : user ?. id } , null , 2 ) ) ;
284+ } else {
285+ printSuccess ( 'Authentication successful' ) ;
286+ if ( user ?. email ) printKV ( 'Email' , user . email ) ;
287+ if ( user ?. id ) printKV ( 'User ID' , user . id ) ;
288+ console . log ( '' ) ;
289+ console . log ( ' Credentials stored in ~/.objectstack/credentials.json' ) ;
290+ console . log ( '' ) ;
171291 }
172- console . log ( '' ) ;
173- console . log ( ' Credentials stored in ~/.objectstack/credentials.json' ) ;
174- console . log ( '' ) ;
292+ return ;
175293 }
176- } catch ( error : any ) {
177- if ( flags . json ) {
178- console . log ( JSON . stringify ( {
179- success : false ,
180- error : error . message ,
181- } , null , 2 ) ) ;
182- this . exit ( 1 ) ;
294+
295+ if ( pollData ?. status === 'expired' ) {
296+ throw new Error ( 'Login timed out. Please run `os auth login` again.' ) ;
297+ }
298+
299+ if ( ! jsonOutput ) {
300+ process . stdout . write ( `\r ${ spinChars [ spinner % spinChars . length ] } Waiting for browser approval...` ) ;
301+ spinner ++ ;
183302 }
184- printError ( error . message || String ( error ) ) ;
185- this . exit ( 1 ) ;
186303 }
304+
305+ throw new Error ( 'Login timed out. Please run `os auth login` again.' ) ;
187306 }
188307}
0 commit comments