egaki 0.5.0 → 0.6.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.
- package/CHANGELOG.md +57 -0
- package/README.md +107 -3
- package/dist/cli.js +459 -124
- package/dist/credentials.d.ts +11 -2
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +68 -5
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +45 -4
- package/dist/model-catalog.d.ts +96 -3
- package/dist/model-catalog.d.ts.map +1 -1
- package/dist/model-catalog.js +427 -3
- package/dist/models.d.ts +4 -4
- package/dist/models.d.ts.map +1 -1
- package/dist/models.js +162 -96
- package/dist/video-model-catalog.d.ts +1 -51
- package/dist/video-model-catalog.d.ts.map +1 -1
- package/dist/video-model-catalog.js +5 -306
- package/dist/xai-auth.d.ts +29 -0
- package/dist/xai-auth.d.ts.map +1 -0
- package/dist/xai-auth.js +325 -0
- package/package.json +5 -4
- package/src/cli.ts +621 -133
- package/src/credentials.ts +70 -7
- package/src/login.ts +48 -3
- package/src/model-catalog.ts +537 -7
- package/src/models.ts +239 -119
- package/src/video-model-catalog.ts +14 -363
- package/src/xai-auth.ts +396 -0
|
@@ -1,306 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
export
|
|
6
|
-
{
|
|
7
|
-
id: 'veo-3.1-generate-001',
|
|
8
|
-
name: 'Veo 3.1',
|
|
9
|
-
provider: 'google',
|
|
10
|
-
strategy: 'video',
|
|
11
|
-
released: '2026-01',
|
|
12
|
-
cost: {
|
|
13
|
-
type: 'per-video-second',
|
|
14
|
-
defaultDurationSec: 8,
|
|
15
|
-
tiers: [
|
|
16
|
-
{ resolution: '720p', audio: false, costPerSecond: 0.2 },
|
|
17
|
-
{ resolution: '720p', audio: true, costPerSecond: 0.4 },
|
|
18
|
-
{ resolution: '1080p', audio: false, costPerSecond: 0.2 },
|
|
19
|
-
{ resolution: '1080p', audio: true, costPerSecond: 0.4 },
|
|
20
|
-
{ resolution: '4k', audio: false, costPerSecond: 0.4 },
|
|
21
|
-
{ resolution: '4k', audio: true, costPerSecond: 0.6 },
|
|
22
|
-
],
|
|
23
|
-
},
|
|
24
|
-
features: {
|
|
25
|
-
textToVideo: true,
|
|
26
|
-
imageToVideo: false,
|
|
27
|
-
capabilities: ['t2v'],
|
|
28
|
-
aspectRatios: ['16:9', '9:16'],
|
|
29
|
-
resolutions: ['720p', '1080p', '4k'],
|
|
30
|
-
durationRangeSec: { min: 4, max: 8 },
|
|
31
|
-
seed: true,
|
|
32
|
-
multipleVideos: true,
|
|
33
|
-
},
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
id: 'veo-3.1-fast-generate-001',
|
|
37
|
-
name: 'Veo 3.1 Fast',
|
|
38
|
-
provider: 'google',
|
|
39
|
-
strategy: 'video',
|
|
40
|
-
released: '2026-01',
|
|
41
|
-
cost: {
|
|
42
|
-
type: 'per-video-second',
|
|
43
|
-
defaultDurationSec: 8,
|
|
44
|
-
tiers: [
|
|
45
|
-
{ resolution: '720p', audio: false, costPerSecond: 0.1 },
|
|
46
|
-
{ resolution: '720p', audio: true, costPerSecond: 0.15 },
|
|
47
|
-
{ resolution: '1080p', audio: false, costPerSecond: 0.1 },
|
|
48
|
-
{ resolution: '1080p', audio: true, costPerSecond: 0.15 },
|
|
49
|
-
{ resolution: '4k', audio: false, costPerSecond: 0.3 },
|
|
50
|
-
{ resolution: '4k', audio: true, costPerSecond: 0.35 },
|
|
51
|
-
],
|
|
52
|
-
},
|
|
53
|
-
features: {
|
|
54
|
-
textToVideo: true,
|
|
55
|
-
imageToVideo: false,
|
|
56
|
-
capabilities: ['t2v'],
|
|
57
|
-
aspectRatios: ['16:9', '9:16'],
|
|
58
|
-
resolutions: ['720p', '1080p', '4k'],
|
|
59
|
-
durationRangeSec: { min: 4, max: 8 },
|
|
60
|
-
seed: true,
|
|
61
|
-
multipleVideos: true,
|
|
62
|
-
},
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
id: 'veo-3.0-generate-001',
|
|
66
|
-
name: 'Veo 3.0',
|
|
67
|
-
provider: 'google',
|
|
68
|
-
strategy: 'video',
|
|
69
|
-
released: '2025-12',
|
|
70
|
-
cost: {
|
|
71
|
-
type: 'per-video-second',
|
|
72
|
-
defaultDurationSec: 8,
|
|
73
|
-
tiers: [
|
|
74
|
-
{ resolution: '720p', audio: false, costPerSecond: 0.2 },
|
|
75
|
-
{ resolution: '720p', audio: true, costPerSecond: 0.4 },
|
|
76
|
-
{ resolution: '1080p', audio: false, costPerSecond: 0.2 },
|
|
77
|
-
{ resolution: '1080p', audio: true, costPerSecond: 0.4 },
|
|
78
|
-
],
|
|
79
|
-
},
|
|
80
|
-
features: {
|
|
81
|
-
textToVideo: true,
|
|
82
|
-
imageToVideo: false,
|
|
83
|
-
capabilities: ['t2v'],
|
|
84
|
-
aspectRatios: ['16:9', '9:16'],
|
|
85
|
-
resolutions: ['720p', '1080p'],
|
|
86
|
-
durationRangeSec: { min: 4, max: 8 },
|
|
87
|
-
seed: true,
|
|
88
|
-
multipleVideos: true,
|
|
89
|
-
},
|
|
90
|
-
},
|
|
91
|
-
{
|
|
92
|
-
id: 'veo-3.0-fast-generate-001',
|
|
93
|
-
name: 'Veo 3.0 Fast',
|
|
94
|
-
provider: 'google',
|
|
95
|
-
strategy: 'video',
|
|
96
|
-
released: '2025-12',
|
|
97
|
-
cost: {
|
|
98
|
-
type: 'per-video-second',
|
|
99
|
-
defaultDurationSec: 8,
|
|
100
|
-
tiers: [
|
|
101
|
-
{ resolution: '720p', audio: false, costPerSecond: 0.1 },
|
|
102
|
-
{ resolution: '720p', audio: true, costPerSecond: 0.15 },
|
|
103
|
-
{ resolution: '1080p', audio: false, costPerSecond: 0.1 },
|
|
104
|
-
{ resolution: '1080p', audio: true, costPerSecond: 0.15 },
|
|
105
|
-
],
|
|
106
|
-
},
|
|
107
|
-
features: {
|
|
108
|
-
textToVideo: true,
|
|
109
|
-
imageToVideo: false,
|
|
110
|
-
capabilities: ['t2v'],
|
|
111
|
-
aspectRatios: ['16:9', '9:16'],
|
|
112
|
-
resolutions: ['720p', '1080p'],
|
|
113
|
-
durationRangeSec: { min: 4, max: 8 },
|
|
114
|
-
seed: true,
|
|
115
|
-
multipleVideos: true,
|
|
116
|
-
},
|
|
117
|
-
},
|
|
118
|
-
// ── Vertex: Veo ──────────────────────────────────────────────────────
|
|
119
|
-
// Same Veo models routed through Vertex AI / Google Cloud billing.
|
|
120
|
-
// Use `egaki login --provider vertex --key <key>` to configure.
|
|
121
|
-
{
|
|
122
|
-
id: 'vertex/veo-3.1-generate-001',
|
|
123
|
-
name: 'Veo 3.1 (Vertex)',
|
|
124
|
-
provider: 'vertex',
|
|
125
|
-
strategy: 'video',
|
|
126
|
-
released: '2026-01',
|
|
127
|
-
cost: {
|
|
128
|
-
type: 'per-video-second',
|
|
129
|
-
defaultDurationSec: 8,
|
|
130
|
-
tiers: [
|
|
131
|
-
{ resolution: '720p', audio: false, costPerSecond: 0.2 },
|
|
132
|
-
{ resolution: '720p', audio: true, costPerSecond: 0.4 },
|
|
133
|
-
{ resolution: '1080p', audio: false, costPerSecond: 0.2 },
|
|
134
|
-
{ resolution: '1080p', audio: true, costPerSecond: 0.4 },
|
|
135
|
-
{ resolution: '4k', audio: false, costPerSecond: 0.4 },
|
|
136
|
-
{ resolution: '4k', audio: true, costPerSecond: 0.6 },
|
|
137
|
-
],
|
|
138
|
-
},
|
|
139
|
-
features: {
|
|
140
|
-
textToVideo: true,
|
|
141
|
-
imageToVideo: false,
|
|
142
|
-
capabilities: ['t2v'],
|
|
143
|
-
aspectRatios: ['16:9', '9:16'],
|
|
144
|
-
resolutions: ['720p', '1080p', '4k'],
|
|
145
|
-
durationRangeSec: { min: 4, max: 8 },
|
|
146
|
-
seed: true,
|
|
147
|
-
multipleVideos: true,
|
|
148
|
-
},
|
|
149
|
-
},
|
|
150
|
-
{
|
|
151
|
-
id: 'vertex/veo-3.1-fast-generate-001',
|
|
152
|
-
name: 'Veo 3.1 Fast (Vertex)',
|
|
153
|
-
provider: 'vertex',
|
|
154
|
-
strategy: 'video',
|
|
155
|
-
released: '2026-01',
|
|
156
|
-
cost: {
|
|
157
|
-
type: 'per-video-second',
|
|
158
|
-
defaultDurationSec: 8,
|
|
159
|
-
tiers: [
|
|
160
|
-
{ resolution: '720p', audio: false, costPerSecond: 0.1 },
|
|
161
|
-
{ resolution: '720p', audio: true, costPerSecond: 0.15 },
|
|
162
|
-
{ resolution: '1080p', audio: false, costPerSecond: 0.1 },
|
|
163
|
-
{ resolution: '1080p', audio: true, costPerSecond: 0.15 },
|
|
164
|
-
{ resolution: '4k', audio: false, costPerSecond: 0.3 },
|
|
165
|
-
{ resolution: '4k', audio: true, costPerSecond: 0.35 },
|
|
166
|
-
],
|
|
167
|
-
},
|
|
168
|
-
features: {
|
|
169
|
-
textToVideo: true,
|
|
170
|
-
imageToVideo: false,
|
|
171
|
-
capabilities: ['t2v'],
|
|
172
|
-
aspectRatios: ['16:9', '9:16'],
|
|
173
|
-
resolutions: ['720p', '1080p', '4k'],
|
|
174
|
-
durationRangeSec: { min: 4, max: 8 },
|
|
175
|
-
seed: true,
|
|
176
|
-
multipleVideos: true,
|
|
177
|
-
},
|
|
178
|
-
},
|
|
179
|
-
{
|
|
180
|
-
id: 'grok-imagine-video',
|
|
181
|
-
name: 'Grok Imagine Video',
|
|
182
|
-
provider: 'xai',
|
|
183
|
-
strategy: 'video',
|
|
184
|
-
released: '2026-03',
|
|
185
|
-
cost: {
|
|
186
|
-
type: 'per-video-second',
|
|
187
|
-
defaultDurationSec: 5,
|
|
188
|
-
tiers: [
|
|
189
|
-
{ resolution: '480p', costPerSecond: 0.05 },
|
|
190
|
-
{ resolution: '720p', costPerSecond: 0.07 },
|
|
191
|
-
],
|
|
192
|
-
},
|
|
193
|
-
features: {
|
|
194
|
-
textToVideo: true,
|
|
195
|
-
imageToVideo: true,
|
|
196
|
-
capabilities: ['t2v', 'i2v', 'editing'],
|
|
197
|
-
aspectRatios: ['1:1', '16:9', '9:16', '4:3', '3:4', '3:2', '2:3'],
|
|
198
|
-
resolutions: ['480p', '720p'],
|
|
199
|
-
durationRangeSec: { min: 1, max: 15 },
|
|
200
|
-
seed: false,
|
|
201
|
-
multipleVideos: false,
|
|
202
|
-
},
|
|
203
|
-
},
|
|
204
|
-
{
|
|
205
|
-
id: 'kling-v2.6-t2v',
|
|
206
|
-
name: 'Kling v2.6 T2V',
|
|
207
|
-
provider: 'klingai',
|
|
208
|
-
strategy: 'video',
|
|
209
|
-
released: '2025-12',
|
|
210
|
-
cost: {
|
|
211
|
-
type: 'per-video-second',
|
|
212
|
-
defaultDurationSec: 5,
|
|
213
|
-
tiers: [
|
|
214
|
-
{ mode: 'std', costPerSecond: 0.042 },
|
|
215
|
-
{ mode: 'pro', audio: false, costPerSecond: 0.07 },
|
|
216
|
-
{ mode: 'pro', audio: true, costPerSecond: 0.14 },
|
|
217
|
-
],
|
|
218
|
-
},
|
|
219
|
-
features: {
|
|
220
|
-
textToVideo: true,
|
|
221
|
-
imageToVideo: false,
|
|
222
|
-
capabilities: ['t2v'],
|
|
223
|
-
aspectRatios: commonRatios,
|
|
224
|
-
durationRangeSec: { min: 5, max: 10 },
|
|
225
|
-
seed: false,
|
|
226
|
-
multipleVideos: false,
|
|
227
|
-
},
|
|
228
|
-
},
|
|
229
|
-
{
|
|
230
|
-
id: 'wan-v2.6-t2v',
|
|
231
|
-
name: 'Wan v2.6 T2V',
|
|
232
|
-
provider: 'alibaba',
|
|
233
|
-
strategy: 'video',
|
|
234
|
-
released: '2026-01',
|
|
235
|
-
cost: {
|
|
236
|
-
type: 'per-video-second',
|
|
237
|
-
defaultDurationSec: 5,
|
|
238
|
-
tiers: [
|
|
239
|
-
{ resolution: '720p', costPerSecond: 0.1 },
|
|
240
|
-
{ resolution: '1080p', costPerSecond: 0.15 },
|
|
241
|
-
],
|
|
242
|
-
},
|
|
243
|
-
features: {
|
|
244
|
-
textToVideo: true,
|
|
245
|
-
imageToVideo: false,
|
|
246
|
-
capabilities: ['t2v'],
|
|
247
|
-
resolutions: ['720p', '1080p'],
|
|
248
|
-
durationRangeSec: { min: 2, max: 15 },
|
|
249
|
-
seed: false,
|
|
250
|
-
multipleVideos: false,
|
|
251
|
-
},
|
|
252
|
-
},
|
|
253
|
-
// Fal direct BYOK models — duration pricing depends on Fal endpoint/config.
|
|
254
|
-
{
|
|
255
|
-
id: 'luma-ray-2',
|
|
256
|
-
name: 'Luma Ray 2 (Fal)',
|
|
257
|
-
provider: 'fal',
|
|
258
|
-
strategy: 'video',
|
|
259
|
-
released: '2025-11',
|
|
260
|
-
cost: { type: 'unknown' },
|
|
261
|
-
features: {
|
|
262
|
-
textToVideo: true,
|
|
263
|
-
imageToVideo: true,
|
|
264
|
-
capabilities: ['t2v', 'i2v'],
|
|
265
|
-
aspectRatios: commonRatios,
|
|
266
|
-
seed: true,
|
|
267
|
-
multipleVideos: false,
|
|
268
|
-
},
|
|
269
|
-
},
|
|
270
|
-
{
|
|
271
|
-
id: 'minimax-video',
|
|
272
|
-
name: 'MiniMax Video (Fal)',
|
|
273
|
-
provider: 'fal',
|
|
274
|
-
strategy: 'video',
|
|
275
|
-
released: '2025-10',
|
|
276
|
-
cost: { type: 'unknown' },
|
|
277
|
-
features: {
|
|
278
|
-
textToVideo: true,
|
|
279
|
-
imageToVideo: false,
|
|
280
|
-
capabilities: ['t2v'],
|
|
281
|
-
aspectRatios: commonRatios,
|
|
282
|
-
seed: false,
|
|
283
|
-
multipleVideos: false,
|
|
284
|
-
},
|
|
285
|
-
},
|
|
286
|
-
{
|
|
287
|
-
id: 'hunyuan-video',
|
|
288
|
-
name: 'Hunyuan Video (Fal)',
|
|
289
|
-
provider: 'fal',
|
|
290
|
-
strategy: 'video',
|
|
291
|
-
released: '2025-09',
|
|
292
|
-
cost: { type: 'unknown' },
|
|
293
|
-
features: {
|
|
294
|
-
textToVideo: true,
|
|
295
|
-
imageToVideo: true,
|
|
296
|
-
capabilities: ['t2v', 'i2v'],
|
|
297
|
-
aspectRatios: commonRatios,
|
|
298
|
-
seed: true,
|
|
299
|
-
multipleVideos: false,
|
|
300
|
-
},
|
|
301
|
-
},
|
|
302
|
-
];
|
|
303
|
-
const videoCatalogIndex = new Map(VIDEO_CATALOG.map((m) => [m.id, m]));
|
|
304
|
-
export function findVideoModel(id) {
|
|
305
|
-
return videoCatalogIndex.get(id);
|
|
306
|
-
}
|
|
1
|
+
// Re-exports from the unified model catalog.
|
|
2
|
+
// video-model-catalog.ts used to be a separate file. All video model data now
|
|
3
|
+
// lives in model-catalog.ts alongside image models. This file exists only so
|
|
4
|
+
// existing imports (gateway/src/plans.ts, etc.) keep working without changes.
|
|
5
|
+
export { VIDEO_CATALOG, findVideoModel, } from './model-catalog.js';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export type XaiAuth = {
|
|
3
|
+
email?: string;
|
|
4
|
+
access: string;
|
|
5
|
+
refresh: string;
|
|
6
|
+
expires: number;
|
|
7
|
+
};
|
|
8
|
+
declare const tokenResponseSchema: z.ZodObject<{
|
|
9
|
+
access_token: z.ZodString;
|
|
10
|
+
refresh_token: z.ZodOptional<z.ZodString>;
|
|
11
|
+
id_token: z.ZodOptional<z.ZodString>;
|
|
12
|
+
expires_in: z.ZodOptional<z.ZodNumber>;
|
|
13
|
+
}, z.core.$strip>;
|
|
14
|
+
type TokenResponse = z.infer<typeof tokenResponseSchema>;
|
|
15
|
+
/** Returns true if the access token's exp claim is within the skew window. */
|
|
16
|
+
export declare function accessTokenIsExpiring(token: string | undefined, skewMs?: number): boolean;
|
|
17
|
+
export declare function refreshXaiToken(refreshToken: string): Promise<TokenResponse>;
|
|
18
|
+
/**
|
|
19
|
+
* Run the xAI OAuth browser flow. Opens the system browser for authorization,
|
|
20
|
+
* waits for the callback, and returns an XaiAuth object with tokens.
|
|
21
|
+
*/
|
|
22
|
+
export declare function xaiOAuthLogin(): Promise<XaiAuth>;
|
|
23
|
+
/**
|
|
24
|
+
* Returns a valid XaiAuth, refreshing the access token if it's expired or
|
|
25
|
+
* about to expire. Calls `save` to persist the refreshed tokens.
|
|
26
|
+
*/
|
|
27
|
+
export declare function getValidXaiAuth(auth: XaiAuth, save: (auth: XaiAuth) => void): Promise<XaiAuth | Error>;
|
|
28
|
+
export {};
|
|
29
|
+
//# sourceMappingURL=xai-auth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"xai-auth.d.ts","sourceRoot":"","sources":["../src/xai-auth.ts"],"names":[],"mappings":"AAeA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAwBvB,MAAM,MAAM,OAAO,GAAG;IACpB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,MAAM,CAAA;CAChB,CAAA;AAED,QAAA,MAAM,mBAAmB;;;;;iBAKvB,CAAA;AAEF,KAAK,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAA;AA+DxD,8EAA8E;AAC9E,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,MAAM,GAAE,MAAqC,GAC5C,OAAO,CAKT;AA6BD,wBAAsB,eAAe,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAelF;AAwHD;;;GAGG;AACH,wBAAsB,aAAa,IAAI,OAAO,CAAC,OAAO,CAAC,CAoEtD;AAID;;;GAGG;AACH,wBAAsB,eAAe,CACnC,IAAI,EAAE,OAAO,EACb,IAAI,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,GAC5B,OAAO,CAAC,OAAO,GAAG,KAAK,CAAC,CAuB1B"}
|
package/dist/xai-auth.js
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// xAI OAuth authentication for egaki.
|
|
2
|
+
// Implements the same OAuth 2.0 PKCE browser flow used by xAI's Grok CLI
|
|
3
|
+
// and the OpenCode xAI plugin. Tokens grant access to the full xAI API
|
|
4
|
+
// (api.x.ai/v1) including image and video generation endpoints.
|
|
5
|
+
//
|
|
6
|
+
// Two auth methods:
|
|
7
|
+
// 1. Browser flow: loopback server on 127.0.0.1:56121 (pinned to match Grok-CLI registration)
|
|
8
|
+
// 2. Device code flow (RFC 8628): headless/VPS environments, no loopback needed
|
|
9
|
+
//
|
|
10
|
+
// Tokens are stored in credentials.json under the 'xai-oauth' key as a structured
|
|
11
|
+
// XaiAuth object. The access token is injected as a bearer token via custom fetch
|
|
12
|
+
// when creating the @ai-sdk/xai provider instance.
|
|
13
|
+
import { createServer } from 'node:http';
|
|
14
|
+
import { spinner, log, note } from '@clack/prompts';
|
|
15
|
+
import pc from 'picocolors';
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import { openUrlInBrowser } from './open-browser.js';
|
|
18
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
19
|
+
// Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from
|
|
20
|
+
// non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships
|
|
21
|
+
// for desktop OAuth flows.
|
|
22
|
+
const CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828';
|
|
23
|
+
const AUTHORIZE_URL = 'https://auth.x.ai/oauth2/authorize';
|
|
24
|
+
const TOKEN_URL = 'https://auth.x.ai/oauth2/token';
|
|
25
|
+
const DEVICE_AUTHORIZATION_URL = 'https://auth.x.ai/oauth2/device/code';
|
|
26
|
+
const SCOPE = 'openid profile email offline_access grok-cli:access api:access';
|
|
27
|
+
// The loopback server must bind to this exact host:port pair — it's registered
|
|
28
|
+
// with xAI for the Grok-CLI client.
|
|
29
|
+
const OAUTH_HOST = '127.0.0.1';
|
|
30
|
+
const OAUTH_PORT = 56121;
|
|
31
|
+
const OAUTH_REDIRECT_PATH = '/callback';
|
|
32
|
+
const REDIRECT_URI = `http://${OAUTH_HOST}:${OAUTH_PORT}${OAUTH_REDIRECT_PATH}`;
|
|
33
|
+
const ACCESS_TOKEN_REFRESH_SKEW_MS = 120_000;
|
|
34
|
+
const tokenResponseSchema = z.object({
|
|
35
|
+
access_token: z.string(),
|
|
36
|
+
refresh_token: z.string().optional(),
|
|
37
|
+
id_token: z.string().optional(),
|
|
38
|
+
expires_in: z.number().optional(),
|
|
39
|
+
});
|
|
40
|
+
// ─── PKCE helpers ────────────────────────────────────────────────────────────
|
|
41
|
+
function randomString(length) {
|
|
42
|
+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
|
43
|
+
const bytes = crypto.getRandomValues(new Uint8Array(length));
|
|
44
|
+
return Array.from(bytes)
|
|
45
|
+
.map((b) => chars[b % chars.length])
|
|
46
|
+
.join('');
|
|
47
|
+
}
|
|
48
|
+
function base64UrlEncode(buffer) {
|
|
49
|
+
const bytes = new Uint8Array(buffer);
|
|
50
|
+
const binary = String.fromCharCode(...bytes);
|
|
51
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
52
|
+
}
|
|
53
|
+
async function generatePKCE() {
|
|
54
|
+
const verifier = randomString(64);
|
|
55
|
+
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
|
|
56
|
+
return { verifier, challenge: base64UrlEncode(hash) };
|
|
57
|
+
}
|
|
58
|
+
function generateState() {
|
|
59
|
+
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer);
|
|
60
|
+
}
|
|
61
|
+
function authHeaders() {
|
|
62
|
+
return {
|
|
63
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
64
|
+
Accept: 'application/json',
|
|
65
|
+
'User-Agent': `egaki/${pkg.version}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
// ─── JWT helpers ─────────────────────────────────────────────────────────────
|
|
69
|
+
function parseJwtClaims(token) {
|
|
70
|
+
const parts = token.split('.');
|
|
71
|
+
if (parts.length < 2)
|
|
72
|
+
return undefined;
|
|
73
|
+
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
74
|
+
while (payload.length % 4 !== 0)
|
|
75
|
+
payload += '=';
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(Buffer.from(payload, 'base64').toString('utf8'));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function extractEmailFromToken(idToken) {
|
|
84
|
+
if (!idToken)
|
|
85
|
+
return undefined;
|
|
86
|
+
const claims = parseJwtClaims(idToken);
|
|
87
|
+
return typeof claims?.email === 'string' ? claims.email : undefined;
|
|
88
|
+
}
|
|
89
|
+
/** Returns true if the access token's exp claim is within the skew window. */
|
|
90
|
+
export function accessTokenIsExpiring(token, skewMs = ACCESS_TOKEN_REFRESH_SKEW_MS) {
|
|
91
|
+
if (!token)
|
|
92
|
+
return false;
|
|
93
|
+
const claims = parseJwtClaims(token);
|
|
94
|
+
if (typeof claims?.exp !== 'number')
|
|
95
|
+
return false;
|
|
96
|
+
return claims.exp * 1000 <= Date.now() + Math.max(0, skewMs);
|
|
97
|
+
}
|
|
98
|
+
// ─── token exchange ──────────────────────────────────────────────────────────
|
|
99
|
+
async function exchangeCodeForTokens(code, verifier) {
|
|
100
|
+
const response = await fetch(TOKEN_URL, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: authHeaders(),
|
|
103
|
+
body: new URLSearchParams({
|
|
104
|
+
grant_type: 'authorization_code',
|
|
105
|
+
code,
|
|
106
|
+
redirect_uri: REDIRECT_URI,
|
|
107
|
+
client_id: CLIENT_ID,
|
|
108
|
+
code_verifier: verifier,
|
|
109
|
+
}).toString(),
|
|
110
|
+
});
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
const detail = await response.text().catch(() => '');
|
|
113
|
+
throw new Error(`xAI token exchange failed (${response.status})${detail ? `: ${detail}` : ''}`);
|
|
114
|
+
}
|
|
115
|
+
const tokens = tokenResponseSchema.parse(await response.json());
|
|
116
|
+
const refreshToken = tokens.refresh_token;
|
|
117
|
+
if (!refreshToken)
|
|
118
|
+
throw new Error('xAI token response was missing refresh_token');
|
|
119
|
+
return { ...tokens, refresh_token: refreshToken };
|
|
120
|
+
}
|
|
121
|
+
export async function refreshXaiToken(refreshToken) {
|
|
122
|
+
const response = await fetch(TOKEN_URL, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: authHeaders(),
|
|
125
|
+
body: new URLSearchParams({
|
|
126
|
+
grant_type: 'refresh_token',
|
|
127
|
+
refresh_token: refreshToken,
|
|
128
|
+
client_id: CLIENT_ID,
|
|
129
|
+
}).toString(),
|
|
130
|
+
});
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
const detail = await response.text().catch(() => '');
|
|
133
|
+
throw new Error(`xAI token refresh failed (${response.status})${detail ? `: ${detail}` : ''}`);
|
|
134
|
+
}
|
|
135
|
+
return tokenResponseSchema.parse(await response.json());
|
|
136
|
+
}
|
|
137
|
+
// ─── loopback server + browser flow ──────────────────────────────────────────
|
|
138
|
+
const HTML_SUCCESS = `<!doctype html>
|
|
139
|
+
<html><head><title>egaki - xAI Authorization Successful</title>
|
|
140
|
+
<style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#131010;color:#f1ecec}.container{text-align:center;padding:2rem}h1{margin-bottom:1rem}p{color:#b7b1b1}</style>
|
|
141
|
+
</head><body><div class="container"><h1>Authorization Successful</h1><p>You can close this window and return to egaki.</p></div>
|
|
142
|
+
<script>setTimeout(()=>window.close(),2000)</script></body></html>`;
|
|
143
|
+
function htmlError(error) {
|
|
144
|
+
const escaped = error.replace(/[&<>"']/g, (c) => {
|
|
145
|
+
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
146
|
+
return map[c] || c;
|
|
147
|
+
});
|
|
148
|
+
return `<!doctype html>
|
|
149
|
+
<html><head><title>egaki - xAI Authorization Failed</title>
|
|
150
|
+
<style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#131010;color:#f1ecec}.container{text-align:center;padding:2rem}h1{color:#fc533a;margin-bottom:1rem}p{color:#b7b1b1}.error{color:#ff917b;font-family:monospace;margin-top:1rem;padding:1rem;background:#3c140d;border-radius:.5rem}</style>
|
|
151
|
+
</head><body><div class="container"><h1>Authorization Failed</h1><p>An error occurred during authorization.</p><div class="error">${escaped}</div></div></body></html>`;
|
|
152
|
+
}
|
|
153
|
+
let pendingOAuth;
|
|
154
|
+
function startOAuthServer() {
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
const server = createServer((req, res) => {
|
|
157
|
+
const url = new URL(req.url || '/', `http://${OAUTH_HOST}:${OAUTH_PORT}`);
|
|
158
|
+
if (url.pathname !== OAUTH_REDIRECT_PATH) {
|
|
159
|
+
res.writeHead(404);
|
|
160
|
+
res.end('Not found');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const code = url.searchParams.get('code');
|
|
164
|
+
const state = url.searchParams.get('state');
|
|
165
|
+
const error = url.searchParams.get('error');
|
|
166
|
+
const errorDescription = url.searchParams.get('error_description');
|
|
167
|
+
if (error) {
|
|
168
|
+
const msg = errorDescription || error;
|
|
169
|
+
pendingOAuth?.reject(new Error(msg));
|
|
170
|
+
pendingOAuth = undefined;
|
|
171
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
172
|
+
res.end(htmlError(msg));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (!code) {
|
|
176
|
+
// Missing code but no explicit error. Don't destroy pendingOAuth;
|
|
177
|
+
// could be a stray browser request, not the real callback.
|
|
178
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
179
|
+
res.end(htmlError('Missing authorization code'));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (!pendingOAuth || state !== pendingOAuth.state) {
|
|
183
|
+
// Bad state: respond 400 but keep pendingOAuth intact so the real
|
|
184
|
+
// callback can still succeed. Clearing it here would let a random
|
|
185
|
+
// local request to :56121/callback?code=x&state=bad kill the
|
|
186
|
+
// legitimate login attempt (local DoS).
|
|
187
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
188
|
+
res.end(htmlError('Invalid state — potential CSRF attack'));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const current = pendingOAuth;
|
|
192
|
+
pendingOAuth = undefined;
|
|
193
|
+
exchangeCodeForTokens(code, current.verifier)
|
|
194
|
+
.then((tokens) => current.resolve(tokens))
|
|
195
|
+
.catch((err) => current.reject(err));
|
|
196
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
197
|
+
res.end(HTML_SUCCESS);
|
|
198
|
+
});
|
|
199
|
+
server.once('error', reject);
|
|
200
|
+
server.listen(OAUTH_PORT, OAUTH_HOST, () => {
|
|
201
|
+
server.removeListener('error', reject);
|
|
202
|
+
// Swallow subsequent errors so they don't crash the process
|
|
203
|
+
server.on('error', () => { });
|
|
204
|
+
resolve(server);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
function waitForOAuthCallback(verifier, state) {
|
|
209
|
+
if (pendingOAuth) {
|
|
210
|
+
pendingOAuth.reject(new Error('Superseded by a newer xAI authorize request'));
|
|
211
|
+
pendingOAuth = undefined;
|
|
212
|
+
}
|
|
213
|
+
return new Promise((resolve, reject) => {
|
|
214
|
+
const timeout = setTimeout(() => {
|
|
215
|
+
if (pendingOAuth) {
|
|
216
|
+
pendingOAuth = undefined;
|
|
217
|
+
reject(new Error('OAuth callback timeout — authorization took too long'));
|
|
218
|
+
}
|
|
219
|
+
}, 5 * 60 * 1000);
|
|
220
|
+
pendingOAuth = {
|
|
221
|
+
verifier,
|
|
222
|
+
state,
|
|
223
|
+
resolve: (tokens) => {
|
|
224
|
+
clearTimeout(timeout);
|
|
225
|
+
resolve(tokens);
|
|
226
|
+
},
|
|
227
|
+
reject: (error) => {
|
|
228
|
+
clearTimeout(timeout);
|
|
229
|
+
reject(error);
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
// ─── public login function ───────────────────────────────────────────────────
|
|
235
|
+
/**
|
|
236
|
+
* Run the xAI OAuth browser flow. Opens the system browser for authorization,
|
|
237
|
+
* waits for the callback, and returns an XaiAuth object with tokens.
|
|
238
|
+
*/
|
|
239
|
+
export async function xaiOAuthLogin() {
|
|
240
|
+
const s = spinner();
|
|
241
|
+
s.start('Starting xAI OAuth server...');
|
|
242
|
+
let server;
|
|
243
|
+
try {
|
|
244
|
+
server = await startOAuthServer();
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
s.stop('Failed to start OAuth server');
|
|
248
|
+
throw new Error(`Could not bind to ${OAUTH_HOST}:${OAUTH_PORT}. Is Grok CLI or another egaki instance running? (${err})`);
|
|
249
|
+
}
|
|
250
|
+
const pkce = await generatePKCE();
|
|
251
|
+
const state = generateState();
|
|
252
|
+
const nonce = generateState();
|
|
253
|
+
// `plan=generic` opts into xAI's generic OAuth plan tier.
|
|
254
|
+
// `referrer=egaki` lets xAI attribute egaki-originated logins.
|
|
255
|
+
const params = new URLSearchParams({
|
|
256
|
+
response_type: 'code',
|
|
257
|
+
client_id: CLIENT_ID,
|
|
258
|
+
redirect_uri: REDIRECT_URI,
|
|
259
|
+
scope: SCOPE,
|
|
260
|
+
code_challenge: pkce.challenge,
|
|
261
|
+
code_challenge_method: 'S256',
|
|
262
|
+
state,
|
|
263
|
+
nonce,
|
|
264
|
+
plan: 'generic',
|
|
265
|
+
referrer: 'egaki',
|
|
266
|
+
});
|
|
267
|
+
const authUrl = `${AUTHORIZE_URL}?${params.toString()}`;
|
|
268
|
+
s.stop('OAuth server ready');
|
|
269
|
+
log.info('Opening browser for xAI authorization...');
|
|
270
|
+
note(authUrl, 'If the browser does not open, visit this URL manually');
|
|
271
|
+
// Register the callback listener BEFORE opening the browser. If the user
|
|
272
|
+
// already has an active xAI session, the browser can redirect back to
|
|
273
|
+
// the loopback server almost instantly. Registering after openUrlInBrowser
|
|
274
|
+
// creates a race where the callback arrives before pendingOAuth exists.
|
|
275
|
+
const callbackPromise = waitForOAuthCallback(pkce.verifier, state);
|
|
276
|
+
await openUrlInBrowser(authUrl);
|
|
277
|
+
s.start('Waiting for authorization...');
|
|
278
|
+
try {
|
|
279
|
+
const tokens = await callbackPromise;
|
|
280
|
+
s.stop('Authorization successful');
|
|
281
|
+
const email = extractEmailFromToken(tokens.id_token);
|
|
282
|
+
if (email) {
|
|
283
|
+
log.info(`Signed in as ${pc.bold(email)}`);
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
email,
|
|
287
|
+
access: tokens.access_token,
|
|
288
|
+
refresh: tokens.refresh_token,
|
|
289
|
+
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
s.stop('Authorization failed');
|
|
294
|
+
throw err;
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
server.close();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// ─── token refresh ───────────────────────────────────────────────────────────
|
|
301
|
+
/**
|
|
302
|
+
* Returns a valid XaiAuth, refreshing the access token if it's expired or
|
|
303
|
+
* about to expire. Calls `save` to persist the refreshed tokens.
|
|
304
|
+
*/
|
|
305
|
+
export async function getValidXaiAuth(auth, save) {
|
|
306
|
+
const expiresSoon = !auth.expires ||
|
|
307
|
+
auth.expires - Date.now() <= ACCESS_TOKEN_REFRESH_SKEW_MS ||
|
|
308
|
+
accessTokenIsExpiring(auth.access);
|
|
309
|
+
if (!expiresSoon)
|
|
310
|
+
return auth;
|
|
311
|
+
try {
|
|
312
|
+
const tokens = await refreshXaiToken(auth.refresh);
|
|
313
|
+
const refreshed = {
|
|
314
|
+
email: auth.email,
|
|
315
|
+
access: tokens.access_token,
|
|
316
|
+
refresh: tokens.refresh_token || auth.refresh,
|
|
317
|
+
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
|
318
|
+
};
|
|
319
|
+
save(refreshed);
|
|
320
|
+
return refreshed;
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
return new Error(`Failed to refresh xAI token. Please run \`egaki login\` and select xAI again. (${err})`);
|
|
324
|
+
}
|
|
325
|
+
}
|