egaki 0.0.1 → 0.0.2
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 +15 -0
- package/README.md +166 -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 +471 -0
- package/dist/credentials.d.ts +18 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +107 -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 +729 -0
- package/dist/models.d.ts +10 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +147 -0
- package/dist/subscription.d.ts +5 -0
- package/dist/subscription.d.ts.map +1 -0
- package/dist/subscription.js +192 -0
- package/package.json +68 -5
- package/src/cli.ts +679 -0
- package/src/credentials.ts +126 -0
- package/src/login.ts +163 -0
- package/src/model-catalog.ts +793 -0
- package/src/models.ts +197 -0
- package/src/subscription.ts +271 -0
- package/index.js +0 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## 0.0.2
|
|
6
|
+
|
|
7
|
+
- Make subscribe messaging explicit about both auth modes.
|
|
8
|
+
- Clarify BYOK provider keys vs single Egaki subscription key in CLI + docs.
|
|
9
|
+
- Expand README with advanced usage and model-specific command examples.
|
|
10
|
+
|
|
11
|
+
## 0.0.1
|
|
12
|
+
|
|
13
|
+
- Initial public release of the `egaki` CLI.
|
|
14
|
+
- Image generation command with model selection and file/stdout output.
|
|
15
|
+
- Login, subscription, unsubscribe, usage, and models commands.
|
package/README.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# egaki
|
|
2
|
+
|
|
3
|
+
Generate AI images from the terminal with one command.
|
|
4
|
+
|
|
5
|
+
`egaki` is a TypeScript CLI built on the Vercel AI SDK and goke. It supports
|
|
6
|
+
Google Imagen, Gemini image-capable models, and model discovery with pricing.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pnpm add -g egaki
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
egaki login
|
|
18
|
+
egaki image "a watercolor fox reading a map" -o fox.png
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## CLI examples
|
|
22
|
+
|
|
23
|
+
### Generate from text
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
egaki image "cinematic mountain village at sunrise" -o village.png
|
|
27
|
+
egaki image "isometric floating city, detailed, soft colors" -m imagen-4.0-generate-001
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Edit with input image
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
egaki image "add a red scarf and make it winter" --input portrait.jpg -o portrait-winter.png
|
|
34
|
+
egaki image "turn this into a manga panel" --input https://example.com/photo.jpg -o manga.png
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Inpainting with a mask
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
egaki image "replace the sky with dramatic storm clouds" --input landscape.png --mask mask.png -o storm.png
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Generate multiple images
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
egaki image "minimal logo concepts for a cat cafe" -n 4 -o logo.png
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Control composition
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
egaki image "cyberpunk alley at night" --aspect-ratio 16:9
|
|
53
|
+
egaki image "polaroid-style travel photo" --aspect-ratio 4:5
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Pipe output to other tools
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
egaki image "flat icon of a fox" --stdout | magick - -resize 512x512 fox-icon.png
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Discover models and pricing
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
egaki models
|
|
66
|
+
egaki models --provider google
|
|
67
|
+
egaki models --json
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Advanced examples
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Deterministic result (models that support seed)
|
|
74
|
+
egaki image "studio product shot of a ceramic mug" \
|
|
75
|
+
-m imagen-4.0-generate-001 \
|
|
76
|
+
--seed 42 \
|
|
77
|
+
-o mug-seed-42.png
|
|
78
|
+
|
|
79
|
+
# High quality Gemini image generation with 4K output
|
|
80
|
+
egaki image "architectural concept art, brutalist library interior" \
|
|
81
|
+
-m gemini-2.5-flash-image \
|
|
82
|
+
--image-size 4K \
|
|
83
|
+
--aspect-ratio 16:9 \
|
|
84
|
+
-o library-4k.png
|
|
85
|
+
|
|
86
|
+
# Multi-reference edit (pass --input multiple times)
|
|
87
|
+
egaki image "blend style from first image and color palette from second" \
|
|
88
|
+
--input style-reference.jpg \
|
|
89
|
+
--input palette-reference.jpg \
|
|
90
|
+
-m nano-banana-pro-preview \
|
|
91
|
+
-o hybrid-style.png
|
|
92
|
+
|
|
93
|
+
# JSON mode for automation/pipelines
|
|
94
|
+
egaki image "futuristic sneaker concept" \
|
|
95
|
+
-m gpt-image-1.5 \
|
|
96
|
+
--json \
|
|
97
|
+
-o sneaker.png
|
|
98
|
+
|
|
99
|
+
# Batch generation with indexed output names
|
|
100
|
+
egaki image "mascot variations, flat vector look" \
|
|
101
|
+
-m gpt-image-1-mini \
|
|
102
|
+
-n 6 \
|
|
103
|
+
-o mascot.png
|
|
104
|
+
# writes mascot-0.png ... mascot-5.png
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Model input examples
|
|
108
|
+
|
|
109
|
+
Use `egaki models --json` to see the full list. These are practical examples of
|
|
110
|
+
which flags pair well with common models:
|
|
111
|
+
|
|
112
|
+
| Model ID | Best for | Example command |
|
|
113
|
+
| --- | --- | --- |
|
|
114
|
+
| `imagen-4.0-ultra-generate-001` | High-quality prompt-to-image with seed + ratio | `egaki image "luxury perfume ad on marble" -m imagen-4.0-ultra-generate-001 --aspect-ratio 3:4 --seed 7 -o perfume.png` |
|
|
115
|
+
| `gemini-3.1-flash-image-preview` | Fast, cheap text+image edits with wide aspect-ratio support | `egaki image "turn into manga splash page" -m gemini-3.1-flash-image-preview --input portrait.jpg --aspect-ratio 4:1 -o manga-wide.png` |
|
|
116
|
+
| `nano-banana-pro-preview` | Highest-fidelity Google text+image output | `egaki image "fashion editorial, dramatic rim light" -m nano-banana-pro-preview --input model.jpg --image-size 2K -o editorial.png` |
|
|
117
|
+
| `gpt-image-1.5` | OpenAI image generation with strong editing/inpainting support | `egaki image "replace background with neon city" -m gpt-image-1.5 --input product.png --mask bg-mask.png -o product-neon.png` |
|
|
118
|
+
| `black-forest-labs/flux-fill-pro` | Inpainting-focused Flux workflow on Replicate | `egaki image "restore damaged poster corners" -m black-forest-labs/flux-fill-pro --input poster.png --mask corners-mask.png -o restored.png` |
|
|
119
|
+
| `fal-ai/flux/schnell` | Very fast low-cost ideation batches | `egaki image "logo sketch, geometric fox" -m fal-ai/flux/schnell -n 8 -o fox-logo.png` |
|
|
120
|
+
|
|
121
|
+
## Feature support by model family
|
|
122
|
+
|
|
123
|
+
Quick rule of thumb for flags:
|
|
124
|
+
|
|
125
|
+
- **Google Imagen (`imagen-*`)**: supports `--seed`, `--aspect-ratio`, `--input`, `--mask`, `-n`
|
|
126
|
+
- **Google Gemini image models**: supports `--input`, `--aspect-ratio`, `--image-size`; usually no `--seed`
|
|
127
|
+
- **OpenAI image models**: strong editing and inpainting; size controls are model-specific
|
|
128
|
+
- **Flux/Fal/Replicate models**: broad aspect-ratio + seed support; editing/inpainting depends on exact model
|
|
129
|
+
|
|
130
|
+
### Subscription and usage
|
|
131
|
+
|
|
132
|
+
egaki supports **both** authentication modes:
|
|
133
|
+
|
|
134
|
+
- **BYOK (bring your own keys):** add provider keys with `egaki login` per provider.
|
|
135
|
+
- **Egaki subscription:** use one `egaki_...` key to access all supported models without managing keys for each provider.
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
# Subscribe and get a checkout URL
|
|
139
|
+
egaki subscribe --email user@example.com --plan pro
|
|
140
|
+
|
|
141
|
+
# Save your Egaki key after checkout
|
|
142
|
+
egaki login --provider egaki --key egaki_...
|
|
143
|
+
|
|
144
|
+
# BYOK example (direct provider key)
|
|
145
|
+
egaki login --provider google --key AIza...
|
|
146
|
+
|
|
147
|
+
# Check subscription usage / cancel
|
|
148
|
+
egaki usage
|
|
149
|
+
egaki unsubscribe
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Help
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
egaki --help
|
|
156
|
+
egaki image --help
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Auth and billing
|
|
160
|
+
|
|
161
|
+
- `egaki login` stores provider keys in `~/.config/egaki/credentials.json`.
|
|
162
|
+
- `egaki subscribe`, `egaki usage`, and `egaki unsubscribe` manage Egaki plans.
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
MIT
|
|
@@ -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":""}
|