egaki 0.10.0 → 0.12.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/dist/cli/chatgpt-auth.d.ts +30 -5
  3. package/dist/cli/chatgpt-auth.d.ts.map +1 -1
  4. package/dist/cli/chatgpt-auth.js +257 -79
  5. package/dist/cli/cli.d.ts.map +1 -1
  6. package/dist/cli/cli.js +97 -30
  7. package/dist/cli/generate.d.ts +10 -2
  8. package/dist/cli/generate.d.ts.map +1 -1
  9. package/dist/cli/generate.js +172 -59
  10. package/dist/cli/generate.test.js +50 -1
  11. package/dist/cli/login.d.ts +38 -3
  12. package/dist/cli/login.d.ts.map +1 -1
  13. package/dist/cli/login.js +44 -17
  14. package/dist/cli/login.test.d.ts +2 -0
  15. package/dist/cli/login.test.d.ts.map +1 -0
  16. package/dist/cli/login.test.js +158 -0
  17. package/dist/cli/model-catalog.d.ts.map +1 -1
  18. package/dist/cli/model-catalog.js +121 -5
  19. package/dist/cli/open-browser.d.ts +3 -1
  20. package/dist/cli/open-browser.d.ts.map +1 -1
  21. package/dist/cli/open-browser.js +2 -2
  22. package/dist/cli/xai-auth.d.ts +3 -1
  23. package/dist/cli/xai-auth.d.ts.map +1 -1
  24. package/dist/cli/xai-auth.js +13 -3
  25. package/dist/vite/generated-media-client.d.ts.map +1 -1
  26. package/dist/vite/generated-media-client.js +2 -2
  27. package/dist/vite/vite-plugin.d.ts.map +1 -1
  28. package/dist/vite/vite-plugin.js +20 -1
  29. package/package.json +7 -7
  30. package/skills/egaki/SKILL.md +14 -0
  31. package/src/cli/chatgpt-auth.ts +379 -89
  32. package/src/cli/cli.ts +115 -29
  33. package/src/cli/generate.test.ts +57 -0
  34. package/src/cli/generate.ts +198 -67
  35. package/src/cli/login.test.ts +185 -0
  36. package/src/cli/login.ts +68 -16
  37. package/src/cli/model-catalog.ts +128 -5
  38. package/src/cli/open-browser.ts +7 -6
  39. package/src/cli/xai-auth.ts +15 -3
  40. package/src/vite/generated-media-client.tsx +6 -1
  41. package/src/vite/vite-plugin.ts +20 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,89 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.12.0
