@oh-my-pi/pi-ai 3.20.1 → 3.34.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.
@@ -6,6 +6,7 @@
6
6
  * It is only intended for CLI use, not browser environments.
7
7
  */
8
8
 
9
+ import type { Server } from "http";
9
10
  import { generatePKCE } from "./pkce";
10
11
  import type { OAuthCredentials } from "./types";
11
12
 
@@ -32,71 +33,97 @@ const TOKEN_URL = "https://oauth2.googleapis.com/token";
32
33
  // Fallback project ID when discovery fails
33
34
  const DEFAULT_PROJECT_ID = "rising-fact-p41fc";
34
35
 
36
+ type CallbackServerInfo = {
37
+ server: Server;
38
+ cancelWait: () => void;
39
+ waitForCode: () => Promise<{ code: string; state: string } | null>;
40
+ };
41
+
35
42
  /**
36
43
  * Start a local HTTP server to receive the OAuth callback
37
44
  */
38
- async function startCallbackServer(): Promise<{
39
- server: { stop: () => void };
40
- getCode: () => Promise<{ code: string; state: string }>;
41
- }> {
45
+ async function startCallbackServer(): Promise<CallbackServerInfo> {
46
+ const { createServer } = await import("http");
47
+
42
48
  return new Promise((resolve, reject) => {
43
- let codeResolve: (value: { code: string; state: string }) => void;
44
- let codeReject: (error: Error) => void;
49
+ let result: { code: string; state: string } | null = null;
50
+ let cancelled = false;
45
51
 
46
- const codePromise = new Promise<{ code: string; state: string }>((res, rej) => {
47
- codeResolve = res;
48
- codeReject = rej;
49
- });
52
+ const server = createServer((req, res) => {
53
+ const url = new URL(req.url || "", `http://localhost:51121`);
50
54
 
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
- }
55
+ if (url.pathname === "/oauth-callback") {
56
+ const code = url.searchParams.get("code");
57
+ const state = url.searchParams.get("state");
58
+ const error = url.searchParams.get("error");
69
59
 
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
- }
60
+ if (error) {
61
+ res.writeHead(400, { "Content-Type": "text/html" });
62
+ res.end(
63
+ `<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`,
64
+ );
65
+ return;
66
+ }
77
67
 
78
- codeReject(new Error("Missing code or state in callback"));
79
- return new Response(
68
+ if (code && state) {
69
+ res.writeHead(200, { "Content-Type": "text/html" });
70
+ res.end(
71
+ `<html><body><h1>Authentication Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`,
72
+ );
73
+ result = { code, state };
74
+ } else {
75
+ res.writeHead(400, { "Content-Type": "text/html" });
76
+ res.end(
80
77
  `<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
81
- { status: 400, headers: { "Content-Type": "text/html" } },
82
78
  );
83
79
  }
80
+ } else {
81
+ res.writeHead(404);
82
+ res.end();
83
+ }
84
+ });
84
85
 
85
- return new Response(null, { status: 404 });
86
- },
87
- error(err) {
88
- reject(err);
89
- return new Response("Internal Server Error", { status: 500 });
90
- },
86
+ server.on("error", (err) => {
87
+ reject(err);
91
88
  });
92
89
 
93
- resolve({
94
- server,
95
- getCode: () => codePromise,
90
+ server.listen(51121, "127.0.0.1", () => {
91
+ resolve({
92
+ server,
93
+ cancelWait: () => {
94
+ cancelled = true;
95
+ },
96
+ waitForCode: async () => {
97
+ const sleep = () => new Promise((r) => setTimeout(r, 100));
98
+ while (!result && !cancelled) {
99
+ await sleep();
100
+ }
101
+ return result;
102
+ },
103
+ });
96
104
  });
97
105
  });
98
106
  }
99
107
 
108
+ /**
109
+ * Parse redirect URL to extract code and state
110
+ */
111
+ function parseRedirectUrl(input: string): { code?: string; state?: string } {
112
+ const value = input.trim();
113
+ if (!value) return {};
114
+
115
+ try {
116
+ const url = new URL(value);
117
+ return {
118
+ code: url.searchParams.get("code") ?? undefined,
119
+ state: url.searchParams.get("state") ?? undefined,
120
+ };
121
+ } catch {
122
+ // Not a URL, return empty
123
+ return {};
124
+ }
125
+ }
126
+
100
127
  interface LoadCodeAssistPayload {
101
128
  cloudaicompanionProject?: string | { id?: string };
102
129
  currentTier?: { id?: string };
@@ -223,16 +250,21 @@ export async function refreshAntigravityToken(refreshToken: string, projectId: s
223
250
  *
224
251
  * @param onAuth - Callback with URL and optional instructions
225
252
  * @param onProgress - Optional progress callback
253
+ * @param onManualCodeInput - Optional promise that resolves with user-pasted redirect URL.
254
+ * Races with browser callback - whichever completes first wins.
226
255
  */
227
256
  export async function loginAntigravity(
228
257
  onAuth: (info: { url: string; instructions?: string }) => void,
229
258
  onProgress?: (message: string) => void,
259
+ onManualCodeInput?: () => Promise<string>,
230
260
  ): Promise<OAuthCredentials> {
231
261
  const { verifier, challenge } = await generatePKCE();
232
262
 
233
263
  // Start local server for callback
234
264
  onProgress?.("Starting local server for OAuth callback...");
235
- const { server, getCode } = await startCallbackServer();
265
+ const server = await startCallbackServer();
266
+
267
+ let code: string | undefined;
236
268
 
237
269
  try {
238
270
  // Build authorization URL
@@ -253,16 +285,75 @@ export async function loginAntigravity(
253
285
  // Notify caller with URL to open
254
286
  onAuth({
255
287
  url: authUrl,
256
- instructions: "Complete the sign-in in your browser. The callback will be captured automatically.",
288
+ instructions: "Complete the sign-in in your browser.",
257
289
  });
258
290
 
259
- // Wait for the callback
291
+ // Wait for the callback, racing with manual input if provided
260
292
  onProgress?.("Waiting for OAuth callback...");
261
- const { code, state } = await getCode();
262
293
 
263
- // Verify state matches
264
- if (state !== verifier) {
265
- throw new Error("OAuth state mismatch - possible CSRF attack");
294
+ if (onManualCodeInput) {
295
+ // Race between browser callback and manual input
296
+ let manualInput: string | undefined;
297
+ let manualError: Error | undefined;
298
+ const manualPromise = onManualCodeInput()
299
+ .then((input) => {
300
+ manualInput = input;
301
+ server.cancelWait();
302
+ })
303
+ .catch((err) => {
304
+ manualError = err instanceof Error ? err : new Error(String(err));
305
+ server.cancelWait();
306
+ });
307
+
308
+ const result = await server.waitForCode();
309
+
310
+ // If manual input was cancelled, throw that error
311
+ if (manualError) {
312
+ throw manualError;
313
+ }
314
+
315
+ if (result?.code) {
316
+ // Browser callback won - verify state
317
+ if (result.state !== verifier) {
318
+ throw new Error("OAuth state mismatch - possible CSRF attack");
319
+ }
320
+ code = result.code;
321
+ } else if (manualInput) {
322
+ // Manual input won
323
+ const parsed = parseRedirectUrl(manualInput);
324
+ if (parsed.state && parsed.state !== verifier) {
325
+ throw new Error("OAuth state mismatch - possible CSRF attack");
326
+ }
327
+ code = parsed.code;
328
+ }
329
+
330
+ // If still no code, wait for manual promise and try that
331
+ if (!code) {
332
+ await manualPromise;
333
+ if (manualError) {
334
+ throw manualError;
335
+ }
336
+ if (manualInput) {
337
+ const parsed = parseRedirectUrl(manualInput);
338
+ if (parsed.state && parsed.state !== verifier) {
339
+ throw new Error("OAuth state mismatch - possible CSRF attack");
340
+ }
341
+ code = parsed.code;
342
+ }
343
+ }
344
+ } else {
345
+ // Original flow: just wait for callback
346
+ const result = await server.waitForCode();
347
+ if (result?.code) {
348
+ if (result.state !== verifier) {
349
+ throw new Error("OAuth state mismatch - possible CSRF attack");
350
+ }
351
+ code = result.code;
352
+ }
353
+ }
354
+
355
+ if (!code) {
356
+ throw new Error("No authorization code received");
266
357
  }
267
358
 
268
359
  // Exchange code for tokens
@@ -317,6 +408,6 @@ export async function loginAntigravity(
317
408
 
318
409
  return credentials;
319
410
  } finally {
320
- server.stop();
411
+ server.server.close();
321
412
  }
322
413
  }
@@ -6,6 +6,7 @@
6
6
  * It is only intended for CLI use, not browser environments.
7
7
  */
8
8
 
9
+ import type { Server } from "http";
9
10
  import { generatePKCE } from "./pkce";
10
11
  import type { OAuthCredentials } from "./types";
11
12
 
@@ -24,71 +25,97 @@ const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
24
25
  const TOKEN_URL = "https://oauth2.googleapis.com/token";
25
26
  const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
26
27
 
28
+ type CallbackServerInfo = {
29
+ server: Server;
30
+ cancelWait: () => void;
31
+ waitForCode: () => Promise<{ code: string; state: string } | null>;
32
+ };
33
+
27
34
  /**
28
35
  * Start a local HTTP server to receive the OAuth callback
29
36
  */
30
- async function startCallbackServer(): Promise<{
31
- server: { stop: () => void };
32
- getCode: () => Promise<{ code: string; state: string }>;
33
- }> {
37
+ async function startCallbackServer(): Promise<CallbackServerInfo> {
38
+ const { createServer } = await import("http");
39
+
34
40
  return new Promise((resolve, reject) => {
35
- let codeResolve: (value: { code: string; state: string }) => void;
36
- let codeReject: (error: Error) => void;
41
+ let result: { code: string; state: string } | null = null;
42
+ let cancelled = false;
37
43
 
38
- const codePromise = new Promise<{ code: string; state: string }>((res, rej) => {
39
- codeResolve = res;
40
- codeReject = rej;
41
- });
44
+ const server = createServer((req, res) => {
45
+ const url = new URL(req.url || "", `http://localhost:8085`);
42
46
 
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
- }
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");
61
51
 
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
- }
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
+ }
69
59
 
70
- codeReject(new Error("Missing code or state in callback"));
71
- return new Response(
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(
72
69
  `<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
73
- { status: 400, headers: { "Content-Type": "text/html" } },
74
70
  );
75
71
  }
72
+ } else {
73
+ res.writeHead(404);
74
+ res.end();
75
+ }
76
+ });
76
77
 
77
- return new Response(null, { status: 404 });
78
- },
79
- error(err) {
80
- reject(err);
81
- return new Response("Internal Server Error", { status: 500 });
82
- },
78
+ server.on("error", (err) => {
79
+ reject(err);
83
80
  });
84
81
 
85
- resolve({
86
- server,
87
- getCode: () => codePromise,
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
+ });
88
96
  });
89
97
  });
90
98
  }
91
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
+
92
119
  interface LoadCodeAssistPayload {
93
120
  cloudaicompanionProject?: string;
94
121
  currentTier?: { id?: string };
@@ -254,16 +281,21 @@ export async function refreshGoogleCloudToken(refreshToken: string, projectId: s
254
281
  *
255
282
  * @param onAuth - Callback with URL and optional instructions
256
283
  * @param onProgress - Optional progress callback
284
+ * @param onManualCodeInput - Optional promise that resolves with user-pasted redirect URL.
285
+ * Races with browser callback - whichever completes first wins.
257
286
  */
258
287
  export async function loginGeminiCli(
259
288
  onAuth: (info: { url: string; instructions?: string }) => void,
260
289
  onProgress?: (message: string) => void,
290
+ onManualCodeInput?: () => Promise<string>,
261
291
  ): Promise<OAuthCredentials> {
262
292
  const { verifier, challenge } = await generatePKCE();
263
293
 
264
294
  // Start local server for callback
265
295
  onProgress?.("Starting local server for OAuth callback...");
266
- const { server, getCode } = await startCallbackServer();
296
+ const server = await startCallbackServer();
297
+
298
+ let code: string | undefined;
267
299
 
268
300
  try {
269
301
  // Build authorization URL
@@ -284,16 +316,75 @@ export async function loginGeminiCli(
284
316
  // Notify caller with URL to open
285
317
  onAuth({
286
318
  url: authUrl,
287
- instructions: "Complete the sign-in in your browser. The callback will be captured automatically.",
319
+ instructions: "Complete the sign-in in your browser.",
288
320
  });
289
321
 
290
- // Wait for the callback
322
+ // Wait for the callback, racing with manual input if provided
291
323
  onProgress?.("Waiting for OAuth callback...");
292
- const { code, state } = await getCode();
293
324
 
294
- // Verify state matches
295
- if (state !== verifier) {
296
- throw new Error("OAuth state mismatch - possible CSRF attack");
325
+ if (onManualCodeInput) {
326
+ // Race between browser callback and manual input
327
+ let manualInput: string | undefined;
328
+ let manualError: Error | undefined;
329
+ const manualPromise = onManualCodeInput()
330
+ .then((input) => {
331
+ manualInput = input;
332
+ server.cancelWait();
333
+ })
334
+ .catch((err) => {
335
+ manualError = err instanceof Error ? err : new Error(String(err));
336
+ server.cancelWait();
337
+ });
338
+
339
+ const result = await server.waitForCode();
340
+
341
+ // If manual input was cancelled, throw that error
342
+ if (manualError) {
343
+ throw manualError;
344
+ }
345
+
346
+ if (result?.code) {
347
+ // Browser callback won - verify state
348
+ if (result.state !== verifier) {
349
+ throw new Error("OAuth state mismatch - possible CSRF attack");
350
+ }
351
+ code = result.code;
352
+ } else if (manualInput) {
353
+ // Manual input won
354
+ const parsed = parseRedirectUrl(manualInput);
355
+ if (parsed.state && parsed.state !== verifier) {
356
+ throw new Error("OAuth state mismatch - possible CSRF attack");
357
+ }
358
+ code = parsed.code;
359
+ }
360
+
361
+ // If still no code, wait for manual promise and try that
362
+ if (!code) {
363
+ await manualPromise;
364
+ if (manualError) {
365
+ throw manualError;
366
+ }
367
+ if (manualInput) {
368
+ const parsed = parseRedirectUrl(manualInput);
369
+ if (parsed.state && parsed.state !== verifier) {
370
+ throw new Error("OAuth state mismatch - possible CSRF attack");
371
+ }
372
+ code = parsed.code;
373
+ }
374
+ }
375
+ } else {
376
+ // Original flow: just wait for callback
377
+ const result = await server.waitForCode();
378
+ if (result?.code) {
379
+ if (result.state !== verifier) {
380
+ throw new Error("OAuth state mismatch - possible CSRF attack");
381
+ }
382
+ code = result.code;
383
+ }
384
+ }
385
+
386
+ if (!code) {
387
+ throw new Error("No authorization code received");
297
388
  }
298
389
 
299
390
  // Exchange code for tokens
@@ -348,6 +439,6 @@ export async function loginGeminiCli(
348
439
 
349
440
  return credentials;
350
441
  } finally {
351
- server.stop();
442
+ server.server.close();
352
443
  }
353
444
  }
@@ -133,6 +133,11 @@ export function getOAuthProviders(): OAuthProviderInfo[] {
133
133
  name: "Anthropic (Claude Pro/Max)",
134
134
  available: true,
135
135
  },
136
+ {
137
+ id: "openai-codex",
138
+ name: "ChatGPT Plus/Pro (Codex Subscription)",
139
+ available: true,
140
+ },
136
141
  {
137
142
  id: "github-copilot",
138
143
  name: "GitHub Copilot",
@@ -148,10 +153,5 @@ export function getOAuthProviders(): OAuthProviderInfo[] {
148
153
  name: "Antigravity (Gemini 3, Claude, GPT-OSS)",
149
154
  available: true,
150
155
  },
151
- {
152
- id: "openai-codex",
153
- name: "ChatGPT Plus/Pro (Codex Subscription)",
154
- available: true,
155
- },
156
156
  ];
157
157
  }