Skip to content

Commit 33fcec9

Browse files
doble196claude
andcommitted
feat(cli): device authorization flow for browser sign-in v1.0.6
- "Sign in with browser" option opens githat.io/device - CLI polls /auth/device/token until user authorizes - Automatic publishable key retrieval after auth - Skip for now remains default option Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2becee4 commit 33fcec9

File tree

3 files changed

+109
-3
lines changed

3 files changed

+109
-3
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "create-githat-app",
3-
"version": "1.0.5",
3+
"version": "1.0.6",
44
"description": "GitHat CLI — scaffold apps and manage the skills marketplace",
55
"type": "module",
66
"bin": {

src/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export const VERSION = '1.0.5';
1+
export const VERSION = '1.0.6';
22
export const DEFAULT_API_URL = 'https://api.githat.io';
33
export const DOCS_URL = 'https://githat.io/docs/sdk';
44
export const DASHBOARD_URL = 'https://githat.io/dashboard/apps';

src/prompts/githat.ts

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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+
1937
function 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+
28127
export 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

Comments
 (0)