6
+
7
+ 1. **New GPT Image 2.5 models** — `gpt-image-2.5-flare` (fast, everyday generation and editing) and `gpt-image-2.5-sunburst` (most capable, best editing precision and reference fidelity). Both add the new `xhigh` and `max` quality levels:
8
+
9
+ ```bash
10
+ egaki image "a ceramic vase on linen, studio light" \
11
+ -m gpt-image-2.5-sunburst --quality max -o vase.png
12
+ ```
13
+
14
+ 2. **New `grok-imagine-image-2.0` model** — $0.04 per image at low quality. `--quality` accepts `auto`, `low` and `medium`; default is `auto`. It replaces `grok-imagine-image-quality` in the model picker. xAI retires `grok-imagine-image-quality` on 2026-11-02:
15
+
16
+ ```bash
17
+ egaki image "a fox wearing armor in a misty forest" \
18
+ -m grok-imagine-image-2.0 --quality medium --resolution 2k -o fox.png
19
+ ```
20
+
21
+ 3. **New `grok-imagine-video-1.5-lite` model** — cheap text-to-video and image-to-video at 480p, 720p or 1080p (1080p is upscaled from 720p). From $0.02 per second:
22
+
23
+ ```bash
24
+ egaki video "a paper boat drifting on a rainy street" \
25
+ -m grok-imagine-video-1.5-lite --resolution 720p -o boat.mp4
26
+ ```
27
+
28
+ 4. **`grok-imagine-video-1.5` supports text-to-video, 1080p and up to 14 reference images.** It also adds the `21:9` and `5:2` aspect ratios. Reference-to-video is capped at 720p:
29
+
30
+ ```bash
31
+ egaki video "the model walks down a white runway" \
32
+ -m grok-imagine-video-1.5 \
33
+ --mode reference-to-video \
34
+ --reference-images ./model-face.jpg \
35
+ --reference-images ./outfit.jpg \
36
+ -o runway.mp4
37
+ ```
38
+
39
+ Thanks @itstamhn for #10 and #11!
40
+
41
+ ## 0.11.0
42
+
43
+ 1. **Background OAuth login for agents and non-TTY shells.** `egaki login --provider chatgpt` and `egaki login --provider xai-oauth` start the browser login and return. An interactive terminal still stays attached until login finishes.
44
+
45
+ ```bash
46
+ egaki login --provider chatgpt
47
+ # Login is running in the background and the browser will open.
48
+ # After approval, verify with: egaki login status chatgpt
49
+ ```
50
+
51
+ 2. **New `egaki login status` command** — check one provider. The command exits with status 1 until that login is complete:
52
+
53
+ ```bash
54
+ egaki login status chatgpt
55
+ egaki login status xai-oauth
56
+ ```
57
+
58
+ 3. **Shell completions** — install, print, or remove the completion script:
59
+
60
+ ```bash
61
+ egaki completions install
62
+ egaki completions script
63
+ egaki completions uninstall
64
+ ```
65
+
66
+ 4. **`--aspect-ratio` works on OpenAI image models.** `gpt-image-*` and `dall-e-*` have no native aspect-ratio parameter. The flag now picks the closest supported size and states the ratio in the prompt. A 16:9 request on `gpt-image-1-mini` produces 1536x1024 instead of a silent 1024x1024 square:
67
+
68
+ ```bash
69
+ egaki image "a red cube on a white table" \
70
+ -m gpt-image-1-mini --aspect-ratio 16:9
71
+ ```
72
+
73
+ 5. **ChatGPT image generation uses the account model list.** A fixed slug such as `gpt-5.4` is no longer sent. Generation uses the highest-priority model that is visible, API-supported, and included in the saved plan. If that model is rejected, it retries once with the next listed model.
74
+
75
+ 6. **ChatGPT login is saved only after the token exchange succeeds.** The redirect uses `localhost`, which the allow-list accepts. The listener stays on `127.0.0.1`. A failed exchange does not replace the previous login. `egaki login --show` no longer calls a revoked token valid. It says the token is not expired locally, or that the next request will try to refresh.
76
+
77
+ 7. **`egaki subscribe --plan` accepts only `plus` and `pro`.** Plus is $29/mo. Pro is $99/mo. An invalid plan name fails in the CLI instead of opening a buy URL that returns 400:
78
+
79
+ ```bash
80
+ egaki subscribe --plan plus
81
+ egaki subscribe --plan pro
82
+ ```
83
+
84
+ 8. **Fixed HMR for a root `.mdx` file that is also imported.** Editing `tagline.mdx` when another file imports it updates the player without a manual reload.
85
+
86
+ 9. **`egaki dev` prints SDK method names and a docs link** after the server URL, including `seekTo`, `screenshot`, `filmstrip`, `export`, and `getInfo`.
87
+
5
88
  ## 0.10.0
6
89
 
7
90
  1. **New `egaki dev` command — zero-config MDX video dev server.** Serve an `.mdx` file with no project setup at all: no `package.json`, no `vite.config.ts`, no `npm install`. All dependencies (react, remotion, vite) come from the egaki CLI's own installation:
@@ -8,15 +8,40 @@ export type ChatGptAuth = {
8
8
  };
9
9
  /** Extract the ChatGPT plan type (e.g. "plus", "pro") from the access token. */
10
10
  export declare function extractPlanType(auth: ChatGptAuth): string | undefined;
11
+ export declare function isRevokedChatGptAccessToken(status: number, body: string): boolean;
12
+ export declare function oauthCallbackAction({ pendingState, callbackState, error, code, }: {
13
+ pendingState: string | undefined;
14
+ callbackState: string | null;
15
+ error: string | null;
16
+ code: string | null;
17
+ }): 'ignore' | 'provider-error' | 'missing-code' | 'exchange';
18
+ export declare function chatGptReLoginMessage({ status, body, }: {
19
+ status: number;
20
+ body: string;
21
+ }): string;
22
+ export type ChatGptResponsesModel = {
23
+ slug: string;
24
+ visibility?: string;
25
+ priority?: number;
26
+ supportedInApi?: boolean;
27
+ plans?: string[];
28
+ };
29
+ export declare function pickChatGptResponsesModel({ models, plan, exclude, }: {
30
+ models: ChatGptResponsesModel[];
31
+ plan: string | undefined;
32
+ exclude?: string[];
33
+ }): string | null;
34
+ export declare function resolveChatGptResponsesModel({ auth, exclude, }: {
35
+ auth: ChatGptAuth;
36
+ exclude?: string[];
37
+ }): Promise<string | Error>;
11
38
  /**
12
39
  * Run the full ChatGPT OAuth browser flow with clack UI.
13
40
  * Returns the ChatGptAuth object to be stored in credentials.json.
14
41
  */
