egaki 0.0.1 → 0.1.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 +54 -0
- package/README.md +239 -0
- package/dist/antigravity-auth.d.ts +15 -0
- package/dist/antigravity-auth.d.ts.map +1 -0
- package/dist/antigravity-auth.js +544 -0
- package/dist/antigravity-image.d.ts +11 -0
- package/dist/antigravity-image.d.ts.map +1 -0
- package/dist/antigravity-image.js +285 -0
- package/dist/chatgpt-auth.js +294 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +666 -0
- package/dist/credentials.d.ts +18 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +107 -0
- package/dist/gateway-model-candidates.generated.d.ts +11 -0
- package/dist/gateway-model-candidates.generated.d.ts.map +1 -0
- package/dist/gateway-model-candidates.generated.js +64 -0
- package/dist/login.d.ts +9 -0
- package/dist/login.d.ts.map +1 -0
- package/dist/login.js +112 -0
- package/dist/model-catalog.d.ts +42 -0
- package/dist/model-catalog.d.ts.map +1 -0
- package/dist/model-catalog.js +898 -0
- package/dist/models.d.ts +15 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +196 -0
- package/dist/open-browser.d.ts +2 -0
- package/dist/open-browser.d.ts.map +1 -0
- package/dist/open-browser.js +37 -0
- package/dist/subscription.d.ts +5 -0
- package/dist/subscription.d.ts.map +1 -0
- package/dist/subscription.js +216 -0
- package/dist/video-model-catalog.d.ts +52 -0
- package/dist/video-model-catalog.d.ts.map +1 -0
- package/dist/video-model-catalog.js +245 -0
- package/package.json +68 -5
- package/src/cli.ts +998 -0
- package/src/credentials.ts +126 -0
- package/src/login.ts +163 -0
- package/src/model-catalog.ts +968 -0
- package/src/models.ts +258 -0
- package/src/open-browser.ts +48 -0
- package/src/subscription.ts +298 -0
- package/src/video-model-catalog.ts +302 -0
- package/index.js +0 -1
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// Antigravity image generation bridge for egaki.
|
|
2
|
+
// Handles direct Antigravity OAuth requests without provider fallback.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import pc from 'picocolors';
|
|
8
|
+
import { getAntigravityAuth, saveAntigravityAuth } from './credentials.js';
|
|
9
|
+
import { getValidAntigravityAccessToken } from './antigravity-auth.js';
|
|
10
|
+
export async function generateWithAntigravityModel({ prompt, model, outputPath, inputImages, aspectRatio, count, json, stdout, }) {
|
|
11
|
+
const auth = getAntigravityAuth();
|
|
12
|
+
if (!auth) {
|
|
13
|
+
console.error(pc.red('Missing Antigravity OAuth login. Run: egaki login --provider antigravity'));
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
const token = await getValidAntigravityAccessToken(auth, (updated) => {
|
|
17
|
+
saveAntigravityAuth(updated);
|
|
18
|
+
});
|
|
19
|
+
if (token instanceof Error) {
|
|
20
|
+
console.error(pc.red(token.message));
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
if (!stdout) {
|
|
24
|
+
console.error(pc.cyan('Generating...'));
|
|
25
|
+
}
|
|
26
|
+
const actualModel = resolveAntigravityModel(model);
|
|
27
|
+
const projectCandidates = [
|
|
28
|
+
process.env['ANTIGRAVITY_PROJECT_ID'],
|
|
29
|
+
token.projectId,
|
|
30
|
+
...getGcloudProjectIds(),
|
|
31
|
+
].filter((value, index, array) => {
|
|
32
|
+
return Boolean(value) && array.indexOf(value) === index;
|
|
33
|
+
});
|
|
34
|
+
const userParts = [{ text: prompt }];
|
|
35
|
+
for (const image of inputImages) {
|
|
36
|
+
userParts.push({
|
|
37
|
+
inlineData: {
|
|
38
|
+
mimeType: detectImageMimeType(image),
|
|
39
|
+
data: Buffer.from(image).toString('base64'),
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const baseRequest = {
|
|
44
|
+
sessionId: `egaki-${crypto.randomUUID()}`,
|
|
45
|
+
systemInstruction: {
|
|
46
|
+
role: 'user',
|
|
47
|
+
parts: [{ text: 'You are an AI image generator. Generate images based on user descriptions.' }],
|
|
48
|
+
},
|
|
49
|
+
contents: [
|
|
50
|
+
{
|
|
51
|
+
role: 'user',
|
|
52
|
+
parts: userParts,
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
generationConfig: {
|
|
56
|
+
candidateCount: count,
|
|
57
|
+
imageConfig: {
|
|
58
|
+
aspectRatio: aspectRatio || '1:1',
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
safetySettings: [
|
|
62
|
+
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_ONLY_HIGH' },
|
|
63
|
+
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_ONLY_HIGH' },
|
|
64
|
+
{ category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'BLOCK_ONLY_HIGH' },
|
|
65
|
+
{ category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_ONLY_HIGH' },
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
const antigravityAttempts = projectCandidates.map((projectId) => {
|
|
69
|
+
return {
|
|
70
|
+
mode: `antigravity:${projectId}`,
|
|
71
|
+
endpoints: [
|
|
72
|
+
'https://daily-cloudcode-pa.sandbox.googleapis.com',
|
|
73
|
+
'https://autopush-cloudcode-pa.sandbox.googleapis.com',
|
|
74
|
+
'https://cloudcode-pa.googleapis.com',
|
|
75
|
+
],
|
|
76
|
+
payload: {
|
|
77
|
+
project: projectId,
|
|
78
|
+
model: actualModel,
|
|
79
|
+
requestType: 'agent',
|
|
80
|
+
userAgent: 'antigravity',
|
|
81
|
+
requestId: `agent-${crypto.randomUUID()}`,
|
|
82
|
+
request: baseRequest,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
const attemptConfigs = antigravityAttempts;
|
|
87
|
+
let lastError = 'Unknown Antigravity API error';
|
|
88
|
+
let responseBody;
|
|
89
|
+
let successfulProjectId;
|
|
90
|
+
for (const attempt of attemptConfigs) {
|
|
91
|
+
for (const endpoint of attempt.endpoints) {
|
|
92
|
+
const response = await fetch(`${endpoint}/v1internal:generateContent`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: {
|
|
95
|
+
Authorization: `Bearer ${token.accessToken}`,
|
|
96
|
+
'Content-Type': 'application/json',
|
|
97
|
+
'User-Agent': 'google-api-nodejs-client/9.15.1',
|
|
98
|
+
'X-Goog-Api-Client': 'gl-node/22.17.0',
|
|
99
|
+
'Client-Metadata': 'ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI',
|
|
100
|
+
},
|
|
101
|
+
body: JSON.stringify(attempt.payload),
|
|
102
|
+
});
|
|
103
|
+
const text = await response.text();
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
lastError = formatAntigravityError({
|
|
106
|
+
mode: attempt.mode,
|
|
107
|
+
status: response.status,
|
|
108
|
+
bodyText: text,
|
|
109
|
+
});
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
responseBody = JSON.parse(text);
|
|
113
|
+
if (typeof attempt.payload.project === 'string' && attempt.payload.project.length > 0) {
|
|
114
|
+
successfulProjectId = attempt.payload.project;
|
|
115
|
+
}
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
if (responseBody) {
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!responseBody) {
|
|
123
|
+
console.error(pc.red(lastError));
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
if (successfulProjectId && successfulProjectId !== auth.projectId) {
|
|
127
|
+
saveAntigravityAuth({ ...auth, projectId: successfulProjectId });
|
|
128
|
+
}
|
|
129
|
+
const generatedImages = extractAntigravityImages(responseBody);
|
|
130
|
+
if (generatedImages.length === 0) {
|
|
131
|
+
console.error(pc.red('No images generated by Antigravity response'));
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
if (stdout) {
|
|
135
|
+
process.stdout.write(generatedImages[0].bytes);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const savedFiles = [];
|
|
139
|
+
for (let i = 0; i < generatedImages.length; i++) {
|
|
140
|
+
const generated = generatedImages[i];
|
|
141
|
+
const filePath = generatedImages.length === 1
|
|
142
|
+
? ensureExtension(outputPath, extensionFromMediaType(generated.mediaType))
|
|
143
|
+
: insertIndex(outputPath, i, extensionFromMediaType(generated.mediaType));
|
|
144
|
+
fs.writeFileSync(filePath, generated.bytes);
|
|
145
|
+
console.error(pc.green(`Saved: ${filePath}`));
|
|
146
|
+
savedFiles.push(filePath);
|
|
147
|
+
}
|
|
148
|
+
if (json) {
|
|
149
|
+
console.log(JSON.stringify({
|
|
150
|
+
model,
|
|
151
|
+
files: savedFiles,
|
|
152
|
+
count: savedFiles.length,
|
|
153
|
+
provider: 'antigravity',
|
|
154
|
+
projectId: token.projectId,
|
|
155
|
+
}, null, 2));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function resolveAntigravityModel(modelId) {
|
|
159
|
+
if (modelId.startsWith('antigravity-')) {
|
|
160
|
+
return modelId.slice('antigravity-'.length);
|
|
161
|
+
}
|
|
162
|
+
return modelId;
|
|
163
|
+
}
|
|
164
|
+
function getGcloudProjectIds() {
|
|
165
|
+
try {
|
|
166
|
+
const output = execFileSync('gcloud', ['projects', 'list', '--format=value(projectId)', '--limit=50'], {
|
|
167
|
+
encoding: 'utf8',
|
|
168
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
169
|
+
});
|
|
170
|
+
return output
|
|
171
|
+
.split('\n')
|
|
172
|
+
.map((line) => line.trim())
|
|
173
|
+
.filter((line) => line.length > 0);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function detectImageMimeType(bytes) {
|
|
180
|
+
if (bytes.length > 3 &&
|
|
181
|
+
bytes[0] === 0x89 &&
|
|
182
|
+
bytes[1] === 0x50 &&
|
|
183
|
+
bytes[2] === 0x4e &&
|
|
184
|
+
bytes[3] === 0x47) {
|
|
185
|
+
return 'image/png';
|
|
186
|
+
}
|
|
187
|
+
if (bytes.length > 2 && bytes[0] === 0xff && bytes[1] === 0xd8) {
|
|
188
|
+
return 'image/jpeg';
|
|
189
|
+
}
|
|
190
|
+
if (bytes.length > 12 &&
|
|
191
|
+
bytes[0] === 0x52 &&
|
|
192
|
+
bytes[1] === 0x49 &&
|
|
193
|
+
bytes[2] === 0x46 &&
|
|
194
|
+
bytes[3] === 0x46 &&
|
|
195
|
+
bytes[8] === 0x57 &&
|
|
196
|
+
bytes[9] === 0x45 &&
|
|
197
|
+
bytes[10] === 0x42 &&
|
|
198
|
+
bytes[11] === 0x50) {
|
|
199
|
+
return 'image/webp';
|
|
200
|
+
}
|
|
201
|
+
return 'image/png';
|
|
202
|
+
}
|
|
203
|
+
function extractAntigravityImages(payload) {
|
|
204
|
+
const root = payload && typeof payload === 'object' && 'response' in payload
|
|
205
|
+
? payload.response
|
|
206
|
+
: payload;
|
|
207
|
+
if (!root || typeof root !== 'object') {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
const candidates = root.candidates;
|
|
211
|
+
if (!candidates || candidates.length === 0) {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
const images = [];
|
|
215
|
+
for (const candidate of candidates) {
|
|
216
|
+
for (const part of candidate.content?.parts || []) {
|
|
217
|
+
const data = part.inlineData?.data;
|
|
218
|
+
if (!data)
|
|
219
|
+
continue;
|
|
220
|
+
const mediaType = part.inlineData?.mimeType || 'image/png';
|
|
221
|
+
images.push({
|
|
222
|
+
bytes: new Uint8Array(Buffer.from(data, 'base64')),
|
|
223
|
+
mediaType,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return images;
|
|
228
|
+
}
|
|
229
|
+
function formatAntigravityError({ mode, status, bodyText, }) {
|
|
230
|
+
const payload = parseAntigravityErrorPayload(bodyText);
|
|
231
|
+
const message = payload?.error?.message || bodyText;
|
|
232
|
+
const metadata = payload?.error?.details?.find((detail) => detail.metadata)?.metadata;
|
|
233
|
+
const links = payload?.error?.details
|
|
234
|
+
?.flatMap((detail) => detail.links || [])
|
|
235
|
+
.filter((link) => Boolean(link?.url));
|
|
236
|
+
const verificationUrl = metadata?.['validation_url']
|
|
237
|
+
|| links?.find((link) => /verify/i.test(link.description || ''))?.url;
|
|
238
|
+
if (verificationUrl) {
|
|
239
|
+
return [
|
|
240
|
+
`${mode} API error ${status}: account verification required`,
|
|
241
|
+
`Verify your Google account here: ${verificationUrl}`,
|
|
242
|
+
`Backend message: ${message}`,
|
|
243
|
+
].join('\n');
|
|
244
|
+
}
|
|
245
|
+
const activationUrl = metadata?.['activationUrl']
|
|
246
|
+
|| metadata?.['activation_url']
|
|
247
|
+
|| links?.find((link) => /activation|console|api/i.test(link.description || ''))?.url;
|
|
248
|
+
if (activationUrl) {
|
|
249
|
+
return [
|
|
250
|
+
`${mode} API error ${status}: API access not configured`,
|
|
251
|
+
`Enable required API here: ${activationUrl}`,
|
|
252
|
+
`Backend message: ${message}`,
|
|
253
|
+
].join('\n');
|
|
254
|
+
}
|
|
255
|
+
return `${mode} API error ${status}: ${message}`;
|
|
256
|
+
}
|
|
257
|
+
function parseAntigravityErrorPayload(bodyText) {
|
|
258
|
+
try {
|
|
259
|
+
return JSON.parse(bodyText);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function extensionFromMediaType(mediaType) {
|
|
266
|
+
const map = {
|
|
267
|
+
'image/png': '.png',
|
|
268
|
+
'image/jpeg': '.jpg',
|
|
269
|
+
'image/webp': '.webp',
|
|
270
|
+
'image/gif': '.gif',
|
|
271
|
+
};
|
|
272
|
+
return map[mediaType] || '.png';
|
|
273
|
+
}
|
|
274
|
+
function ensureExtension(filePath, ext) {
|
|
275
|
+
const parsed = path.parse(filePath);
|
|
276
|
+
if (parsed.ext) {
|
|
277
|
+
return filePath;
|
|
278
|
+
}
|
|
279
|
+
return filePath + ext;
|
|
280
|
+
}
|
|
281
|
+
function insertIndex(filePath, index, ext) {
|
|
282
|
+
const parsed = path.parse(filePath);
|
|
283
|
+
const finalExt = parsed.ext || ext;
|
|
284
|
+
return path.join(parsed.dir, `${parsed.name}-${index}${finalExt}`);
|
|
285
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// ChatGPT OAuth authentication for egaki.
|
|
2
|
+
// Implements the same OAuth 2.0 PKCE browser flow used by OpenAI Codex CLI.
|
|
3
|
+
// Tokens are stored inside credentials.json under the 'chatgpt' key as a
|
|
4
|
+
// structured object (not a plain string like other providers).
|
|
5
|
+
//
|
|
6
|
+
// The access token is a standard OpenAI bearer token that works with the
|
|
7
|
+
// Image API, so we can inject it as OPENAI_API_KEY for the SDK to pick up.
|
|
8
|
+
import { createServer } from 'node:http';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { spinner, log, note } from '@clack/prompts';
|
|
11
|
+
import pc from 'picocolors';
|
|
12
|
+
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
|
13
|
+
const ISSUER = 'https://auth.openai.com';
|
|
14
|
+
const OAUTH_PORT = 1455;
|
|
15
|
+
const REDIRECT_URI = `http://localhost:${OAUTH_PORT}/auth/callback`;
|
|
16
|
+
// ─── PKCE helpers ────────────────────────────────────────────────────────────
|
|
17
|
+
function randomString(length) {
|
|
18
|
+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
|
19
|
+
const bytes = crypto.getRandomValues(new Uint8Array(length));
|
|
20
|
+
return Array.from(bytes)
|
|
21
|
+
.map((b) => chars[b % chars.length])
|
|
22
|
+
.join('');
|
|
23
|
+
}
|
|
24
|
+
function base64UrlEncode(buffer) {
|
|
25
|
+
const bytes = new Uint8Array(buffer);
|
|
26
|
+
const binary = String.fromCharCode(...bytes);
|
|
27
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
28
|
+
}
|
|
29
|
+
async function generatePKCE() {
|
|
30
|
+
const verifier = randomString(43);
|
|
31
|
+
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
|
|
32
|
+
return { verifier, challenge: base64UrlEncode(hash) };
|
|
33
|
+
}
|
|
34
|
+
function generateState() {
|
|
35
|
+
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer);
|
|
36
|
+
}
|
|
37
|
+
// ─── JWT parsing ─────────────────────────────────────────────────────────────
|
|
38
|
+
function parseJwtClaims(token) {
|
|
39
|
+
const parts = token.split('.');
|
|
40
|
+
if (parts.length !== 3)
|
|
41
|
+
return undefined;
|
|
42
|
+
const payload = parts[1];
|
|
43
|
+
if (!payload)
|
|
44
|
+
return undefined;
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(Buffer.from(payload, 'base64url').toString());
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function extractEmail(tokens) {
|
|
53
|
+
const idClaims = tokens.id_token ? parseJwtClaims(tokens.id_token) : undefined;
|
|
54
|
+
const accessClaims = parseJwtClaims(tokens.access_token);
|
|
55
|
+
return ((typeof idClaims?.email === 'string' ? idClaims.email : undefined) ??
|
|
56
|
+
(typeof accessClaims?.email === 'string' ? accessClaims.email : undefined));
|
|
57
|
+
}
|
|
58
|
+
function extractAccountId(tokens) {
|
|
59
|
+
const idClaims = tokens.id_token ? parseJwtClaims(tokens.id_token) : undefined;
|
|
60
|
+
const accessClaims = parseJwtClaims(tokens.access_token);
|
|
61
|
+
const idAuth = idClaims?.['https://api.openai.com/auth'];
|
|
62
|
+
const accessAuth = accessClaims?.['https://api.openai.com/auth'];
|
|
63
|
+
return ((typeof idClaims?.chatgpt_account_id === 'string' ? idClaims.chatgpt_account_id : undefined) ??
|
|
64
|
+
(typeof idAuth?.chatgpt_account_id === 'string' ? idAuth.chatgpt_account_id : undefined) ??
|
|
65
|
+
(typeof accessClaims?.chatgpt_account_id === 'string'
|
|
66
|
+
? accessClaims.chatgpt_account_id
|
|
67
|
+
: undefined) ??
|
|
68
|
+
(typeof accessAuth?.chatgpt_account_id === 'string'
|
|
69
|
+
? accessAuth.chatgpt_account_id
|
|
70
|
+
: undefined));
|
|
71
|
+
}
|
|
72
|
+
/** Extract the ChatGPT plan type (e.g. "plus", "pro") from the access token. */
|
|
73
|
+
export function extractPlanType(auth) {
|
|
74
|
+
const claims = parseJwtClaims(auth.access);
|
|
75
|
+
const authClaim = claims?.['https://api.openai.com/auth'];
|
|
76
|
+
return authClaim?.chatgpt_plan_type;
|
|
77
|
+
}
|
|
78
|
+
// ─── OAuth URL + token exchange ──────────────────────────────────────────────
|
|
79
|
+
function buildAuthorizeUrl(pkce, state) {
|
|
80
|
+
const params = new URLSearchParams({
|
|
81
|
+
response_type: 'code',
|
|
82
|
+
client_id: CLIENT_ID,
|
|
83
|
+
redirect_uri: REDIRECT_URI,
|
|
84
|
+
scope: 'openid profile email offline_access',
|
|
85
|
+
code_challenge: pkce.challenge,
|
|
86
|
+
code_challenge_method: 'S256',
|
|
87
|
+
id_token_add_organizations: 'true',
|
|
88
|
+
codex_cli_simplified_flow: 'true',
|
|
89
|
+
state,
|
|
90
|
+
originator: 'egaki',
|
|
91
|
+
});
|
|
92
|
+
return `${ISSUER}/oauth/authorize?${params.toString()}`;
|
|
93
|
+
}
|
|
94
|
+
async function exchangeCodeForTokens(code, verifier) {
|
|
95
|
+
const response = await fetch(`${ISSUER}/oauth/token`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
98
|
+
body: new URLSearchParams({
|
|
99
|
+
grant_type: 'authorization_code',
|
|
100
|
+
code,
|
|
101
|
+
redirect_uri: REDIRECT_URI,
|
|
102
|
+
client_id: CLIENT_ID,
|
|
103
|
+
code_verifier: verifier,
|
|
104
|
+
}).toString(),
|
|
105
|
+
});
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new Error(`Token exchange failed: ${response.status}`);
|
|
108
|
+
}
|
|
109
|
+
return (await response.json());
|
|
110
|
+
}
|
|
111
|
+
// ─── local callback server ───────────────────────────────────────────────────
|
|
112
|
+
const HTML_SUCCESS = `<!doctype html>
|
|
113
|
+
<html><body style="font-family:system-ui;text-align:center;padding:4em">
|
|
114
|
+
<h1>Authorization Successful</h1>
|
|
115
|
+
<p>You can close this window and return to the terminal.</p>
|
|
116
|
+
</body></html>`;
|
|
117
|
+
const htmlError = (error) => `<!doctype html><html><body style="font-family:system-ui;text-align:center;padding:4em">
|
|
118
|
+
<h1>Authorization Failed</h1><pre>${error}</pre></body></html>`;
|
|
119
|
+
let oauthServer;
|
|
120
|
+
let pendingOAuth;
|
|
121
|
+
function handleOAuthRequest(req, res) {
|
|
122
|
+
const url = new URL(req.url ?? '/', `http://localhost:${OAUTH_PORT}`);
|
|
123
|
+
if (url.pathname !== '/auth/callback') {
|
|
124
|
+
res.statusCode = 404;
|
|
125
|
+
res.end('Not found');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const code = url.searchParams.get('code');
|
|
129
|
+
const state = url.searchParams.get('state');
|
|
130
|
+
const error = url.searchParams.get('error');
|
|
131
|
+
const errorDescription = url.searchParams.get('error_description');
|
|
132
|
+
if (error) {
|
|
133
|
+
const message = errorDescription || error;
|
|
134
|
+
pendingOAuth?.reject(new Error(message));
|
|
135
|
+
pendingOAuth = undefined;
|
|
136
|
+
res.statusCode = 400;
|
|
137
|
+
res.setHeader('Content-Type', 'text/html');
|
|
138
|
+
res.end(htmlError(message));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (!code) {
|
|
142
|
+
const message = 'Missing authorization code';
|
|
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));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (!pendingOAuth || state !== pendingOAuth.state) {
|
|
151
|
+
const message = 'Invalid state — potential CSRF attack';
|
|
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));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const current = pendingOAuth;
|
|
160
|
+
pendingOAuth = undefined;
|
|
161
|
+
exchangeCodeForTokens(code, current.verifier)
|
|
162
|
+
.then((tokens) => current.resolve(tokens))
|
|
163
|
+
.catch((err) => current.reject(err instanceof Error ? err : new Error(String(err))));
|
|
164
|
+
res.statusCode = 200;
|
|
165
|
+
res.setHeader('Content-Type', 'text/html');
|
|
166
|
+
res.end(HTML_SUCCESS);
|
|
167
|
+
}
|
|
168
|
+
async function startOAuthServer() {
|
|
169
|
+
if (oauthServer)
|
|
170
|
+
return;
|
|
171
|
+
oauthServer = createServer(handleOAuthRequest);
|
|
172
|
+
await new Promise((resolve, reject) => {
|
|
173
|
+
oauthServer?.once('error', reject);
|
|
174
|
+
oauthServer?.listen(OAUTH_PORT, '127.0.0.1', () => resolve());
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
async function stopOAuthServer() {
|
|
178
|
+
if (!oauthServer)
|
|
179
|
+
return;
|
|
180
|
+
await new Promise((resolve) => oauthServer?.close(() => resolve()));
|
|
181
|
+
oauthServer = undefined;
|
|
182
|
+
}
|
|
183
|
+
function waitForOAuthCallback(verifier, state) {
|
|
184
|
+
return new Promise((resolve, reject) => {
|
|
185
|
+
const timeout = setTimeout(() => {
|
|
186
|
+
if (pendingOAuth) {
|
|
187
|
+
pendingOAuth = undefined;
|
|
188
|
+
reject(new Error('OAuth callback timeout — authorization took too long'));
|
|
189
|
+
}
|
|
190
|
+
}, 5 * 60 * 1000);
|
|
191
|
+
pendingOAuth = {
|
|
192
|
+
verifier,
|
|
193
|
+
state,
|
|
194
|
+
resolve: (tokens) => {
|
|
195
|
+
clearTimeout(timeout);
|
|
196
|
+
resolve(tokens);
|
|
197
|
+
},
|
|
198
|
+
reject: (error) => {
|
|
199
|
+
clearTimeout(timeout);
|
|
200
|
+
reject(error);
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
function openBrowser(url) {
|
|
206
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
207
|
+
const child = spawn(cmd, [url], { detached: true, stdio: 'ignore' });
|
|
208
|
+
child.unref();
|
|
209
|
+
}
|
|
210
|
+
// ─── public API ──────────────────────────────────────────────────────────────
|
|
211
|
+
/**
|
|
212
|
+
* Run the full ChatGPT OAuth browser flow with clack UI.
|
|
213
|
+
* Returns the ChatGptAuth object to be stored in credentials.json.
|
|
214
|
+
*/
|
|
215
|
+
export async function chatGptOAuthLogin() {
|
|
216
|
+
await startOAuthServer();
|
|
217
|
+
const pkce = await generatePKCE();
|
|
218
|
+
const state = generateState();
|
|
219
|
+
const authUrl = buildAuthorizeUrl(pkce, state);
|
|
220
|
+
const callbackPromise = waitForOAuthCallback(pkce.verifier, state);
|
|
221
|
+
note(`Open this URL in your browser to sign in with your ChatGPT account:\n\n` +
|
|
222
|
+
` ${pc.cyan(pc.underline(authUrl))}\n\n` +
|
|
223
|
+
`${pc.dim(`Listening for callback on ${REDIRECT_URI}`)}`, 'ChatGPT OAuth');
|
|
224
|
+
openBrowser(authUrl);
|
|
225
|
+
const s = spinner();
|
|
226
|
+
s.start('Waiting for authorization in browser...');
|
|
227
|
+
let tokens;
|
|
228
|
+
try {
|
|
229
|
+
tokens = await callbackPromise;
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
await stopOAuthServer();
|
|
233
|
+
}
|
|
234
|
+
s.stop('Authorization received');
|
|
235
|
+
const now = Date.now();
|
|
236
|
+
const auth = {
|
|
237
|
+
email: extractEmail(tokens),
|
|
238
|
+
accountId: extractAccountId(tokens),
|
|
239
|
+
refresh: tokens.refresh_token,
|
|
240
|
+
access: tokens.access_token,
|
|
241
|
+
expires: now + (tokens.expires_in ?? 3600) * 1000,
|
|
242
|
+
};
|
|
243
|
+
const plan = extractPlanType(auth);
|
|
244
|
+
const label = auth.email ?? auth.accountId ?? 'unknown';
|
|
245
|
+
log.success(`Signed in as ${pc.bold(label)}` + (plan ? ` (${pc.cyan(plan)} plan)` : ''));
|
|
246
|
+
return auth;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Refresh an expired ChatGPT access token using the refresh token.
|
|
250
|
+
* Returns the updated auth object, or an Error if refresh fails.
|
|
251
|
+
*/
|
|
252
|
+
export async function refreshChatGptToken(auth) {
|
|
253
|
+
try {
|
|
254
|
+
const response = await fetch(`${ISSUER}/oauth/token`, {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
257
|
+
body: new URLSearchParams({
|
|
258
|
+
grant_type: 'refresh_token',
|
|
259
|
+
refresh_token: auth.refresh,
|
|
260
|
+
client_id: CLIENT_ID,
|
|
261
|
+
}).toString(),
|
|
262
|
+
});
|
|
263
|
+
if (!response.ok) {
|
|
264
|
+
return new Error(`Token refresh failed: ${response.status}`);
|
|
265
|
+
}
|
|
266
|
+
const json = (await response.json());
|
|
267
|
+
return {
|
|
268
|
+
...auth,
|
|
269
|
+
access: json.access_token,
|
|
270
|
+
refresh: json.refresh_token ?? auth.refresh,
|
|
271
|
+
expires: Date.now() + (json.expires_in ?? 3600) * 1000,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Get a valid access token from a ChatGptAuth object.
|
|
280
|
+
* Auto-refreshes if expired. Returns the token string or an Error.
|
|
281
|
+
* If the auth was refreshed, the `onRefresh` callback is called with
|
|
282
|
+
* the updated auth so the caller can persist it.
|
|
283
|
+
*/
|
|
284
|
+
export async function getValidAccessToken(auth, onRefresh) {
|
|
285
|
+
// Token still valid (with 60s buffer)
|
|
286
|
+
if (auth.expires > Date.now() + 60_000) {
|
|
287
|
+
return auth.access;
|
|
288
|
+
}
|
|
289
|
+
const refreshed = await refreshChatGptToken(auth);
|
|
290
|
+
if (refreshed instanceof Error)
|
|
291
|
+
return refreshed;
|
|
292
|
+
onRefresh?.(refreshed);
|
|
293
|
+
return refreshed.access;
|
|
294
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
|