@@ -16,6 +16,24 @@ export interface GitHatAnswers {
1616 authFeatures : AuthFeature [ ] ;
1717}
1818
19+ interface DeviceCodeResponse {
20+ device_code : string ;
21+ user_code : string ;
22+ verification_uri : string ;
23+ verification_uri_complete : string ;
24+ expires_in : number ;
25+ interval : number ;
26+ }
27+
28+ interface DeviceTokenResponse {
29+ error ?: 'authorization_pending' | 'expired_token' ;
30+ publishable_key ?: string ;
31+ app_id ?: string ;
32+ app_name ?: string ;
33+ org_id ?: string ;
34+ org_name ?: string ;
35+ }
36+
1937function openBrowser ( url : string ) : void {
2038 try {
2139 const cmd = process . platform === 'darwin' ? 'open' : process . platform === 'win32' ? 'start' : 'xdg-open' ;
@@ -25,6 +43,87 @@ function openBrowser(url: string): void {
2543 }
2644}
2745
46+ function sleep ( ms : number ) : Promise < void > {
47+ return new Promise ( ( resolve ) => setTimeout ( resolve , ms ) ) ;
48+ }
49+
50+ async function deviceAuthFlow ( ) : Promise < string | null > {
51+ const spinner = p . spinner ( ) ;
52+
53+ try {
54+ // Step 1: Get device code
55+ spinner . start ( 'Requesting device code...' ) ;
56+ const codeRes = await fetch ( `${ DEFAULT_API_URL } /auth/device/code` , {
57+ method : 'POST' ,
58+ headers : { 'Content-Type' : 'application/json' } ,
59+ body : JSON . stringify ( { client_name : 'create-githat-app' } ) ,
60+ } ) ;
61+
62+ if ( ! codeRes . ok ) {
63+ spinner . stop ( 'Failed to get device code' ) ;
64+ return null ;
65+ }
66+
67+ const codeData : DeviceCodeResponse = await codeRes . json ( ) ;
68+ spinner . stop ( 'Device code generated' ) ;
69+
70+ // Step 2: Show code and open browser
71+ p . note (
72+ `Code: ${ codeData . user_code } \n\nOpening browser to complete sign-in...\nIf it doesn't open, visit: ${ codeData . verification_uri_complete } ` ,
73+ 'Authorize Device'
74+ ) ;
75+
76+ openBrowser ( codeData . verification_uri_complete ) ;
77+
78+ // Step 3: Poll for authorization
79+ spinner . start ( 'Waiting for browser authorization...' ) ;
80+
81+ const expiresAt = Date . now ( ) + codeData . expires_in * 1000 ;
82+ const pollInterval = ( codeData . interval || 5 ) * 1000 ;
83+
84+ while ( Date . now ( ) < expiresAt ) {
85+ await sleep ( pollInterval ) ;
86+
87+ const tokenRes = await fetch ( `${ DEFAULT_API_URL } /auth/device/token` , {
88+ method : 'POST' ,
89+ headers : { 'Content-Type' : 'application/json' } ,
90+ body : JSON . stringify ( { device_code : codeData . device_code } ) ,
91+ } ) ;
92+
93+ const tokenData : DeviceTokenResponse = await tokenRes . json ( ) ;
94+
95+ if ( tokenData . error === 'authorization_pending' ) {
96+ // Keep polling
97+ continue ;
98+ }
99+
100+ if ( tokenData . error === 'expired_token' ) {
101+ spinner . stop ( 'Device code expired' ) ;
102+ p . log . error ( 'Authorization timed out. Please try again.' ) ;
103+ return null ;
104+ }
105+
106+ if ( tokenData . publishable_key ) {
107+ spinner . stop ( 'Authorized!' ) ;
108+ p . log . success ( `Connected to ${ tokenData . app_name || 'your app' } (${ tokenData . org_name || 'your org' } )` ) ;
109+ return tokenData . publishable_key ;
110+ }
111+
112+ // Unknown error
113+ spinner . stop ( 'Authorization failed' ) ;
114+ return null ;
115+ }
116+
117+ spinner . stop ( 'Timed out' ) ;
118+ p . log . error ( 'Authorization timed out. Please try again.' ) ;
119+ return null ;
120+ } catch ( err ) {
121+ spinner . stop ( 'Connection error' ) ;
122+ p . log . error ( 'Failed to connect to GitHat API. Check your internet connection.' ) ;
123+ return null ;
124+ }
125+ }
126+
28127export async function promptGitHat ( existingKey ?: string ) : Promise < GitHatAnswers > {
29128 let publishableKey = existingKey || '' ;
30129
@@ -34,6 +133,7 @@ export async function promptGitHat(existingKey?: string): Promise<GitHatAnswers>
34133 message : 'Connect to GitHat' ,
35134 options : [
36135 { value : 'skip' , label : 'Skip for now' , hint : 'auth works on localhost — add key later' } ,
136+ { value : 'browser' , label : 'Sign in with browser' , hint : 'opens githat.io to authorize' } ,
37137 { value : 'paste' , label : 'I have a key' , hint : 'paste your pk_live_... key' } ,
38138 ] ,
39139 } ) ;
@@ -43,7 +143,13 @@ export async function promptGitHat(existingKey?: string): Promise<GitHatAnswers>
43143 process . exit ( 0 ) ;
44144 }
45145
46- if ( connectChoice === 'paste' ) {
146+ if ( connectChoice === 'browser' ) {
147+ const key = await deviceAuthFlow ( ) ;
148+ if ( key ) {
149+ publishableKey = key ;
150+ }
151+ // If browser auth failed, continue without key (same as skip)
152+ } else if ( connectChoice === 'paste' ) {
47153 const pastedKey = await p . text ( {
48154 message : 'Publishable key' ,
49155 placeholder : `pk_live_... (get one at ${ DASHBOARD_URL } )` ,
0 commit comments