15
- export declare function chatGptOAuthLogin(): Promise<ChatGptAuth>;
16
- /**
17
- * Refresh an expired ChatGPT access token using the refresh token.
18
- * Returns the updated auth object, or an Error if refresh fails.
19
- */
42
+ export declare function chatGptOAuthLogin({ openInBackground, }?: {
43
+ openInBackground?: boolean | undefined;
44
+ }): Promise<ChatGptAuth>;
20
45
  export declare function refreshChatGptToken(auth: ChatGptAuth): Promise<ChatGptAuth | Error>;
21
46
  /**
22
47
  * Refresh the auth object if needed and return the full updated state.
@@ -1 +1 @@
1
- {"version":3,"file":"chatgpt-auth.d.ts","sourceRoot":"","sources":["../../src/cli/chatgpt-auth.ts"],"names":[],"mappings":"AAmBA,MAAM,MAAM,WAAW,GAAG;IACxB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,CAAA;CAChB,CAAA;AAgGD,gFAAgF;AAChF,wBAAgB,eAAe,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,GAAG,SAAS,CAOrE;AA8JD;;;GAGG;AACH,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,WAAW,CAAC,CA8C9D;AAED;;;GAGG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,WAAW,GAAG,KAAK,CAAC,CAyBzF;AAED;;GAEG;AACH,wBAAsB,mBAAmB,CACvC,IAAI,EAAE,WAAW,EACjB,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,IAAI,GACzC,OAAO,CAAC,WAAW,GAAG,KAAK,CAAC,CAS9B"}
1
+ {"version":3,"file":"chatgpt-auth.d.ts","sourceRoot":"","sources":["../../src/cli/chatgpt-auth.ts"],"names":[],"mappings":"AAoBA,MAAM,MAAM,WAAW,GAAG;IACxB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,CAAA;CAChB,CAAA;AAoGD,gFAAgF;AAChF,wBAAgB,eAAe,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,GAAG,SAAS,CAKrE;AAyDD,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAKjF;AAED,wBAAgB,mBAAmB,CAAC,EAClC,YAAY,EACZ,aAAa,EACb,KAAK,EACL,IAAI,GACL,EAAE;IACD,YAAY,EAAE,MAAM,GAAG,SAAS,CAAA;IAChC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CACpB,GAAG,QAAQ,GAAG,gBAAgB,GAAG,cAAc,GAAG,UAAU,CAK5D;AAED,wBAAgB,qBAAqB,CAAC,EACpC,MAAM,EACN,IAAI,GACL,EAAE;IACD,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,MAAM,CAAA;CACb,GAAG,MAAM,CAGT;AAED,MAAM,MAAM,qBAAqB,GAAG;IAClC,IAAI,EAAE,MAAM,CAAA;IACZ,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;CACjB,CAAA;AAID,wBAAgB,yBAAyB,CAAC,EACxC,MAAM,EACN,IAAI,EACJ,OAAY,GACb,EAAE;IACD,MAAM,EAAE,qBAAqB,EAAE,CAAA;IAC/B,IAAI,EAAE,MAAM,GAAG,SAAS,CAAA;IACxB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;CACnB,GAAG,MAAM,GAAG,IAAI,CAWhB;AAED,wBAAsB,4BAA4B,CAAC,EACjD,IAAI,EACJ,OAAY,GACb,EAAE;IACD,IAAI,EAAE,WAAW,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;CACnB,GAAG,OAAO,CAAC,MAAM,GAAG,KAAK,CAAC,CAiD1B;AAsMD;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,EACtC,gBAAwB,GACzB;;CAAK,GAAG,OAAO,CAAC,WAAW,CAAC,CAwE5B;AA8DD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,WAAW,GAAG,KAAK,CAAC,CAUnF;AAED;;GAEG;AACH,wBAAsB,mBAAmB,CACvC,IAAI,EAAE,WAAW,EACjB,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,IAAI,GACzC,OAAO,CAAC,WAAW,GAAG,KAAK,CAAC,CAS9B"}
@@ -6,13 +6,14 @@
6
6
  // Tokens are stored so egaki can reuse the ChatGPT login for Codex-style
7
7
  // backend requests and refresh them when needed.
8
8
  import { createServer } from 'node:http';
9
- import { spawn } from 'node:child_process';
10
9
  import { spinner, log, note } from '@clack/prompts';
11
- import pc from 'picocolors';
10
+ import { colors as pc } from 'goke';
11
+ import { z } from 'zod';
12
+ import { openUrlInBrowser } from './open-browser.js';
12
13
  const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
13
14
  const ISSUER = 'https://auth.openai.com';
14
- const OAUTH_PORT = 1455;
15
- const REDIRECT_URI = `http://localhost:${OAUTH_PORT}/auth/callback`;
15
+ // 1457 is the Codex fallback. The shared client allow-list accepts both.
16
+ const OAUTH_PORTS = [1455, 1457];
16
17
  // ─── PKCE helpers ────────────────────────────────────────────────────────────
17
18
  function randomString(length) {
18
19
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
@@ -31,10 +32,15 @@ async function generatePKCE() {
31
32
  const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
32
33
  return { verifier, challenge: base64UrlEncode(hash) };
33
34
  }
34
- function generateState() {
35
- return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer);
36
- }
37
35
  // ─── JWT parsing ─────────────────────────────────────────────────────────────
36
+ const jwtClaimsSchema = z.object({
37
+ email: z.string().optional(),
38
+ chatgpt_account_id: z.string().optional(),
39
+ 'https://api.openai.com/auth': z.object({
40
+ chatgpt_account_id: z.string().optional(),
41
+ chatgpt_plan_type: z.string().optional(),
42
+ }).optional(),
43
+ });
38
44
  function parseJwtClaims(token) {
39
45
  const parts = token.split('.');
40
46
  if (parts.length !== 3)
@@ -43,7 +49,7 @@ function parseJwtClaims(token) {
43
49
  if (!payload)
44
50
  return undefined;
45
51
  try {
46
- return JSON.parse(Buffer.from(payload, 'base64url').toString());
52
+ return jwtClaimsSchema.parse(JSON.parse(Buffer.from(payload, 'base64url').toString()));
47
53
  }
48
54
  catch {
49
55
  return undefined;
@@ -85,13 +91,13 @@ export function extractPlanType(auth) {
85
91
  return authClaim?.chatgpt_plan_type;
86
92
  }
87
93
  // ─── OAuth URL + token exchange ──────────────────────────────────────────────
88
- function buildAuthorizeUrl(pkce, state) {
94
+ function buildAuthorizeUrl({ challenge, state, redirectUri, }) {
89
95
  const params = new URLSearchParams({
90
96
  response_type: 'code',
91
97
  client_id: CLIENT_ID,
92
- redirect_uri: REDIRECT_URI,
98
+ redirect_uri: redirectUri,
93
99
  scope: 'openid profile email offline_access api.connectors.read api.connectors.invoke',
94
- code_challenge: pkce.challenge,
100
+ code_challenge: challenge,
95
101
  code_challenge_method: 'S256',
96
102
  id_token_add_organizations: 'true',
97
103
  codex_cli_simplified_flow: 'true',
@@ -100,23 +106,94 @@ function buildAuthorizeUrl(pkce, state) {
100
106
  });
101
107
  return `${ISSUER}/oauth/authorize?${params.toString()}`;
102
108
  }
103
- async function exchangeCodeForTokens(code, verifier) {
109
+ async function exchangeCodeForTokens({ code, verifier, redirectUri, }) {
104
110
  const response = await fetch(`${ISSUER}/oauth/token`, {
105
111
  method: 'POST',
106
112
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
107
113
  body: new URLSearchParams({
108
114
  grant_type: 'authorization_code',
109
115
  code,
110
- redirect_uri: REDIRECT_URI,
116
+ redirect_uri: redirectUri,
111
117
  client_id: CLIENT_ID,
112
118
  code_verifier: verifier,
113
119
  }).toString(),
114
120
  });
115
121
  if (!response.ok) {
116
- throw new Error(`Token exchange failed: ${response.status}`);
122
+ const body = await response.text().catch(() => '');
123
+ throw new Error(`Token exchange failed: ${response.status}${body ? ` ${body}` : ''}. The previous ChatGPT login was not replaced.`);
117
124
  }
118
125
  return (await response.json());
119
126
  }
127
+ export function isRevokedChatGptAccessToken(status, body) {
128
+ return (status === 401 &&
129
+ (body.includes('token_revoked') || body.includes('invalidated oauth token')));
130
+ }
131
+ export function oauthCallbackAction({ pendingState, callbackState, error, code, }) {
132
+ if (!pendingState || callbackState !== pendingState)
133
+ return 'ignore';
134
+ if (error)
135
+ return 'provider-error';
136
+ if (!code)
137
+ return 'missing-code';
138
+ return 'exchange';
139
+ }
140
+ export function chatGptReLoginMessage({ status, body, }) {
141
+ const detail = body.trim();
142
+ return `ChatGPT rejected the saved login (${status}${detail ? `: ${detail}` : ''}). Run: egaki login --provider chatgpt`;
143
+ }
144
+ // ChatGPT accounts reject a fixed slug such as gpt-5.4. Use the account's
145
+ // current models list and pick the highest-priority listed model.
146
+ export function pickChatGptResponsesModel({ models, plan, exclude = [], }) {
147
+ const ranked = models
148
+ .filter((model) => {
149
+ if (!model.slug || exclude.includes(model.slug))
150
+ return false;
151
+ if (model.visibility && model.visibility !== 'list')
152
+ return false;
153
+ if (model.supportedInApi === false)
154
+ return false;
155
+ if (plan && model.plans && !model.plans.includes(plan))
156
+ return false;
157
+ return true;
158
+ })
159
+ .sort((a, b) => (a.priority ?? 1_000) - (b.priority ?? 1_000));
160
+ return ranked[0]?.slug ?? null;
161
+ }
162
+ export async function resolveChatGptResponsesModel({ auth, exclude = [], }) {
163
+ const headers = new Headers({
164
+ Authorization: `Bearer ${auth.access}`,
165
+ });
166
+ if (auth.accountId)
167
+ headers.set('ChatGPT-Account-ID', auth.accountId);
168
+ const response = await fetch('https://chatgpt.com/backend-api/codex/models?client_version=0.0.0', { headers }).catch((err) => err instanceof Error ? err : new Error(String(err)));
169
+ if (response instanceof Error)
170
+ return response;
171
+ if (!response.ok) {
172
+ const body = await response.text().catch(() => '');
173
+ if (response.status === 401) {
174
+ return new Error(chatGptReLoginMessage({ status: response.status, body }));
175
+ }
176
+ return new Error(`ChatGPT model list failed: ${response.status}${body ? ` ${body}` : ''}`);
177
+ }
178
+ const json = (await response.json());
179
+ const slug = pickChatGptResponsesModel({
180
+ models: (json.models ?? []).flatMap((model) => model.slug
181
+ ? [{
182
+ slug: model.slug,
183
+ visibility: model.visibility,
184
+ priority: model.priority,
185
+ supportedInApi: model.supported_in_api,
186
+ plans: model.available_in_plans,
187
+ }]
188
+ : []),
189
+ plan: auth.plan,
190
+ exclude,
191
+ });
192
+ if (!slug) {
193
+ return new Error('No ChatGPT model is available for image generation. Run: egaki login --provider chatgpt');
194
+ }
195
+ return slug;
196
+ }
120
197
  // ─── local callback server ───────────────────────────────────────────────────
121
198
  const HTML_SUCCESS = `<!doctype html>
122
199
  <html><body style="font-family:system-ui;text-align:center;padding:4em">
@@ -125,10 +202,27 @@ const HTML_SUCCESS = `<!doctype html>
125
202
  </body></html>`;
126
203
  const htmlError = (error) => `<!doctype html><html><body style="font-family:system-ui;text-align:center;padding:4em">
127
204
  <h1>Authorization Failed</h1><pre>${error}</pre></body></html>`;
205
+ function escapeHtml(value) {
206
+ return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
207
+ }
208
+ function sendHtml({ res, status, html, }) {
209
+ res.statusCode = status;
210
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
211
+ res.setHeader('Connection', 'close');
212
+ res.end(html);
213
+ }
214
+ function failCallback(res, message) {
215
+ pendingOAuth?.reject(new Error(message));
216
+ pendingOAuth = undefined;
217
+ sendHtml({ res, status: 400, html: htmlError(escapeHtml(message)) });
218
+ }
219
+ const OAUTH_HOST = '127.0.0.1';
128
220
  let oauthServer;
129
221
  let pendingOAuth;
130
- function handleOAuthRequest(req, res) {
131
- const url = new URL(req.url ?? '/', `http://localhost:${OAUTH_PORT}`);
222
+ // Browser success used to be sent before token exchange. The page then said
223
+ // login was done while credentials.json stayed unchanged.
224
+ async function handleOAuthRequest(req, res) {
225
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
132
226
  if (url.pathname !== '/auth/callback') {
133
227
  res.statusCode = 404;
134
228
  res.end('Not found');
@@ -138,68 +232,110 @@ function handleOAuthRequest(req, res) {
138
232
  const state = url.searchParams.get('state');
139
233
  const error = url.searchParams.get('error');
140
234
  const errorDescription = url.searchParams.get('error_description');
141
- if (error) {
142
- const message = errorDescription || error;
143
- pendingOAuth?.reject(new Error(message));
144
- pendingOAuth = undefined;
145
- res.statusCode = 400;
146
- res.setHeader('Content-Type', 'text/html');
147
- res.end(htmlError(message));
235
+ const action = oauthCallbackAction({
236
+ pendingState: pendingOAuth?.state,
237
+ callbackState: state,
238
+ error,
239
+ code,
240
+ });
241
+ if (action === 'ignore') {
242
+ sendHtml({
243
+ res,
244
+ status: 400,
245
+ html: htmlError(escapeHtml('Invalid state. This callback does not match the active egaki login.')),
246
+ });
148
247
  return;
149
248
  }
150
- if (!code) {
151
- const message = 'Missing authorization code';
152
- pendingOAuth?.reject(new Error(message));
153
- pendingOAuth = undefined;
154
- res.statusCode = 400;
155
- res.setHeader('Content-Type', 'text/html');
156
- res.end(htmlError(message));
249
+ if (action === 'provider-error') {
250
+ failCallback(res, errorDescription || error || 'Authorization failed');
157
251
  return;
158
252
  }
159
- if (!pendingOAuth || state !== pendingOAuth.state) {
160
- const message = 'Invalid state — potential CSRF attack';
161
- pendingOAuth?.reject(new Error(message));
162
- pendingOAuth = undefined;
163
- res.statusCode = 400;
164
- res.setHeader('Content-Type', 'text/html');
165
- res.end(htmlError(message));
253
+ if (action === 'missing-code' || !code) {
254
+ failCallback(res, 'Missing authorization code. The previous ChatGPT login was not replaced.');
166
255
  return;
167
256
  }
168
257
  const current = pendingOAuth;
258
+ if (!current) {
259
+ sendHtml({
260
+ res,
261
+ status: 400,
262
+ html: htmlError(escapeHtml('Invalid state. This callback does not match the active egaki login.')),
263
+ });
264
+ return;
265
+ }
169
266
  pendingOAuth = undefined;
170
- exchangeCodeForTokens(code, current.verifier)
171
- .then((tokens) => current.resolve(tokens))
172
- .catch((err) => current.reject(err instanceof Error ? err : new Error(String(err))));
173
- res.statusCode = 200;
174
- res.setHeader('Content-Type', 'text/html');
175
- res.end(HTML_SUCCESS);
267
+ try {
268
+ const tokens = await exchangeCodeForTokens({
269
+ code,
270
+ verifier: current.verifier,
271
+ redirectUri: current.redirectUri,
272
+ });
273
+ sendHtml({ res, status: 200, html: HTML_SUCCESS });
274
+ current.resolve(tokens);
275
+ }
276
+ catch (err) {
277
+ const failure = err instanceof Error ? err : new Error(String(err));
278
+ current.reject(failure);
279
+ sendHtml({ res, status: 400, html: htmlError(escapeHtml(failure.message)) });
280
+ }
176
281
  }
177
- async function startOAuthServer() {
178
- if (oauthServer)
179
- return;
180
- oauthServer = createServer(handleOAuthRequest);
181
- await new Promise((resolve, reject) => {
182
- oauthServer?.once('error', reject);
183
- oauthServer?.listen(OAUTH_PORT, '127.0.0.1', () => resolve());
282
+ function listen(server, port) {
283
+ return new Promise((resolve, reject) => {
284
+ const onError = (err) => {
285
+ server.close();
286
+ reject(err);
287
+ };
288
+ server.once('error', onError);
289
+ server.listen(port, OAUTH_HOST, () => {
290
+ server.removeListener('error', onError);
291
+ resolve();
292
+ });
184
293
  });
185
294
  }
295
+ // Listen on 127.0.0.1 only. The redirect host must stay localhost for the allow-list.
296
+ async function startOAuthServer() {
297
+ if (oauthServer) {
298
+ const address = oauthServer.address();
299
+ if (address && typeof address === 'object')
300
+ return address.port;
301
+ }
302
+ const errors = [];
303
+ for (const port of OAUTH_PORTS) {
304
+ const server = createServer((req, res) => {
305
+ void handleOAuthRequest(req, res);
306
+ });
307
+ try {
308
+ await listen(server, port);
309
+ }
310
+ catch (err) {
311
+ const message = err instanceof Error ? err.message : String(err);
312
+ errors.push(`${OAUTH_HOST}:${port} ${message}`);
313
+ continue;
314
+ }
315
+ oauthServer = server;
316
+ return port;
317
+ }
318
+ throw new Error(`Could not listen for the ChatGPT login callback. ${errors.join(' ')} Close Codex or another egaki login, then run egaki login --provider chatgpt again.`);
319
+ }
186
320
  async function stopOAuthServer() {
187
- if (!oauthServer)
188
- return;
189
- await new Promise((resolve) => oauthServer?.close(() => resolve()));
321
+ const server = oauthServer;
190
322
  oauthServer = undefined;
323
+ if (!server)
324
+ return;
325
+ await new Promise((resolve) => server.close(() => resolve()));
191
326
  }
192
- function waitForOAuthCallback(verifier, state) {
327
+ function waitForOAuthCallback({ verifier, state, redirectUri, }) {
193
328
  return new Promise((resolve, reject) => {
194
329
  const timeout = setTimeout(() => {
195
330
  if (pendingOAuth) {
196
331
  pendingOAuth = undefined;
197
- reject(new Error('OAuth callback timeout — authorization took too long'));
332
+ reject(new Error('OAuth callback timeout. The browser sign-in did not reach this process, so the previous ChatGPT login was not replaced.'));
198
333
  }
199
334
  }, 5 * 60 * 1000);
200
335
  pendingOAuth = {
201
336
  verifier,
202
337
  state,
338
+ redirectUri,
203
339
  resolve: (tokens) => {
204
340
  clearTimeout(timeout);
205
341
  resolve(tokens);
@@ -211,32 +347,52 @@ function waitForOAuthCallback(verifier, state) {
211
347
  };
212
348
  });
213
349
  }
214
- function openBrowser(url) {
215
- const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
216
- const child = spawn(cmd, [url], { detached: true, stdio: 'ignore' });
217
- child.unref();
218
- }
219
350
  // ─── public API ──────────────────────────────────────────────────────────────
220
351
  /**
221
352
  * Run the full ChatGPT OAuth browser flow with clack UI.
222
353
  * Returns the ChatGptAuth object to be stored in credentials.json.
223
354
  */
