@oh-my-pi/pi-ai 6.7.670 → 6.8.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.
@@ -1,21 +1,19 @@
1
1
  /**
2
2
  * Gemini CLI OAuth flow (Google Cloud Code Assist)
3
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
4
  */
8
5
 
9
- import type { Server } from "http";
6
+ import { OAuthCallbackFlow } from "./callback-server";
10
7
  import { generatePKCE } from "./pkce";
11
- import type { OAuthCredentials } from "./types";
8
+ import type { OAuthController, OAuthCredentials } from "./types";
12
9
 
13
10
  const decode = (s: string) => atob(s);
14
11
  const CLIENT_ID = decode(
15
12
  "NjgxMjU1ODA5Mzk1LW9vOGZ0Mm9wcmRybnA5ZTNhcWY2YXYzaG1kaWIxMzVqLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29t",
16
13
  );
17
14
  const CLIENT_SECRET = decode("R09DU1BYLTR1SGdNUG0tMW83U2stZ2VWNkN1NWNsWEZzeGw=");
18
- const REDIRECT_URI = "http://localhost:8085/oauth2callback";
15
+ const CALLBACK_PORT = 8085;
16
+ const CALLBACK_PATH = "/oauth2callback";
19
17
  const SCOPES = [
20
18
  "https://www.googleapis.com/auth/cloud-platform",
21
19
  "https://www.googleapis.com/auth/userinfo.email",
@@ -25,106 +23,12 @@ const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
25
23
  const TOKEN_URL = "https://oauth2.googleapis.com/token";
26
24
  const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
27
25
 
28
- type CallbackServerInfo = {
29
- server: Server;
30
- cancelWait: () => void;
31
- waitForCode: () => Promise<{ code: string; state: string } | null>;
32
- };
33
-
34
- /**
35
- * Start a local HTTP server to receive the OAuth callback
36
- */
37
- async function startCallbackServer(): Promise<CallbackServerInfo> {
38
- const { createServer } = await import("http");
39
-
40
- return new Promise((resolve, reject) => {
41
- let result: { code: string; state: string } | null = null;
42
- let cancelled = false;
43
-
44
- const server = createServer((req, res) => {
45
- const url = new URL(req.url || "", `http://localhost:8085`);
46
-
47
- if (url.pathname === "/oauth2callback") {
48
- const code = url.searchParams.get("code");
49
- const state = url.searchParams.get("state");
50
- const error = url.searchParams.get("error");
51
-
52
- if (error) {
53
- res.writeHead(400, { "Content-Type": "text/html" });
54
- res.end(
55
- `<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`,
56
- );
57
- return;
58
- }
59
-
60
- if (code && state) {
61
- res.writeHead(200, { "Content-Type": "text/html" });
62
- res.end(
63
- `<html><body><h1>Authentication Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`,
64
- );
65
- result = { code, state };
66
- } else {
67
- res.writeHead(400, { "Content-Type": "text/html" });
68
- res.end(
69
- `<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
70
- );
71
- }
72
- } else {
73
- res.writeHead(404);
74
- res.end();
75
- }
76
- });
77
-
78
- server.on("error", (err) => {
79
- reject(err);
80
- });
81
-
82
- server.listen(8085, "127.0.0.1", () => {
83
- resolve({
84
- server,
85
- cancelWait: () => {
86
- cancelled = true;
87
- },
88
- waitForCode: async () => {
89
- const sleep = () => new Promise((r) => setTimeout(r, 100));
90
- while (!result && !cancelled) {
91
- await sleep();
92
- }
93
- return result;
94
- },
95
- });
96
- });
97
- });
98
- }
99
-
100
- /**
101
- * Parse redirect URL to extract code and state
102
- */
103
- function parseRedirectUrl(input: string): { code?: string; state?: string } {
104
- const value = input.trim();
105
- if (!value) return {};
106
-
107
- try {
108
- const url = new URL(value);
109
- return {
110
- code: url.searchParams.get("code") ?? undefined,
111
- state: url.searchParams.get("state") ?? undefined,
112
- };
113
- } catch {
114
- // Not a URL, return empty
115
- return {};
116
- }
117
- }
118
-
119
26
  interface LoadCodeAssistPayload {
120
27
  cloudaicompanionProject?: string;
121
28
  currentTier?: { id?: string };
122
29
  allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
123
30
  }
124
31
 
125
- /**
126
- * Long-running operation response from onboardUser
127
- */
128
32
  interface LongRunningOperationResponse {
129
33
  name?: string;
130
34
  done?: boolean;
@@ -133,7 +37,6 @@ interface LongRunningOperationResponse {
133
37
  };
134
38
  }
135
39
 
136
- // Tier IDs as used by the Cloud Code API
137
40
  const TIER_FREE = "free-tier";
138
41
  const TIER_LEGACY = "legacy-tier";
139
42
  const TIER_STANDARD = "standard-tier";
@@ -144,16 +47,6 @@ interface GoogleRpcErrorResponse {
144
47
  };
145
48
  }
146
49
 
147
- /**
148
- * Wait helper for onboarding retries
149
- */
150
- function wait(ms: number): Promise<void> {
151
- return new Promise((resolve) => setTimeout(resolve, ms));
152
- }
153
-
154
- /**
155
- * Get default tier from allowed tiers
156
- */
157
50
  function getDefaultTier(allowedTiers?: Array<{ id?: string; isDefault?: boolean }>): { id?: string } {
158
51
  if (!allowedTiers || allowedTiers.length === 0) return { id: TIER_LEGACY };
159
52
  const defaultTier = allowedTiers.find((t) => t.isDefault);
@@ -168,9 +61,6 @@ function isVpcScAffectedUser(payload: unknown): boolean {
168
61
  return error.details.some((detail) => detail.reason === "SECURITY_POLICY_VIOLATED");
169
62
  }
170
63
 
171
- /**
172
- * Poll a long-running operation until completion
173
- */
174
64
  async function pollOperation(
175
65
  operationName: string,
176
66
  headers: Record<string, string>,
@@ -180,7 +70,7 @@ async function pollOperation(
180
70
  while (true) {
181
71
  if (attempt > 0) {
182
72
  onProgress?.(`Waiting for project provisioning (attempt ${attempt + 1})...`);
183
- await wait(5000);
73
+ await Bun.sleep(5000);
184
74
  }
185
75
 
186
76
  const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, {
@@ -201,11 +91,7 @@ async function pollOperation(
201
91
  }
202
92
  }
203
93
 
204
- /**
205
- * Discover or provision a Google Cloud project for the user
206
- */
207
94
  async function discoverProject(accessToken: string, onProgress?: (message: string) => void): Promise<string> {
208
- // Check for user-provided project ID via environment variable
209
95
  const envProjectId = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID;
210
96
 
211
97
  const headers = {
@@ -215,7 +101,6 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin
215
101
  "X-Goog-Api-Client": "gl-node/22.17.0",
216
102
  };
217
103
 
218
- // Try to load existing project via loadCodeAssist
219
104
  onProgress?.("Checking for existing Cloud Code Assist project...");
220
105
  const loadResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, {
221
106
  method: "POST",
@@ -251,12 +136,10 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin
251
136
  data = (await loadResponse.json()) as LoadCodeAssistPayload;
252
137
  }
253
138
 
254
- // If user already has a current tier and project, use it
255
139
  if (data.currentTier) {
256
140
  if (data.cloudaicompanionProject) {
257
141
  return data.cloudaicompanionProject;
258
142
  }
259
- // User has a tier but no managed project - they need to provide one via env var
260
143
  if (envProjectId) {
261
144
  return envProjectId;
262
145
  }
@@ -266,7 +149,6 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin
266
149
  );
267
150
  }
268
151
 
269
- // User needs to be onboarded - get the default tier
270
152
  const tier = getDefaultTier(data.allowedTiers);
271
153
  const tierId = tier?.id ?? TIER_FREE;
272
154
 
@@ -279,8 +161,6 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin
279
161
 
280
162
  onProgress?.("Provisioning Cloud Code Assist project (this may take a moment)...");
281
163
 
282
- // Build onboard request - for free tier, don't include project ID (Google provisions one)
283
- // For other tiers, include the user's project ID if available
284
164
  const onboardBody: Record<string, unknown> = {
285
165
  tierId,
286
166
  metadata: {
@@ -295,7 +175,6 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin
295
175
  (onboardBody.metadata as Record<string, unknown>).duetProject = envProjectId;
296
176
  }
297
177
 
298
- // Start onboarding - this returns a long-running operation
299
178
  const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, {
300
179
  method: "POST",
301
180
  headers,
@@ -309,18 +188,15 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin
309
188
 
310
189
  let lroData = (await onboardResponse.json()) as LongRunningOperationResponse;
311
190
 
312
- // If the operation isn't done yet, poll until completion
313
191
  if (!lroData.done && lroData.name) {
314
192
  lroData = await pollOperation(lroData.name, headers, onProgress);
315
193
  }
316
194
 
317
- // Try to get project ID from the response
318
195
  const projectId = lroData.response?.cloudaicompanionProject?.id;
319
196
  if (projectId) {
320
197
  return projectId;
321
198
  }
322
199
 
323
- // If no project ID from onboarding, fall back to env var
324
200
  if (envProjectId) {
325
201
  return envProjectId;
326
202
  }
@@ -332,9 +208,6 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin
332
208
  );
333
209
  }
334
210
 
335
- /**
336
- * Get user email from the access token
337
- */
338
211
  async function getUserEmail(accessToken: string): Promise<string | undefined> {
339
212
  try {
340
213
  const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
@@ -353,165 +226,51 @@ async function getUserEmail(accessToken: string): Promise<string | undefined> {
353
226
  return undefined;
354
227
  }
355
228
 
356
- /**
357
- * Refresh Google Cloud Code Assist token
358
- */
359
- export async function refreshGoogleCloudToken(refreshToken: string, projectId: string): Promise<OAuthCredentials> {
360
- const response = await fetch(TOKEN_URL, {
361
- method: "POST",
362
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
363
- body: new URLSearchParams({
364
- client_id: CLIENT_ID,
365
- client_secret: CLIENT_SECRET,
366
- refresh_token: refreshToken,
367
- grant_type: "refresh_token",
368
- }),
369
- });
229
+ class GeminiCliOAuthFlow extends OAuthCallbackFlow {
230
+ private verifier: string = "";
231
+ private challenge: string = "";
370
232
 
371
- if (!response.ok) {
372
- const error = await response.text();
373
- throw new Error(`Google Cloud token refresh failed: ${error}`);
233
+ constructor(ctrl: OAuthController) {
234
+ super(ctrl, CALLBACK_PORT, CALLBACK_PATH);
374
235
  }
375
236
 
376
- const data = (await response.json()) as {
377
- access_token: string;
378
- expires_in: number;
379
- refresh_token?: string;
380
- };
381
-
382
- return {
383
- refresh: data.refresh_token || refreshToken,
384
- access: data.access_token,
385
- expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
386
- projectId,
387
- };
388
- }
389
-
390
- /**
391
- * Login with Gemini CLI (Google Cloud Code Assist) OAuth
392
- *
393
- * @param onAuth - Callback with URL and optional instructions
394
- * @param onProgress - Optional progress callback
395
- * @param onManualCodeInput - Optional promise that resolves with user-pasted redirect URL.
396
- * Races with browser callback - whichever completes first wins.
397
- */
398
- export async function loginGeminiCli(
399
- onAuth: (info: { url: string; instructions?: string }) => void,
400
- onProgress?: (message: string) => void,
401
- onManualCodeInput?: () => Promise<string>,
402
- ): Promise<OAuthCredentials> {
403
- const { verifier, challenge } = await generatePKCE();
404
-
405
- // Start local server for callback
406
- onProgress?.("Starting local server for OAuth callback...");
407
- const server = await startCallbackServer();
237
+ protected async generateAuthUrl(
238
+ state: string,
239
+ redirectUri: string,
240
+ ): Promise<{ url: string; instructions?: string }> {
241
+ const pkce = await generatePKCE();
242
+ this.verifier = pkce.verifier;
243
+ this.challenge = pkce.challenge;
408
244
 
409
- let code: string | undefined;
410
-
411
- try {
412
- // Build authorization URL
413
245
  const authParams = new URLSearchParams({
414
246
  client_id: CLIENT_ID,
415
247
  response_type: "code",
416
- redirect_uri: REDIRECT_URI,
248
+ redirect_uri: redirectUri,
417
249
  scope: SCOPES.join(" "),
418
- code_challenge: challenge,
250
+ code_challenge: this.challenge,
419
251
  code_challenge_method: "S256",
420
- state: verifier,
252
+ state,
421
253
  access_type: "offline",
422
254
  prompt: "consent",
423
255
  });
424
256
 
425
- const authUrl = `${AUTH_URL}?${authParams.toString()}`;
426
-
427
- // Notify caller with URL to open
428
- onAuth({
429
- url: authUrl,
430
- instructions: "Complete the sign-in in your browser.",
431
- });
432
-
433
- // Wait for the callback, racing with manual input if provided
434
- onProgress?.("Waiting for OAuth callback...");
435
-
436
- if (onManualCodeInput) {
437
- // Race between browser callback and manual input
438
- let manualInput: string | undefined;
439
- let manualError: Error | undefined;
440
- const manualPromise = onManualCodeInput()
441
- .then((input) => {
442
- manualInput = input;
443
- server.cancelWait();
444
- })
445
- .catch((err) => {
446
- manualError = err instanceof Error ? err : new Error(String(err));
447
- server.cancelWait();
448
- });
449
-
450
- const result = await server.waitForCode();
451
-
452
- // If manual input was cancelled, throw that error
453
- if (manualError) {
454
- throw manualError;
455
- }
456
-
457
- if (result?.code) {
458
- // Browser callback won - verify state
459
- if (result.state !== verifier) {
460
- throw new Error("OAuth state mismatch - possible CSRF attack");
461
- }
462
- code = result.code;
463
- } else if (manualInput) {
464
- // Manual input won
465
- const parsed = parseRedirectUrl(manualInput);
466
- if (parsed.state && parsed.state !== verifier) {
467
- throw new Error("OAuth state mismatch - possible CSRF attack");
468
- }
469
- code = parsed.code;
470
- }
471
-
472
- // If still no code, wait for manual promise and try that
473
- if (!code) {
474
- await manualPromise;
475
- if (manualError) {
476
- throw manualError;
477
- }
478
- if (manualInput) {
479
- const parsed = parseRedirectUrl(manualInput);
480
- if (parsed.state && parsed.state !== verifier) {
481
- throw new Error("OAuth state mismatch - possible CSRF attack");
482
- }
483
- code = parsed.code;
484
- }
485
- }
486
- } else {
487
- // Original flow: just wait for callback
488
- const result = await server.waitForCode();
489
- if (result?.code) {
490
- if (result.state !== verifier) {
491
- throw new Error("OAuth state mismatch - possible CSRF attack");
492
- }
493
- code = result.code;
494
- }
495
- }
257
+ const url = `${AUTH_URL}?${authParams.toString()}`;
258
+ return { url, instructions: "Complete the sign-in in your browser." };
259
+ }
496
260
 
497
- if (!code) {
498
- throw new Error("No authorization code received");
499
- }
261
+ protected async exchangeToken(code: string, _state: string, redirectUri: string): Promise<OAuthCredentials> {
262
+ this.ctrl.onProgress?.("Exchanging authorization code for tokens...");
500
263
 
501
- // Exchange code for tokens
502
- onProgress?.("Exchanging authorization code for tokens...");
503
264
  const tokenResponse = await fetch(TOKEN_URL, {
504
265
  method: "POST",
505
- headers: {
506
- "Content-Type": "application/x-www-form-urlencoded",
507
- },
266
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
508
267
  body: new URLSearchParams({
509
268
  client_id: CLIENT_ID,
510
269
  client_secret: CLIENT_SECRET,
511
270
  code,
512
271
  grant_type: "authorization_code",
513
- redirect_uri: REDIRECT_URI,
514
- code_verifier: verifier,
272
+ redirect_uri: redirectUri,
273
+ code_verifier: this.verifier,
515
274
  }),
516
275
  });
517
276
 
@@ -530,26 +289,59 @@ export async function loginGeminiCli(
530
289
  throw new Error("No refresh token received. Please try again.");
531
290
  }
532
291
 
533
- // Get user email
534
- onProgress?.("Getting user info...");
292
+ this.ctrl.onProgress?.("Getting user info...");
535
293
  const email = await getUserEmail(tokenData.access_token);
536
294
 
537
- // Discover project
538
- const projectId = await discoverProject(tokenData.access_token, onProgress);
539
-
540
- // Calculate expiry time (current time + expires_in seconds - 5 min buffer)
541
- const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000;
295
+ const projectId = await discoverProject(tokenData.access_token, this.ctrl.onProgress);
542
296
 
543
- const credentials: OAuthCredentials = {
297
+ return {
544
298
  refresh: tokenData.refresh_token,
545
299
  access: tokenData.access_token,
546
- expires: expiresAt,
300
+ expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000,
547
301
  projectId,
548
302
  email,
549
303
  };
304
+ }
305
+ }
550
306
 
551
- return credentials;
552
- } finally {
553
- server.server.close();
307
+ /**
308
+ * Login with Gemini CLI (Google Cloud Code Assist) OAuth
309
+ */
310
+ export async function loginGeminiCli(ctrl: OAuthController): Promise<OAuthCredentials> {
311
+ const flow = new GeminiCliOAuthFlow(ctrl);
312
+ return flow.login();
313
+ }
314
+
315
+ /**
316
+ * Refresh Google Cloud Code Assist token
317
+ */
318
+ export async function refreshGoogleCloudToken(refreshToken: string, projectId: string): Promise<OAuthCredentials> {
319
+ const response = await fetch(TOKEN_URL, {
320
+ method: "POST",
321
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
322
+ body: new URLSearchParams({
323
+ client_id: CLIENT_ID,
324
+ client_secret: CLIENT_SECRET,
325
+ refresh_token: refreshToken,
326
+ grant_type: "refresh_token",
327
+ }),
328
+ });
329
+
330
+ if (!response.ok) {
331
+ const error = await response.text();
332
+ throw new Error(`Google Cloud token refresh failed: ${error}`);
554
333
  }
334
+
335
+ const data = (await response.json()) as {
336
+ access_token: string;
337
+ expires_in: number;
338
+ refresh_token?: string;
339
+ };
340
+
341
+ return {
342
+ refresh: data.refresh_token || refreshToken,
343
+ access: data.access_token,
344
+ expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
345
+ projectId,
346
+ };
555
347
  }