@oh-my-pi/pi-ai 1.337.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Antigravity OAuth flow (Gemini 3, Claude, GPT-OSS via Google Cloud)
3
+ * Uses different OAuth credentials than google-gemini-cli for access to additional models.
4
+ *
5
+ * NOTE: This module uses Node.js http.createServer for the OAuth callback.
6
+ * It is only intended for CLI use, not browser environments.
7
+ */
8
+
9
+ import { generatePKCE } from "./pkce.js";
10
+ import type { OAuthCredentials } from "./types.js";
11
+
12
+ // Antigravity OAuth credentials (different from Gemini CLI)
13
+ const decode = (s: string) => atob(s);
14
+ const CLIENT_ID = decode(
15
+ "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
16
+ );
17
+ const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
18
+ const REDIRECT_URI = "http://localhost:51121/oauth-callback";
19
+
20
+ // Antigravity requires additional scopes
21
+ const SCOPES = [
22
+ "https://www.googleapis.com/auth/cloud-platform",
23
+ "https://www.googleapis.com/auth/userinfo.email",
24
+ "https://www.googleapis.com/auth/userinfo.profile",
25
+ "https://www.googleapis.com/auth/cclog",
26
+ "https://www.googleapis.com/auth/experimentsandconfigs",
27
+ ];
28
+
29
+ const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
30
+ const TOKEN_URL = "https://oauth2.googleapis.com/token";
31
+
32
+ // Fallback project ID when discovery fails
33
+ const DEFAULT_PROJECT_ID = "rising-fact-p41fc";
34
+
35
+ /**
36
+ * Start a local HTTP server to receive the OAuth callback
37
+ */
38
+ async function startCallbackServer(): Promise<{
39
+ server: { stop: () => void };
40
+ getCode: () => Promise<{ code: string; state: string }>;
41
+ }> {
42
+ return new Promise((resolve, reject) => {
43
+ let codeResolve: (value: { code: string; state: string }) => void;
44
+ let codeReject: (error: Error) => void;
45
+
46
+ const codePromise = new Promise<{ code: string; state: string }>((res, rej) => {
47
+ codeResolve = res;
48
+ codeReject = rej;
49
+ });
50
+
51
+ const server = Bun.serve({
52
+ port: 51121,
53
+ hostname: "127.0.0.1",
54
+ fetch(req) {
55
+ const url = new URL(req.url);
56
+
57
+ if (url.pathname === "/oauth-callback") {
58
+ const code = url.searchParams.get("code");
59
+ const state = url.searchParams.get("state");
60
+ const error = url.searchParams.get("error");
61
+
62
+ if (error) {
63
+ codeReject(new Error(`OAuth error: ${error}`));
64
+ return new Response(
65
+ `<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`,
66
+ { status: 400, headers: { "Content-Type": "text/html" } },
67
+ );
68
+ }
69
+
70
+ if (code && state) {
71
+ codeResolve({ code, state });
72
+ return new Response(
73
+ `<html><body><h1>Authentication Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`,
74
+ { status: 200, headers: { "Content-Type": "text/html" } },
75
+ );
76
+ }
77
+
78
+ codeReject(new Error("Missing code or state in callback"));
79
+ return new Response(
80
+ `<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
81
+ { status: 400, headers: { "Content-Type": "text/html" } },
82
+ );
83
+ }
84
+
85
+ return new Response(null, { status: 404 });
86
+ },
87
+ error(err) {
88
+ reject(err);
89
+ return new Response("Internal Server Error", { status: 500 });
90
+ },
91
+ });
92
+
93
+ resolve({
94
+ server,
95
+ getCode: () => codePromise,
96
+ });
97
+ });
98
+ }
99
+
100
+ interface LoadCodeAssistPayload {
101
+ cloudaicompanionProject?: string | { id?: string };
102
+ currentTier?: { id?: string };
103
+ allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
104
+ }
105
+
106
+ /**
107
+ * Discover or provision a project for the user
108
+ */
109
+ async function discoverProject(accessToken: string, onProgress?: (message: string) => void): Promise<string> {
110
+ const headers = {
111
+ Authorization: `Bearer ${accessToken}`,
112
+ "Content-Type": "application/json",
113
+ "User-Agent": "google-api-nodejs-client/9.15.1",
114
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
115
+ "Client-Metadata": JSON.stringify({
116
+ ideType: "IDE_UNSPECIFIED",
117
+ platform: "PLATFORM_UNSPECIFIED",
118
+ pluginType: "GEMINI",
119
+ }),
120
+ };
121
+
122
+ // Try endpoints in order: prod first, then sandbox
123
+ const endpoints = ["https://cloudcode-pa.googleapis.com", "https://daily-cloudcode-pa.sandbox.googleapis.com"];
124
+
125
+ onProgress?.("Checking for existing project...");
126
+
127
+ for (const endpoint of endpoints) {
128
+ try {
129
+ const loadResponse = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
130
+ method: "POST",
131
+ headers,
132
+ body: JSON.stringify({
133
+ metadata: {
134
+ ideType: "IDE_UNSPECIFIED",
135
+ platform: "PLATFORM_UNSPECIFIED",
136
+ pluginType: "GEMINI",
137
+ },
138
+ }),
139
+ });
140
+
141
+ if (loadResponse.ok) {
142
+ const data = (await loadResponse.json()) as LoadCodeAssistPayload;
143
+
144
+ // Handle both string and object formats
145
+ if (typeof data.cloudaicompanionProject === "string" && data.cloudaicompanionProject) {
146
+ return data.cloudaicompanionProject;
147
+ }
148
+ if (
149
+ data.cloudaicompanionProject &&
150
+ typeof data.cloudaicompanionProject === "object" &&
151
+ data.cloudaicompanionProject.id
152
+ ) {
153
+ return data.cloudaicompanionProject.id;
154
+ }
155
+ }
156
+ } catch {
157
+ // Try next endpoint
158
+ }
159
+ }
160
+
161
+ // Use fallback project ID
162
+ onProgress?.("Using default project...");
163
+ return DEFAULT_PROJECT_ID;
164
+ }
165
+
166
+ /**
167
+ * Get user email from the access token
168
+ */
169
+ async function getUserEmail(accessToken: string): Promise<string | undefined> {
170
+ try {
171
+ const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
172
+ headers: {
173
+ Authorization: `Bearer ${accessToken}`,
174
+ },
175
+ });
176
+
177
+ if (response.ok) {
178
+ const data = (await response.json()) as { email?: string };
179
+ return data.email;
180
+ }
181
+ } catch {
182
+ // Ignore errors, email is optional
183
+ }
184
+ return undefined;
185
+ }
186
+
187
+ /**
188
+ * Refresh Antigravity token
189
+ */
190
+ export async function refreshAntigravityToken(refreshToken: string, projectId: string): Promise<OAuthCredentials> {
191
+ const response = await fetch(TOKEN_URL, {
192
+ method: "POST",
193
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
194
+ body: new URLSearchParams({
195
+ client_id: CLIENT_ID,
196
+ client_secret: CLIENT_SECRET,
197
+ refresh_token: refreshToken,
198
+ grant_type: "refresh_token",
199
+ }),
200
+ });
201
+
202
+ if (!response.ok) {
203
+ const error = await response.text();
204
+ throw new Error(`Antigravity token refresh failed: ${error}`);
205
+ }
206
+
207
+ const data = (await response.json()) as {
208
+ access_token: string;
209
+ expires_in: number;
210
+ refresh_token?: string;
211
+ };
212
+
213
+ return {
214
+ refresh: data.refresh_token || refreshToken,
215
+ access: data.access_token,
216
+ expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
217
+ projectId,
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Login with Antigravity OAuth
223
+ *
224
+ * @param onAuth - Callback with URL and optional instructions
225
+ * @param onProgress - Optional progress callback
226
+ */
227
+ export async function loginAntigravity(
228
+ onAuth: (info: { url: string; instructions?: string }) => void,
229
+ onProgress?: (message: string) => void,
230
+ ): Promise<OAuthCredentials> {
231
+ const { verifier, challenge } = await generatePKCE();
232
+
233
+ // Start local server for callback
234
+ onProgress?.("Starting local server for OAuth callback...");
235
+ const { server, getCode } = await startCallbackServer();
236
+
237
+ try {
238
+ // Build authorization URL
239
+ const authParams = new URLSearchParams({
240
+ client_id: CLIENT_ID,
241
+ response_type: "code",
242
+ redirect_uri: REDIRECT_URI,
243
+ scope: SCOPES.join(" "),
244
+ code_challenge: challenge,
245
+ code_challenge_method: "S256",
246
+ state: verifier,
247
+ access_type: "offline",
248
+ prompt: "consent",
249
+ });
250
+
251
+ const authUrl = `${AUTH_URL}?${authParams.toString()}`;
252
+
253
+ // Notify caller with URL to open
254
+ onAuth({
255
+ url: authUrl,
256
+ instructions: "Complete the sign-in in your browser. The callback will be captured automatically.",
257
+ });
258
+
259
+ // Wait for the callback
260
+ onProgress?.("Waiting for OAuth callback...");
261
+ const { code, state } = await getCode();
262
+
263
+ // Verify state matches
264
+ if (state !== verifier) {
265
+ throw new Error("OAuth state mismatch - possible CSRF attack");
266
+ }
267
+
268
+ // Exchange code for tokens
269
+ onProgress?.("Exchanging authorization code for tokens...");
270
+ const tokenResponse = await fetch(TOKEN_URL, {
271
+ method: "POST",
272
+ headers: {
273
+ "Content-Type": "application/x-www-form-urlencoded",
274
+ },
275
+ body: new URLSearchParams({
276
+ client_id: CLIENT_ID,
277
+ client_secret: CLIENT_SECRET,
278
+ code,
279
+ grant_type: "authorization_code",
280
+ redirect_uri: REDIRECT_URI,
281
+ code_verifier: verifier,
282
+ }),
283
+ });
284
+
285
+ if (!tokenResponse.ok) {
286
+ const error = await tokenResponse.text();
287
+ throw new Error(`Token exchange failed: ${error}`);
288
+ }
289
+
290
+ const tokenData = (await tokenResponse.json()) as {
291
+ access_token: string;
292
+ refresh_token: string;
293
+ expires_in: number;
294
+ };
295
+
296
+ if (!tokenData.refresh_token) {
297
+ throw new Error("No refresh token received. Please try again.");
298
+ }
299
+
300
+ // Get user email
301
+ onProgress?.("Getting user info...");
302
+ const email = await getUserEmail(tokenData.access_token);
303
+
304
+ // Discover project
305
+ const projectId = await discoverProject(tokenData.access_token, onProgress);
306
+
307
+ // Calculate expiry time (current time + expires_in seconds - 5 min buffer)
308
+ const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000;
309
+
310
+ const credentials: OAuthCredentials = {
311
+ refresh: tokenData.refresh_token,
312
+ access: tokenData.access_token,
313
+ expires: expiresAt,
314
+ projectId,
315
+ email,
316
+ };
317
+
318
+ return credentials;
319
+ } finally {
320
+ server.stop();
321
+ }
322
+ }
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Gemini CLI OAuth flow (Google Cloud Code Assist)
3
+ * Standard Gemini models only (gemini-2.0-flash, gemini-2.5-*)
4
+ *
5
+ * NOTE: This module uses Node.js http.createServer for the OAuth callback.
6
+ * It is only intended for CLI use, not browser environments.
7
+ */
8
+
9
+ import { generatePKCE } from "./pkce.js";
10
+ import type { OAuthCredentials } from "./types.js";
11
+
12
+ const decode = (s: string) => atob(s);
13
+ const CLIENT_ID = decode(
14
+ "NjgxMjU1ODA5Mzk1LW9vOGZ0Mm9wcmRybnA5ZTNhcWY2YXYzaG1kaWIxMzVqLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29t",
15
+ );
16
+ const CLIENT_SECRET = decode("R09DU1BYLTR1SGdNUG0tMW83U2stZ2VWNkN1NWNsWEZzeGw=");
17
+ const REDIRECT_URI = "http://localhost:8085/oauth2callback";
18
+ const SCOPES = [
19
+ "https://www.googleapis.com/auth/cloud-platform",
20
+ "https://www.googleapis.com/auth/userinfo.email",
21
+ "https://www.googleapis.com/auth/userinfo.profile",
22
+ ];
23
+ const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
24
+ const TOKEN_URL = "https://oauth2.googleapis.com/token";
25
+ const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
26
+
27
+ /**
28
+ * Start a local HTTP server to receive the OAuth callback
29
+ */
30
+ async function startCallbackServer(): Promise<{
31
+ server: { stop: () => void };
32
+ getCode: () => Promise<{ code: string; state: string }>;
33
+ }> {
34
+ return new Promise((resolve, reject) => {
35
+ let codeResolve: (value: { code: string; state: string }) => void;
36
+ let codeReject: (error: Error) => void;
37
+
38
+ const codePromise = new Promise<{ code: string; state: string }>((res, rej) => {
39
+ codeResolve = res;
40
+ codeReject = rej;
41
+ });
42
+
43
+ const server = Bun.serve({
44
+ port: 8085,
45
+ hostname: "127.0.0.1",
46
+ fetch(req) {
47
+ const url = new URL(req.url);
48
+
49
+ if (url.pathname === "/oauth2callback") {
50
+ const code = url.searchParams.get("code");
51
+ const state = url.searchParams.get("state");
52
+ const error = url.searchParams.get("error");
53
+
54
+ if (error) {
55
+ codeReject(new Error(`OAuth error: ${error}`));
56
+ return new Response(
57
+ `<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`,
58
+ { status: 400, headers: { "Content-Type": "text/html" } },
59
+ );
60
+ }
61
+
62
+ if (code && state) {
63
+ codeResolve({ code, state });
64
+ return new Response(
65
+ `<html><body><h1>Authentication Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`,
66
+ { status: 200, headers: { "Content-Type": "text/html" } },
67
+ );
68
+ }
69
+
70
+ codeReject(new Error("Missing code or state in callback"));
71
+ return new Response(
72
+ `<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
73
+ { status: 400, headers: { "Content-Type": "text/html" } },
74
+ );
75
+ }
76
+
77
+ return new Response(null, { status: 404 });
78
+ },
79
+ error(err) {
80
+ reject(err);
81
+ return new Response("Internal Server Error", { status: 500 });
82
+ },
83
+ });
84
+
85
+ resolve({
86
+ server,
87
+ getCode: () => codePromise,
88
+ });
89
+ });
90
+ }
91
+
92
+ interface LoadCodeAssistPayload {
93
+ cloudaicompanionProject?: string;
94
+ currentTier?: { id?: string };
95
+ allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
96
+ }
97
+
98
+ interface OnboardUserPayload {
99
+ done?: boolean;
100
+ response?: {
101
+ cloudaicompanionProject?: { id?: string };
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Wait helper for onboarding retries
107
+ */
108
+ function wait(ms: number): Promise<void> {
109
+ return new Promise((resolve) => setTimeout(resolve, ms));
110
+ }
111
+
112
+ /**
113
+ * Get default tier ID from allowed tiers
114
+ */
115
+ function getDefaultTierId(allowedTiers?: Array<{ id?: string; isDefault?: boolean }>): string | undefined {
116
+ if (!allowedTiers || allowedTiers.length === 0) return undefined;
117
+ const defaultTier = allowedTiers.find((t) => t.isDefault);
118
+ return defaultTier?.id ?? allowedTiers[0]?.id;
119
+ }
120
+
121
+ /**
122
+ * Discover or provision a Google Cloud project for the user
123
+ */
124
+ async function discoverProject(accessToken: string, onProgress?: (message: string) => void): Promise<string> {
125
+ const headers = {
126
+ Authorization: `Bearer ${accessToken}`,
127
+ "Content-Type": "application/json",
128
+ "User-Agent": "google-api-nodejs-client/9.15.1",
129
+ "X-Goog-Api-Client": "gl-node/22.17.0",
130
+ };
131
+
132
+ // Try to load existing project via loadCodeAssist
133
+ onProgress?.("Checking for existing Cloud Code Assist project...");
134
+ const loadResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, {
135
+ method: "POST",
136
+ headers,
137
+ body: JSON.stringify({
138
+ metadata: {
139
+ ideType: "IDE_UNSPECIFIED",
140
+ platform: "PLATFORM_UNSPECIFIED",
141
+ pluginType: "GEMINI",
142
+ },
143
+ }),
144
+ });
145
+
146
+ if (loadResponse.ok) {
147
+ const data = (await loadResponse.json()) as LoadCodeAssistPayload;
148
+
149
+ // If we have an existing project, use it
150
+ if (data.cloudaicompanionProject) {
151
+ return data.cloudaicompanionProject;
152
+ }
153
+
154
+ // Otherwise, try to onboard with the FREE tier
155
+ const tierId = getDefaultTierId(data.allowedTiers) ?? "FREE";
156
+
157
+ onProgress?.("Provisioning Cloud Code Assist project (this may take a moment)...");
158
+
159
+ // Onboard with retries (the API may take time to provision)
160
+ for (let attempt = 0; attempt < 10; attempt++) {
161
+ const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, {
162
+ method: "POST",
163
+ headers,
164
+ body: JSON.stringify({
165
+ tierId,
166
+ metadata: {
167
+ ideType: "IDE_UNSPECIFIED",
168
+ platform: "PLATFORM_UNSPECIFIED",
169
+ pluginType: "GEMINI",
170
+ },
171
+ }),
172
+ });
173
+
174
+ if (onboardResponse.ok) {
175
+ const onboardData = (await onboardResponse.json()) as OnboardUserPayload;
176
+ const projectId = onboardData.response?.cloudaicompanionProject?.id;
177
+
178
+ if (onboardData.done && projectId) {
179
+ return projectId;
180
+ }
181
+ }
182
+
183
+ // Wait before retrying
184
+ if (attempt < 9) {
185
+ onProgress?.(`Waiting for project provisioning (attempt ${attempt + 2}/10)...`);
186
+ await wait(3000);
187
+ }
188
+ }
189
+ }
190
+
191
+ throw new Error(
192
+ "Could not discover or provision a Google Cloud project. " +
193
+ "Please ensure you have access to Google Cloud Code Assist (Gemini CLI).",
194
+ );
195
+ }
196
+
197
+ /**
198
+ * Get user email from the access token
199
+ */
200
+ async function getUserEmail(accessToken: string): Promise<string | undefined> {
201
+ try {
202
+ const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
203
+ headers: {
204
+ Authorization: `Bearer ${accessToken}`,
205
+ },
206
+ });
207
+
208
+ if (response.ok) {
209
+ const data = (await response.json()) as { email?: string };
210
+ return data.email;
211
+ }
212
+ } catch {
213
+ // Ignore errors, email is optional
214
+ }
215
+ return undefined;
216
+ }
217
+
218
+ /**
219
+ * Refresh Google Cloud Code Assist token
220
+ */
221
+ export async function refreshGoogleCloudToken(refreshToken: string, projectId: string): Promise<OAuthCredentials> {
222
+ const response = await fetch(TOKEN_URL, {
223
+ method: "POST",
224
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
225
+ body: new URLSearchParams({
226
+ client_id: CLIENT_ID,
227
+ client_secret: CLIENT_SECRET,
228
+ refresh_token: refreshToken,
229
+ grant_type: "refresh_token",
230
+ }),
231
+ });
232
+
233
+ if (!response.ok) {
234
+ const error = await response.text();
235
+ throw new Error(`Google Cloud token refresh failed: ${error}`);
236
+ }
237
+
238
+ const data = (await response.json()) as {
239
+ access_token: string;
240
+ expires_in: number;
241
+ refresh_token?: string;
242
+ };
243
+
244
+ return {
245
+ refresh: data.refresh_token || refreshToken,
246
+ access: data.access_token,
247
+ expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
248
+ projectId,
249
+ };
250
+ }
251
+
252
+ /**
253
+ * Login with Gemini CLI (Google Cloud Code Assist) OAuth
254
+ *
255
+ * @param onAuth - Callback with URL and optional instructions
256
+ * @param onProgress - Optional progress callback
257
+ */
258
+ export async function loginGeminiCli(
259
+ onAuth: (info: { url: string; instructions?: string }) => void,
260
+ onProgress?: (message: string) => void,
261
+ ): Promise<OAuthCredentials> {
262
+ const { verifier, challenge } = await generatePKCE();
263
+
264
+ // Start local server for callback
265
+ onProgress?.("Starting local server for OAuth callback...");
266
+ const { server, getCode } = await startCallbackServer();
267
+
268
+ try {
269
+ // Build authorization URL
270
+ const authParams = new URLSearchParams({
271
+ client_id: CLIENT_ID,
272
+ response_type: "code",
273
+ redirect_uri: REDIRECT_URI,
274
+ scope: SCOPES.join(" "),
275
+ code_challenge: challenge,
276
+ code_challenge_method: "S256",
277
+ state: verifier,
278
+ access_type: "offline",
279
+ prompt: "consent",
280
+ });
281
+
282
+ const authUrl = `${AUTH_URL}?${authParams.toString()}`;
283
+
284
+ // Notify caller with URL to open
285
+ onAuth({
286
+ url: authUrl,
287
+ instructions: "Complete the sign-in in your browser. The callback will be captured automatically.",
288
+ });
289
+
290
+ // Wait for the callback
291
+ onProgress?.("Waiting for OAuth callback...");
292
+ const { code, state } = await getCode();
293
+
294
+ // Verify state matches
295
+ if (state !== verifier) {
296
+ throw new Error("OAuth state mismatch - possible CSRF attack");
297
+ }
298
+
299
+ // Exchange code for tokens
300
+ onProgress?.("Exchanging authorization code for tokens...");
301
+ const tokenResponse = await fetch(TOKEN_URL, {
302
+ method: "POST",
303
+ headers: {
304
+ "Content-Type": "application/x-www-form-urlencoded",
305
+ },
306
+ body: new URLSearchParams({
307
+ client_id: CLIENT_ID,
308
+ client_secret: CLIENT_SECRET,
309
+ code,
310
+ grant_type: "authorization_code",
311
+ redirect_uri: REDIRECT_URI,
312
+ code_verifier: verifier,
313
+ }),
314
+ });
315
+
316
+ if (!tokenResponse.ok) {
317
+ const error = await tokenResponse.text();
318
+ throw new Error(`Token exchange failed: ${error}`);
319
+ }
320
+
321
+ const tokenData = (await tokenResponse.json()) as {
322
+ access_token: string;
323
+ refresh_token: string;
324
+ expires_in: number;
325
+ };
326
+
327
+ if (!tokenData.refresh_token) {
328
+ throw new Error("No refresh token received. Please try again.");
329
+ }
330
+
331
+ // Get user email
332
+ onProgress?.("Getting user info...");
333
+ const email = await getUserEmail(tokenData.access_token);
334
+
335
+ // Discover project
336
+ const projectId = await discoverProject(tokenData.access_token, onProgress);
337
+
338
+ // Calculate expiry time (current time + expires_in seconds - 5 min buffer)
339
+ const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000;
340
+
341
+ const credentials: OAuthCredentials = {
342
+ refresh: tokenData.refresh_token,
343
+ access: tokenData.access_token,
344
+ expires: expiresAt,
345
+ projectId,
346
+ email,
347
+ };
348
+
349
+ return credentials;
350
+ } finally {
351
+ server.stop();
352
+ }
353
+ }