224
- export async function chatGptOAuthLogin() {
225
- await startOAuthServer();
355
+ export async function chatGptOAuthLogin({ openInBackground = false, } = {}) {
356
+ const port = await startOAuthServer();
357
+ // The allow-list accepts localhost, not 127.0.0.1. The listener stays on IPv4.
358
+ const redirectUri = `http://localhost:${port}/auth/callback`;
226
359
  const pkce = await generatePKCE();
227
- const state = generateState();
228
- const authUrl = buildAuthorizeUrl(pkce, state);
229
- const callbackPromise = waitForOAuthCallback(pkce.verifier, state);
360
+ const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer);
361
+ const authUrl = buildAuthorizeUrl({
362
+ challenge: pkce.challenge,
363
+ state,
364
+ redirectUri,
365
+ });
366
+ const callbackPromise = waitForOAuthCallback({
367
+ verifier: pkce.verifier,
368
+ state,
369
+ redirectUri,
370
+ });
230
371
  note(`Open this URL in your browser to sign in with your ChatGPT account:\n\n` +
231
372
  ` ${pc.cyan(pc.underline(authUrl))}\n\n` +
232
- `${pc.dim(`Listening for callback on ${REDIRECT_URI}`)}`, 'ChatGPT OAuth');
233
- openBrowser(authUrl);
373
+ `${pc.dim(`Listening for callback on ${redirectUri}`)}\n` +
374
+ `${pc.dim('The previous login stays saved until this command prints that the new login was saved.')}`, 'ChatGPT OAuth');
375
+ const opened = openUrlInBrowser(authUrl, {
376
+ allowNonInteractive: openInBackground,
377
+ });
378
+ if (openInBackground && !opened) {
379
+ const error = new Error('Could not open a browser for ChatGPT authorization');
380
+ pendingOAuth?.reject(error);
381
+ pendingOAuth = undefined;
382
+ await stopOAuthServer();
383
+ await callbackPromise;
384
+ throw error;
385
+ }
234
386
  const s = spinner();
