@hung319/opencode-iflow-cli 1.1.1
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/README.md +187 -0
- package/dist/constants.d.ts +19 -0
- package/dist/constants.js +61 -0
- package/dist/iflow/apikey.d.ts +6 -0
- package/dist/iflow/apikey.js +17 -0
- package/dist/iflow/oauth.d.ts +20 -0
- package/dist/iflow/oauth.js +113 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/plugin/accounts.d.ts +24 -0
- package/dist/plugin/accounts.js +147 -0
- package/dist/plugin/auth-page.d.ts +3 -0
- package/dist/plugin/auth-page.js +573 -0
- package/dist/plugin/cli.d.ts +12 -0
- package/dist/plugin/cli.js +88 -0
- package/dist/plugin/config/index.d.ts +2 -0
- package/dist/plugin/config/index.js +2 -0
- package/dist/plugin/config/loader.d.ts +3 -0
- package/dist/plugin/config/loader.js +110 -0
- package/dist/plugin/config/schema.d.ts +35 -0
- package/dist/plugin/config/schema.js +22 -0
- package/dist/plugin/errors.d.ts +14 -0
- package/dist/plugin/errors.js +25 -0
- package/dist/plugin/logger.d.ts +8 -0
- package/dist/plugin/logger.js +63 -0
- package/dist/plugin/server.d.ts +10 -0
- package/dist/plugin/server.js +121 -0
- package/dist/plugin/storage.d.ts +4 -0
- package/dist/plugin/storage.js +91 -0
- package/dist/plugin/token.d.ts +3 -0
- package/dist/plugin/token.js +26 -0
- package/dist/plugin/types.d.ts +55 -0
- package/dist/plugin/types.js +0 -0
- package/dist/plugin.d.ts +34 -0
- package/dist/plugin.js +676 -0
- package/package.json +65 -0
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
import { loadConfig } from './plugin/config';
|
|
2
|
+
import { exec } from 'node:child_process';
|
|
3
|
+
import { AccountManager, generateAccountId } from './plugin/accounts';
|
|
4
|
+
import { accessTokenExpired } from './plugin/token';
|
|
5
|
+
import { refreshAccessToken } from './plugin/token';
|
|
6
|
+
import { authorizeIFlowOAuth } from './iflow/oauth';
|
|
7
|
+
import { validateApiKey } from './iflow/apikey';
|
|
8
|
+
import { startOAuthServer } from './plugin/server';
|
|
9
|
+
import { promptAddAnotherAccount, promptLoginMode, promptApiKey, promptEmail, promptOAuthCallback } from './plugin/cli';
|
|
10
|
+
import { IFLOW_CONSTANTS, applyThinkingConfig } from './constants';
|
|
11
|
+
import * as logger from './plugin/logger';
|
|
12
|
+
const IFLOW_PROVIDER_ID = 'iflow';
|
|
13
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
14
|
+
const isNetworkError = (e) => e instanceof Error && /econnreset|etimedout|enotfound|network|fetch failed/i.test(e.message);
|
|
15
|
+
const openBrowser = (url) => {
|
|
16
|
+
const escapedUrl = url.replace(/"/g, '\\"');
|
|
17
|
+
const platform = process.platform;
|
|
18
|
+
const command = platform === 'win32'
|
|
19
|
+
? `cmd /c start "" "${escapedUrl}"`
|
|
20
|
+
: platform === 'darwin'
|
|
21
|
+
? `open "${escapedUrl}"`
|
|
22
|
+
: `xdg-open "${escapedUrl}"`;
|
|
23
|
+
exec(command, (error) => {
|
|
24
|
+
if (error)
|
|
25
|
+
logger.warn(`Failed to open browser automatically: ${error.message}`, error);
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Detect if running in headless environment (SSH, container, CI, etc.)
|
|
30
|
+
*/
|
|
31
|
+
function isHeadlessEnvironment() {
|
|
32
|
+
return !!(process.env.SSH_CONNECTION ||
|
|
33
|
+
process.env.SSH_CLIENT ||
|
|
34
|
+
process.env.SSH_TTY ||
|
|
35
|
+
process.env.OPENCODE_HEADLESS ||
|
|
36
|
+
process.env.CI ||
|
|
37
|
+
process.env.CONTAINER ||
|
|
38
|
+
(!process.env.DISPLAY && process.platform !== 'darwin' && process.platform !== 'win32'));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Parse OAuth callback input - can be full URL or just the code
|
|
42
|
+
*/
|
|
43
|
+
function parseOAuthCallbackInput(input) {
|
|
44
|
+
const trimmed = input.trim();
|
|
45
|
+
if (!trimmed) {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
// If it's a URL, extract code and state from query params
|
|
49
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
50
|
+
try {
|
|
51
|
+
const url = new URL(trimmed);
|
|
52
|
+
return {
|
|
53
|
+
code: url.searchParams.get('code') || undefined,
|
|
54
|
+
state: url.searchParams.get('state') || undefined,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// If it looks like query params (code=...&state=...)
|
|
62
|
+
const candidate = trimmed.startsWith('?') ? trimmed.slice(1) : trimmed;
|
|
63
|
+
if (candidate.includes('=')) {
|
|
64
|
+
const params = new URLSearchParams(candidate);
|
|
65
|
+
const code = params.get('code') || undefined;
|
|
66
|
+
const state = params.get('state') || undefined;
|
|
67
|
+
if (code || state) {
|
|
68
|
+
return { code, state };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Assume it's just the code
|
|
72
|
+
return { code: trimmed };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Default model configurations for iFlow
|
|
76
|
+
*/
|
|
77
|
+
const DEFAULT_MODELS = {
|
|
78
|
+
'iflow-rome-30ba3b': {
|
|
79
|
+
name: 'iFlow ROME 30B',
|
|
80
|
+
limit: { context: 256000, output: 64000 },
|
|
81
|
+
modalities: { input: ['text'], output: ['text'] }
|
|
82
|
+
},
|
|
83
|
+
'qwen3-coder-plus': {
|
|
84
|
+
name: 'Qwen3 Coder Plus',
|
|
85
|
+
limit: { context: 1000000, output: 64000 },
|
|
86
|
+
modalities: { input: ['text'], output: ['text'] }
|
|
87
|
+
},
|
|
88
|
+
'qwen3-max': {
|
|
89
|
+
name: 'Qwen3 Max',
|
|
90
|
+
limit: { context: 256000, output: 32000 },
|
|
91
|
+
modalities: { input: ['text'], output: ['text'] }
|
|
92
|
+
},
|
|
93
|
+
'qwen3-vl-plus': {
|
|
94
|
+
name: 'Qwen3 VL Plus',
|
|
95
|
+
limit: { context: 256000, output: 32000 },
|
|
96
|
+
modalities: { input: ['text', 'image'], output: ['text'] }
|
|
97
|
+
},
|
|
98
|
+
'qwen3-235b-a22b-thinking-2507': {
|
|
99
|
+
name: 'Qwen3 235B Thinking',
|
|
100
|
+
limit: { context: 256000, output: 64000 },
|
|
101
|
+
modalities: { input: ['text'], output: ['text'] }
|
|
102
|
+
},
|
|
103
|
+
'kimi-k2': {
|
|
104
|
+
name: 'Kimi K2',
|
|
105
|
+
limit: { context: 128000, output: 64000 },
|
|
106
|
+
modalities: { input: ['text'], output: ['text'] }
|
|
107
|
+
},
|
|
108
|
+
'kimi-k2-0905': {
|
|
109
|
+
name: 'Kimi K2 0905',
|
|
110
|
+
limit: { context: 256000, output: 64000 },
|
|
111
|
+
modalities: { input: ['text'], output: ['text'] }
|
|
112
|
+
},
|
|
113
|
+
'glm-4.6': {
|
|
114
|
+
name: 'GLM-4.6 Thinking',
|
|
115
|
+
limit: { context: 200000, output: 128000 },
|
|
116
|
+
modalities: { input: ['text', 'image'], output: ['text'] },
|
|
117
|
+
variants: {
|
|
118
|
+
low: { thinkingConfig: { thinkingBudget: 1024 } },
|
|
119
|
+
medium: { thinkingConfig: { thinkingBudget: 8192 } },
|
|
120
|
+
max: { thinkingConfig: { thinkingBudget: 32768 } }
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
'deepseek-v3': {
|
|
124
|
+
name: 'DeepSeek V3',
|
|
125
|
+
limit: { context: 128000, output: 32000 },
|
|
126
|
+
modalities: { input: ['text'], output: ['text'] }
|
|
127
|
+
},
|
|
128
|
+
'deepseek-v3.2': {
|
|
129
|
+
name: 'DeepSeek V3.2',
|
|
130
|
+
limit: { context: 128000, output: 64000 },
|
|
131
|
+
modalities: { input: ['text'], output: ['text'] }
|
|
132
|
+
},
|
|
133
|
+
'deepseek-r1': {
|
|
134
|
+
name: 'DeepSeek R1',
|
|
135
|
+
limit: { context: 128000, output: 32000 },
|
|
136
|
+
modalities: { input: ['text'], output: ['text'] },
|
|
137
|
+
variants: {
|
|
138
|
+
low: { thinkingConfig: { thinkingBudget: 1024 } },
|
|
139
|
+
medium: { thinkingConfig: { thinkingBudget: 8192 } },
|
|
140
|
+
max: { thinkingConfig: { thinkingBudget: 32768 } }
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
'qwen3-32b': {
|
|
144
|
+
name: 'Qwen3 32B',
|
|
145
|
+
limit: { context: 128000, output: 32000 },
|
|
146
|
+
modalities: { input: ['text'], output: ['text'] }
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
export const createIFlowPlugin = (id) => async ({ client, directory }) => {
|
|
150
|
+
const config = loadConfig();
|
|
151
|
+
const showToast = (message, variant) => {
|
|
152
|
+
client.tui.showToast({ body: { message, variant } }).catch(() => { });
|
|
153
|
+
};
|
|
154
|
+
return {
|
|
155
|
+
auth: {
|
|
156
|
+
provider: id,
|
|
157
|
+
loader: async (getAuth, provider) => {
|
|
158
|
+
await getAuth();
|
|
159
|
+
const am = await AccountManager.loadFromDisk(config.account_selection_strategy);
|
|
160
|
+
// Auto-configure models if not already configured
|
|
161
|
+
const configuredModels = provider?.models || {};
|
|
162
|
+
const mergedModels = { ...DEFAULT_MODELS, ...configuredModels };
|
|
163
|
+
return {
|
|
164
|
+
apiKey: '',
|
|
165
|
+
baseURL: IFLOW_CONSTANTS.BASE_URL,
|
|
166
|
+
models: mergedModels,
|
|
167
|
+
async fetch(input, init) {
|
|
168
|
+
const url = typeof input === 'string' ? input : input.url;
|
|
169
|
+
let retry = 0;
|
|
170
|
+
let iterations = 0;
|
|
171
|
+
const startTime = Date.now();
|
|
172
|
+
const maxIterations = config.max_request_iterations;
|
|
173
|
+
const timeoutMs = config.request_timeout_ms;
|
|
174
|
+
while (true) {
|
|
175
|
+
iterations++;
|
|
176
|
+
const elapsed = Date.now() - startTime;
|
|
177
|
+
if (iterations > maxIterations) {
|
|
178
|
+
throw new Error(`Request exceeded max iterations (${maxIterations}). All accounts may be unhealthy or rate-limited.`);
|
|
179
|
+
}
|
|
180
|
+
if (elapsed > timeoutMs) {
|
|
181
|
+
throw new Error(`Request timeout after ${Math.ceil(elapsed / 1000)}s. Max timeout: ${Math.ceil(timeoutMs / 1000)}s.`);
|
|
182
|
+
}
|
|
183
|
+
const count = am.getAccountCount();
|
|
184
|
+
if (count === 0)
|
|
185
|
+
throw new Error('No accounts. Login first.');
|
|
186
|
+
const acc = am.getCurrentOrNext();
|
|
187
|
+
if (!acc) {
|
|
188
|
+
const minWait = am.getMinWaitTime();
|
|
189
|
+
if (minWait > 0) {
|
|
190
|
+
showToast(`All accounts rate-limited. Waiting ${Math.ceil(minWait / 1000)}s...`, 'warning');
|
|
191
|
+
await sleep(Math.min(minWait, 5000));
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
throw new Error('No healthy accounts available');
|
|
195
|
+
}
|
|
196
|
+
if (count > 1 && am.shouldShowToast()) {
|
|
197
|
+
showToast(`Using ${acc.email} (${am.getAccounts().indexOf(acc) + 1}/${count})`, 'info');
|
|
198
|
+
}
|
|
199
|
+
if (acc.authMethod === 'oauth' &&
|
|
200
|
+
acc.expiresAt &&
|
|
201
|
+
accessTokenExpired(acc.expiresAt)) {
|
|
202
|
+
try {
|
|
203
|
+
const authDetails = am.toAuthDetails(acc);
|
|
204
|
+
const refreshed = await refreshAccessToken(authDetails);
|
|
205
|
+
am.updateFromAuth(acc, refreshed);
|
|
206
|
+
await am.saveToDisk();
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
logger.error(`Token refresh failed for account ${acc.id}`, error);
|
|
210
|
+
am.markUnhealthy(acc, 'Token refresh failed', Date.now() + 300000);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const body = init?.body ? JSON.parse(init.body) : {};
|
|
215
|
+
const model = body.model || 'qwen3-max';
|
|
216
|
+
let processedBody = applyThinkingConfig(body, model);
|
|
217
|
+
if (processedBody.stream === false && processedBody.stream_options) {
|
|
218
|
+
const { stream_options, ...rest } = processedBody;
|
|
219
|
+
processedBody = rest;
|
|
220
|
+
}
|
|
221
|
+
const apiTimestamp = config.enable_log_api_request ? logger.getTimestamp() : null;
|
|
222
|
+
const incomingHeaders = init?.headers || {};
|
|
223
|
+
const cleanedHeaders = {};
|
|
224
|
+
for (const [key, value] of Object.entries(incomingHeaders)) {
|
|
225
|
+
const lowerKey = key.toLowerCase();
|
|
226
|
+
if (lowerKey !== 'authorization' &&
|
|
227
|
+
lowerKey !== 'user-agent' &&
|
|
228
|
+
lowerKey !== 'content-type') {
|
|
229
|
+
cleanedHeaders[key] = value;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const headers = {
|
|
233
|
+
Authorization: `Bearer ${acc.apiKey}`,
|
|
234
|
+
'User-Agent': IFLOW_CONSTANTS.USER_AGENT,
|
|
235
|
+
'Content-Type': 'application/json',
|
|
236
|
+
...cleanedHeaders
|
|
237
|
+
};
|
|
238
|
+
if (apiTimestamp) {
|
|
239
|
+
const sanitizedHeaders = {
|
|
240
|
+
...headers,
|
|
241
|
+
Authorization: `Bearer ${acc.apiKey.substring(0, 10)}...`
|
|
242
|
+
};
|
|
243
|
+
const requestData = {
|
|
244
|
+
url: typeof input === 'string' ? input : input.url,
|
|
245
|
+
method: init?.method || 'POST',
|
|
246
|
+
headers: sanitizedHeaders,
|
|
247
|
+
body: processedBody,
|
|
248
|
+
account: acc.email
|
|
249
|
+
};
|
|
250
|
+
logger.logApiRequest(requestData, apiTimestamp);
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const response = await fetch(input, {
|
|
254
|
+
...init,
|
|
255
|
+
headers,
|
|
256
|
+
body: JSON.stringify(processedBody),
|
|
257
|
+
method: init?.method || 'POST'
|
|
258
|
+
});
|
|
259
|
+
if (response.ok) {
|
|
260
|
+
if (apiTimestamp) {
|
|
261
|
+
const responseData = {
|
|
262
|
+
status: response.status,
|
|
263
|
+
statusText: response.statusText,
|
|
264
|
+
headers: {}
|
|
265
|
+
};
|
|
266
|
+
logger.logApiResponse(responseData, apiTimestamp);
|
|
267
|
+
}
|
|
268
|
+
return response;
|
|
269
|
+
}
|
|
270
|
+
const errorText = await response.text().catch(() => '');
|
|
271
|
+
const responseData = {
|
|
272
|
+
status: response.status,
|
|
273
|
+
statusText: response.statusText,
|
|
274
|
+
body: errorText,
|
|
275
|
+
account: acc.email
|
|
276
|
+
};
|
|
277
|
+
const sanitizedHeaders = {
|
|
278
|
+
...headers,
|
|
279
|
+
Authorization: `Bearer ${acc.apiKey.substring(0, 10)}...`
|
|
280
|
+
};
|
|
281
|
+
const requestData = {
|
|
282
|
+
url: typeof input === 'string' ? input : input.url,
|
|
283
|
+
method: init?.method || 'POST',
|
|
284
|
+
headers: sanitizedHeaders,
|
|
285
|
+
body: processedBody,
|
|
286
|
+
account: acc.email
|
|
287
|
+
};
|
|
288
|
+
if (config.enable_log_api_request && apiTimestamp) {
|
|
289
|
+
logger.logApiResponse(responseData, apiTimestamp);
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
const errorTimestamp = logger.getTimestamp();
|
|
293
|
+
logger.logApiError(requestData, responseData, errorTimestamp);
|
|
294
|
+
}
|
|
295
|
+
if (response.status === 429) {
|
|
296
|
+
const retryAfter = parseInt(response.headers.get('retry-after') || '60', 10);
|
|
297
|
+
logger.warn(`Rate limited on account ${acc.email}, retry after ${retryAfter}s`);
|
|
298
|
+
am.markRateLimited(acc, retryAfter * 1000);
|
|
299
|
+
await sleep(1000);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (response.status === 401 || response.status === 403) {
|
|
303
|
+
logger.warn(`Authentication failed for ${acc.email}: ${response.status}`);
|
|
304
|
+
am.markUnhealthy(acc, 'Authentication failed', Date.now() + 300000);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (response.status >= 500) {
|
|
308
|
+
if (retry < 3) {
|
|
309
|
+
retry++;
|
|
310
|
+
logger.warn(`Server error ${response.status}, retry ${retry}/3`);
|
|
311
|
+
await sleep(1000 * Math.pow(2, retry));
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
logger.error(`Server error ${response.status} after ${retry} retries`);
|
|
315
|
+
am.markUnhealthy(acc, 'Server error', Date.now() + 300000);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
throw new Error(`iFlow Error: ${response.status} - ${errorText}`);
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
if (isNetworkError(error) && retry < 3) {
|
|
322
|
+
retry++;
|
|
323
|
+
logger.warn(`Network error, retry ${retry}/3: ${error.message}`);
|
|
324
|
+
await sleep(1000 * Math.pow(2, retry));
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const sanitizedHeaders = {
|
|
328
|
+
...headers,
|
|
329
|
+
Authorization: `Bearer ${acc.apiKey.substring(0, 10)}...`
|
|
330
|
+
};
|
|
331
|
+
const requestData = {
|
|
332
|
+
url: typeof input === 'string' ? input : input.url,
|
|
333
|
+
method: init?.method || 'POST',
|
|
334
|
+
headers: sanitizedHeaders,
|
|
335
|
+
body: processedBody,
|
|
336
|
+
account: acc.email
|
|
337
|
+
};
|
|
338
|
+
const networkErrorData = {
|
|
339
|
+
status: 0,
|
|
340
|
+
statusText: 'Network Error',
|
|
341
|
+
body: error.message,
|
|
342
|
+
account: acc.email
|
|
343
|
+
};
|
|
344
|
+
if (config.enable_log_api_request && apiTimestamp) {
|
|
345
|
+
logger.logApiResponse(networkErrorData, apiTimestamp);
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
const errorTimestamp = logger.getTimestamp();
|
|
349
|
+
logger.logApiError(requestData, networkErrorData, errorTimestamp);
|
|
350
|
+
}
|
|
351
|
+
logger.error(`Request failed after ${retry} retries: ${error.message}`, error);
|
|
352
|
+
throw error;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
},
|
|
358
|
+
methods: [
|
|
359
|
+
{
|
|
360
|
+
id: 'oauth',
|
|
361
|
+
label: 'iFlow OAuth 2.0',
|
|
362
|
+
type: 'oauth',
|
|
363
|
+
authorize: async (inputs) => new Promise(async (resolve) => {
|
|
364
|
+
const isHeadless = isHeadlessEnvironment();
|
|
365
|
+
/**
|
|
366
|
+
* Perform OAuth with local server + manual code input fallback
|
|
367
|
+
* Always starts server, always shows URL, always allows manual input
|
|
368
|
+
*/
|
|
369
|
+
const performOAuth = async () => {
|
|
370
|
+
try {
|
|
371
|
+
const authData = await authorizeIFlowOAuth(config.auth_server_port_start);
|
|
372
|
+
// Start local OAuth server (always)
|
|
373
|
+
const server = await startOAuthServer(authData.authUrl, authData.state, authData.redirectUri, config.auth_server_port_start, config.auth_server_port_range);
|
|
374
|
+
console.log('\n=== iFlow OAuth Authentication ===\n');
|
|
375
|
+
console.log('OAuth URL:');
|
|
376
|
+
console.log(authData.authUrl);
|
|
377
|
+
console.log('');
|
|
378
|
+
// Open browser automatically if not headless
|
|
379
|
+
if (!isHeadless) {
|
|
380
|
+
console.log('Opening browser automatically...');
|
|
381
|
+
openBrowser(authData.authUrl);
|
|
382
|
+
}
|
|
383
|
+
console.log(`\nLocal callback server running on port ${server.actualPort}`);
|
|
384
|
+
console.log('Waiting for authentication...');
|
|
385
|
+
console.log('\n(If the browser does not open automatically, open the URL above manually)');
|
|
386
|
+
console.log('(You can also paste the callback URL or authorization code below)\n');
|
|
387
|
+
// Race between server callback and manual code input
|
|
388
|
+
const manualInputPromise = (async () => {
|
|
389
|
+
const callbackInput = await promptOAuthCallback();
|
|
390
|
+
const { code, state } = parseOAuthCallbackInput(callbackInput);
|
|
391
|
+
if (!code) {
|
|
392
|
+
throw new Error('No authorization code provided');
|
|
393
|
+
}
|
|
394
|
+
if (state && state !== authData.state) {
|
|
395
|
+
throw new Error('State mismatch - possible CSRF attempt');
|
|
396
|
+
}
|
|
397
|
+
// Close server since we got manual input
|
|
398
|
+
server.close();
|
|
399
|
+
return await server.exchangeCode(code);
|
|
400
|
+
})();
|
|
401
|
+
const result = await Promise.race([
|
|
402
|
+
server.waitForAuth(),
|
|
403
|
+
manualInputPromise
|
|
404
|
+
]);
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
407
|
+
catch (e) {
|
|
408
|
+
logger.error(`OAuth authorization failed: ${e.message}`, e);
|
|
409
|
+
return { type: 'failed', error: e.message };
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
if (inputs) {
|
|
413
|
+
const accounts = [];
|
|
414
|
+
let startFresh = true;
|
|
415
|
+
const existingAm = await AccountManager.loadFromDisk(config.account_selection_strategy);
|
|
416
|
+
if (existingAm.getAccountCount() > 0) {
|
|
417
|
+
const existingAccounts = existingAm.getAccounts().map((acc, idx) => ({
|
|
418
|
+
email: acc.email,
|
|
419
|
+
index: idx
|
|
420
|
+
}));
|
|
421
|
+
const loginMode = await promptLoginMode(existingAccounts);
|
|
422
|
+
startFresh = loginMode === 'fresh';
|
|
423
|
+
console.log(startFresh
|
|
424
|
+
? '\nStarting fresh - existing accounts will be replaced.\n'
|
|
425
|
+
: '\nAdding to existing accounts.\n');
|
|
426
|
+
}
|
|
427
|
+
while (true) {
|
|
428
|
+
console.log(`\n=== iFlow OAuth (Account ${accounts.length + 1}) ===\n`);
|
|
429
|
+
const result = await performOAuth();
|
|
430
|
+
if ('type' in result && result.type === 'failed') {
|
|
431
|
+
if (accounts.length === 0) {
|
|
432
|
+
return resolve({
|
|
433
|
+
url: '',
|
|
434
|
+
instructions: `Authentication failed: ${result.error}`,
|
|
435
|
+
method: 'code',
|
|
436
|
+
callback: async () => ({ type: 'failed' })
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
console.warn(`[opencode-iflow-auth] Skipping failed account ${accounts.length + 1}: ${result.error}`);
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
const successResult = result;
|
|
443
|
+
accounts.push(successResult);
|
|
444
|
+
const isFirstAccount = accounts.length === 1;
|
|
445
|
+
const am = await AccountManager.loadFromDisk(config.account_selection_strategy);
|
|
446
|
+
if (isFirstAccount && startFresh) {
|
|
447
|
+
am.getAccounts().forEach((acc) => am.removeAccount(acc));
|
|
448
|
+
}
|
|
449
|
+
const acc = {
|
|
450
|
+
id: generateAccountId(),
|
|
451
|
+
email: successResult.email,
|
|
452
|
+
authMethod: 'oauth',
|
|
453
|
+
refreshToken: successResult.refreshToken,
|
|
454
|
+
accessToken: successResult.accessToken,
|
|
455
|
+
expiresAt: successResult.expiresAt,
|
|
456
|
+
apiKey: successResult.apiKey,
|
|
457
|
+
rateLimitResetTime: 0,
|
|
458
|
+
isHealthy: true
|
|
459
|
+
};
|
|
460
|
+
am.addAccount(acc);
|
|
461
|
+
await am.saveToDisk();
|
|
462
|
+
showToast(`Account ${accounts.length} authenticated${successResult.email ? ` (${successResult.email})` : ''}`, 'success');
|
|
463
|
+
let currentAccountCount = accounts.length;
|
|
464
|
+
try {
|
|
465
|
+
const currentStorage = await AccountManager.loadFromDisk(config.account_selection_strategy);
|
|
466
|
+
currentAccountCount = currentStorage.getAccountCount();
|
|
467
|
+
}
|
|
468
|
+
catch (e) {
|
|
469
|
+
logger.warn(`Failed to load account count: ${e.message}`);
|
|
470
|
+
}
|
|
471
|
+
const addAnother = await promptAddAnotherAccount(currentAccountCount);
|
|
472
|
+
if (!addAnother) {
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const primary = accounts[0];
|
|
477
|
+
if (!primary) {
|
|
478
|
+
return resolve({
|
|
479
|
+
url: '',
|
|
480
|
+
instructions: 'Authentication cancelled',
|
|
481
|
+
method: 'code',
|
|
482
|
+
callback: async () => ({ type: 'failed' })
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
let actualAccountCount = accounts.length;
|
|
486
|
+
try {
|
|
487
|
+
const finalStorage = await AccountManager.loadFromDisk(config.account_selection_strategy);
|
|
488
|
+
actualAccountCount = finalStorage.getAccountCount();
|
|
489
|
+
}
|
|
490
|
+
catch (e) {
|
|
491
|
+
logger.warn(`Failed to load account count: ${e.message}`);
|
|
492
|
+
}
|
|
493
|
+
return resolve({
|
|
494
|
+
url: '',
|
|
495
|
+
instructions: `Multi-account setup complete (${actualAccountCount} account(s)).`,
|
|
496
|
+
method: 'code',
|
|
497
|
+
callback: async () => ({ type: 'success', key: primary.apiKey })
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
// TUI mode (no inputs) - return code-based auth that works for both headless and non-headless
|
|
501
|
+
try {
|
|
502
|
+
const authData = await authorizeIFlowOAuth(config.auth_server_port_start);
|
|
503
|
+
// Start server in background
|
|
504
|
+
const server = await startOAuthServer(authData.authUrl, authData.state, authData.redirectUri, config.auth_server_port_start, config.auth_server_port_range);
|
|
505
|
+
// Open browser if not headless
|
|
506
|
+
if (!isHeadless) {
|
|
507
|
+
openBrowser(authData.authUrl);
|
|
508
|
+
}
|
|
509
|
+
resolve({
|
|
510
|
+
url: authData.authUrl,
|
|
511
|
+
instructions: `Open this URL to authenticate:\n${authData.authUrl}\n\nA local callback server is running on port ${server.actualPort}.\nYou can either wait for automatic redirect (if browser opened) or paste the callback URL/code below.`,
|
|
512
|
+
method: 'code',
|
|
513
|
+
callback: async (callbackInput) => {
|
|
514
|
+
try {
|
|
515
|
+
const { code, state } = parseOAuthCallbackInput(callbackInput);
|
|
516
|
+
if (!code) {
|
|
517
|
+
return { type: 'failed', error: 'Missing authorization code' };
|
|
518
|
+
}
|
|
519
|
+
if (state && state !== authData.state) {
|
|
520
|
+
return { type: 'failed', error: 'State mismatch - possible CSRF attempt' };
|
|
521
|
+
}
|
|
522
|
+
const res = await server.exchangeCode(code);
|
|
523
|
+
const am = await AccountManager.loadFromDisk(config.account_selection_strategy);
|
|
524
|
+
const acc = {
|
|
525
|
+
id: generateAccountId(),
|
|
526
|
+
email: res.email,
|
|
527
|
+
authMethod: 'oauth',
|
|
528
|
+
refreshToken: res.refreshToken,
|
|
529
|
+
accessToken: res.accessToken,
|
|
530
|
+
expiresAt: res.expiresAt,
|
|
531
|
+
apiKey: res.apiKey,
|
|
532
|
+
rateLimitResetTime: 0,
|
|
533
|
+
isHealthy: true
|
|
534
|
+
};
|
|
535
|
+
am.addAccount(acc);
|
|
536
|
+
await am.saveToDisk();
|
|
537
|
+
showToast(`Successfully logged in as ${res.email}`, 'success');
|
|
538
|
+
return { type: 'success', key: res.apiKey };
|
|
539
|
+
}
|
|
540
|
+
catch (e) {
|
|
541
|
+
logger.error(`Login failed: ${e.message}`, e);
|
|
542
|
+
showToast(`Login failed: ${e.message}`, 'error');
|
|
543
|
+
return { type: 'failed', error: e.message };
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
catch (e) {
|
|
549
|
+
logger.error(`Authorization failed: ${e.message}`, e);
|
|
550
|
+
showToast(`Authorization failed: ${e.message}`, 'error');
|
|
551
|
+
resolve({
|
|
552
|
+
url: '',
|
|
553
|
+
instructions: 'Authorization failed',
|
|
554
|
+
method: 'code',
|
|
555
|
+
callback: async () => ({ type: 'failed' })
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
})
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
id: 'api',
|
|
562
|
+
label: 'iFlow API Key',
|
|
563
|
+
type: 'api',
|
|
564
|
+
authorize: async (inputs) => new Promise(async (resolve) => {
|
|
565
|
+
if (inputs) {
|
|
566
|
+
const accounts = [];
|
|
567
|
+
let startFresh = true;
|
|
568
|
+
const existingAm = await AccountManager.loadFromDisk(config.account_selection_strategy);
|
|
569
|
+
if (existingAm.getAccountCount() > 0) {
|
|
570
|
+
const existingAccounts = existingAm.getAccounts().map((acc, idx) => ({
|
|
571
|
+
email: acc.email,
|
|
572
|
+
index: idx
|
|
573
|
+
}));
|
|
574
|
+
const loginMode = await promptLoginMode(existingAccounts);
|
|
575
|
+
startFresh = loginMode === 'fresh';
|
|
576
|
+
console.log(startFresh
|
|
577
|
+
? '\nStarting fresh - existing accounts will be replaced.\n'
|
|
578
|
+
: '\nAdding to existing accounts.\n');
|
|
579
|
+
}
|
|
580
|
+
while (true) {
|
|
581
|
+
console.log(`\n=== iFlow API Key (Account ${accounts.length + 1}) ===\n`);
|
|
582
|
+
const apiKey = await promptApiKey();
|
|
583
|
+
if (!apiKey) {
|
|
584
|
+
if (accounts.length === 0) {
|
|
585
|
+
return resolve({
|
|
586
|
+
url: '',
|
|
587
|
+
instructions: 'API key required',
|
|
588
|
+
method: 'auto',
|
|
589
|
+
callback: async () => ({ type: 'failed' })
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
try {
|
|
595
|
+
await validateApiKey(apiKey);
|
|
596
|
+
const email = await promptEmail();
|
|
597
|
+
accounts.push({ apiKey, email });
|
|
598
|
+
const isFirstAccount = accounts.length === 1;
|
|
599
|
+
const am = await AccountManager.loadFromDisk(config.account_selection_strategy);
|
|
600
|
+
if (isFirstAccount && startFresh) {
|
|
601
|
+
am.getAccounts().forEach((acc) => am.removeAccount(acc));
|
|
602
|
+
}
|
|
603
|
+
const acc = {
|
|
604
|
+
id: generateAccountId(),
|
|
605
|
+
email,
|
|
606
|
+
authMethod: 'apikey',
|
|
607
|
+
apiKey,
|
|
608
|
+
rateLimitResetTime: 0,
|
|
609
|
+
isHealthy: true
|
|
610
|
+
};
|
|
611
|
+
am.addAccount(acc);
|
|
612
|
+
await am.saveToDisk();
|
|
613
|
+
showToast(`Account ${accounts.length} added (${email})`, 'success');
|
|
614
|
+
let currentAccountCount = accounts.length;
|
|
615
|
+
try {
|
|
616
|
+
const currentStorage = await AccountManager.loadFromDisk(config.account_selection_strategy);
|
|
617
|
+
currentAccountCount = currentStorage.getAccountCount();
|
|
618
|
+
}
|
|
619
|
+
catch (e) {
|
|
620
|
+
logger.warn(`Failed to load account count: ${e.message}`);
|
|
621
|
+
}
|
|
622
|
+
const addAnother = await promptAddAnotherAccount(currentAccountCount);
|
|
623
|
+
if (!addAnother) {
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
catch (error) {
|
|
628
|
+
console.error(`API key validation failed: ${error.message}`);
|
|
629
|
+
if (accounts.length === 0) {
|
|
630
|
+
return resolve({
|
|
631
|
+
url: '',
|
|
632
|
+
instructions: `API key validation failed: ${error.message}`,
|
|
633
|
+
method: 'auto',
|
|
634
|
+
callback: async () => ({ type: 'failed' })
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
const primary = accounts[0];
|
|
641
|
+
if (!primary) {
|
|
642
|
+
return resolve({
|
|
643
|
+
url: '',
|
|
644
|
+
instructions: 'Authentication cancelled',
|
|
645
|
+
method: 'auto',
|
|
646
|
+
callback: async () => ({ type: 'failed' })
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
let actualAccountCount = accounts.length;
|
|
650
|
+
try {
|
|
651
|
+
const finalStorage = await AccountManager.loadFromDisk(config.account_selection_strategy);
|
|
652
|
+
actualAccountCount = finalStorage.getAccountCount();
|
|
653
|
+
}
|
|
654
|
+
catch (e) {
|
|
655
|
+
logger.warn(`Failed to load account count: ${e.message}`);
|
|
656
|
+
}
|
|
657
|
+
return resolve({
|
|
658
|
+
url: '',
|
|
659
|
+
instructions: `Multi-account setup complete (${actualAccountCount} account(s)).`,
|
|
660
|
+
method: 'auto',
|
|
661
|
+
callback: async () => ({ type: 'success', key: primary.apiKey })
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
resolve({
|
|
665
|
+
url: '',
|
|
666
|
+
instructions: 'API Key authentication not supported in TUI mode. Use CLI: opencode auth login',
|
|
667
|
+
method: 'auto',
|
|
668
|
+
callback: async () => ({ type: 'failed' })
|
|
669
|
+
});
|
|
670
|
+
})
|
|
671
|
+
}
|
|
672
|
+
]
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
};
|
|
676
|
+
export const IFlowOAuthPlugin = createIFlowPlugin(IFLOW_PROVIDER_ID);
|