235
387
  s.start('Waiting for authorization in browser...');
236
388
  let tokens;
237
389
  try {
238
390
  tokens = await callbackPromise;
239
391
  }
392
+ catch (err) {
393
+ s.stop('ChatGPT sign-in did not save new credentials');
394
+ throw err instanceof Error ? err : new Error(String(err));
395
+ }
240
396
  finally {
241
397
  await stopOAuthServer();
242
398
  }
@@ -255,30 +411,40 @@ export async function chatGptOAuthLogin() {
255
411
  log.success(`Signed in as ${pc.bold(label)}` + (plan ? ` (${pc.cyan(plan)} plan)` : ''));
256
412
  return auth;
257
413
  }
258
- /**
259
- * Refresh an expired ChatGPT access token using the refresh token.
260
- * Returns the updated auth object, or an Error if refresh fails.
261
- */
262
- export async function refreshChatGptToken(auth) {
414
+ let refreshFlight;
415
+ async function performChatGptTokenRefresh(auth) {
263
416
  try {
264
417
  const response = await fetch(`${ISSUER}/oauth/token`, {
265
418
  method: 'POST',
266
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
267
- body: new URLSearchParams({
419
+ headers: { 'Content-Type': 'application/json' },
420
+ body: JSON.stringify({
268
421
  grant_type: 'refresh_token',
269
422
  refresh_token: auth.refresh,
270
423
  client_id: CLIENT_ID,
271
- }).toString(),
424
+ }),
272
425
  });
273
426
  if (!response.ok) {
274
- return new Error(`Token refresh failed: ${response.status}`);
427
+ const body = await response.text().catch(() => '');
428
+ if (response.status === 401) {
429
+ return new Error(chatGptReLoginMessage({ status: response.status, body }));
430
+ }
431
+ return new Error(`Token refresh failed: ${response.status}${body ? ` ${body}` : ''}`);
275
432
  }
276
433
  const json = (await response.json());
434
+ if (!json.access_token) {
435
+ return new Error('ChatGPT token refresh did not return an access token. Run: egaki login --provider chatgpt');
436
+ }
437
+ const tokens = {
438
+ id_token: json.id_token,
439
+ access_token: json.access_token,
440
+ refresh_token: json.refresh_token ?? auth.refresh,
441
+ expires_in: json.expires_in,
442
+ };
277
443
  return {
278
444
  ...auth,
279
- plan: extractPlanTypeFromTokens(json) ?? auth.plan,
280
- access: json.access_token,
281
- refresh: json.refresh_token ?? auth.refresh,
445
+ plan: extractPlanTypeFromTokens(tokens) ?? auth.plan,
446
+ access: tokens.access_token,
447
+ refresh: tokens.refresh_token,
282
448
  expires: Date.now() + (json.expires_in ?? 3600) * 1000,
283
449
  };
284
450
  }
@@ -286,6 +452,18 @@ export async function refreshChatGptToken(auth) {
286
452
  return err instanceof Error ? err : new Error(String(err));
287
453
  }
288
454
  }
455
+ export function refreshChatGptToken(auth) {
456
+ if (refreshFlight?.source === auth.refresh)
457
+ return refreshFlight.result;
458
+ const result = performChatGptTokenRefresh(auth);
459
+ refreshFlight = { source: auth.refresh, result };
460
+ void result.then((value) => {
461
+ if (value instanceof Error && refreshFlight?.result === result) {
462
+ refreshFlight = undefined;
463
+ }
464
+ });
465
+ return result;
466
+ }
289
467
  /**
290
468
  * Refresh the auth object if needed and return the full updated state.
291
469
  */
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/cli/cli.ts"],"names":[],"mappings":";AAgGA,QAAA,MAAM,GAAG,yBAAgB,CAAA;AA61CzB,OAAO,EAAE,GAAG,EAAE,CAAA"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/cli/cli.ts"],"names":[],"mappings":";AAqGA,QAAA,MAAM,GAAG,yBAAgB,CAAA;AA46CzB,OAAO,EAAE,GAAG,EAAE,CAAA"}