hedgequantx 2.7.29 → 2.7.30
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/package.json +1 -1
- package/src/app.js +21 -7
- package/src/pages/ai-agents.js +87 -47
- package/src/services/cliproxy/installer.js +111 -0
- package/src/services/cliproxy/manager.js +81 -104
- package/src/ui/index.js +8 -3
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -42,7 +42,15 @@ const restoreTerminal = () => {
|
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
process.on('exit', restoreTerminal);
|
|
45
|
-
process.on('SIGINT', () => {
|
|
45
|
+
process.on('SIGINT', () => {
|
|
46
|
+
restoreTerminal();
|
|
47
|
+
// Draw bottom border before exit
|
|
48
|
+
const termWidth = process.stdout.columns || 100;
|
|
49
|
+
const boxWidth = termWidth < 60 ? Math.max(termWidth - 2, 40) : Math.max(getLogoWidth(), 98);
|
|
50
|
+
console.log(chalk.cyan('\n╚' + '═'.repeat(boxWidth - 2) + '╝'));
|
|
51
|
+
console.log(chalk.gray('GOODBYE!'));
|
|
52
|
+
process.exit(0);
|
|
53
|
+
});
|
|
46
54
|
process.on('SIGTERM', () => { restoreTerminal(); process.exit(0); });
|
|
47
55
|
|
|
48
56
|
process.on('uncaughtException', (err) => {
|
|
@@ -162,20 +170,26 @@ const run = async () => {
|
|
|
162
170
|
try {
|
|
163
171
|
log.info('Starting HQX CLI');
|
|
164
172
|
|
|
165
|
-
// First launch - show banner with loading
|
|
173
|
+
// First launch - show banner with loading
|
|
166
174
|
await banner();
|
|
167
175
|
const boxWidth = getLogoWidth();
|
|
168
176
|
const innerWidth = boxWidth - 2;
|
|
177
|
+
|
|
178
|
+
// Show loading inside the box
|
|
169
179
|
console.log(chalk.cyan('╠' + '═'.repeat(innerWidth) + '╣'));
|
|
180
|
+
const loadingText = ' LOADING DASHBOARD...';
|
|
181
|
+
const loadingPad = innerWidth - loadingText.length;
|
|
182
|
+
process.stdout.write(chalk.cyan('║') + chalk.yellow(loadingText) + ' '.repeat(loadingPad) + chalk.cyan('║'));
|
|
170
183
|
|
|
171
|
-
const spinner = ora({ text: ' Loading...', color: 'yellow' }).start();
|
|
172
184
|
const restored = await connections.restoreFromStorage();
|
|
173
185
|
|
|
174
186
|
if (restored) {
|
|
175
187
|
currentService = connections.getAll()[0].service;
|
|
176
188
|
await refreshStats();
|
|
177
189
|
}
|
|
178
|
-
|
|
190
|
+
|
|
191
|
+
// Clear loading line
|
|
192
|
+
process.stdout.write('\r' + ' '.repeat(boxWidth + 2) + '\r');
|
|
179
193
|
|
|
180
194
|
// Main loop
|
|
181
195
|
while (true) {
|
|
@@ -234,13 +248,13 @@ const run = async () => {
|
|
|
234
248
|
}
|
|
235
249
|
|
|
236
250
|
console.log(chalk.cyan('╠' + '─'.repeat(innerWidth) + '╣'));
|
|
237
|
-
console.log(chalk.cyan('║') + chalk.red(centerText('[X]
|
|
251
|
+
console.log(chalk.cyan('║') + chalk.red(centerText('[X] EXIT', innerWidth)) + chalk.cyan('║'));
|
|
238
252
|
console.log(chalk.cyan('╚' + '═'.repeat(innerWidth) + '╝'));
|
|
239
253
|
|
|
240
|
-
const input = await prompts.textInput(chalk.cyan('
|
|
254
|
+
const input = await prompts.textInput(chalk.cyan('SELECT (1-' + numbered.length + '/X): '));
|
|
241
255
|
|
|
242
256
|
if (!input || input.toLowerCase() === 'x') {
|
|
243
|
-
console.log(chalk.gray('
|
|
257
|
+
console.log(chalk.gray('GOODBYE!'));
|
|
244
258
|
process.exit(0);
|
|
245
259
|
}
|
|
246
260
|
|
package/src/pages/ai-agents.js
CHANGED
|
@@ -66,7 +66,7 @@ const selectModelFromList = async (provider, models, boxWidth) => {
|
|
|
66
66
|
clearWithBanner();
|
|
67
67
|
drawModelsTable(provider, models, boxWidth);
|
|
68
68
|
|
|
69
|
-
const input = await prompts.textInput(chalk.cyan('
|
|
69
|
+
const input = await prompts.textInput(chalk.cyan('SELECT MODEL: '));
|
|
70
70
|
const choice = (input || '').toLowerCase().trim();
|
|
71
71
|
|
|
72
72
|
if (choice === 'b' || choice === '') return null;
|
|
@@ -74,7 +74,7 @@ const selectModelFromList = async (provider, models, boxWidth) => {
|
|
|
74
74
|
const num = parseInt(choice);
|
|
75
75
|
if (!isNaN(num) && num >= 1 && num <= models.length) return models[num - 1];
|
|
76
76
|
|
|
77
|
-
console.log(chalk.red('
|
|
77
|
+
console.log(chalk.red(' INVALID OPTION'));
|
|
78
78
|
await new Promise(r => setTimeout(r, 1000));
|
|
79
79
|
}
|
|
80
80
|
};
|
|
@@ -82,16 +82,16 @@ const selectModelFromList = async (provider, models, boxWidth) => {
|
|
|
82
82
|
/** Select a model for a provider (fetches from API) */
|
|
83
83
|
const selectModel = async (provider, apiKey) => {
|
|
84
84
|
const boxWidth = getLogoWidth();
|
|
85
|
-
const spinner = ora({ text: '
|
|
85
|
+
const spinner = ora({ text: 'FETCHING MODELS FROM API...', color: 'yellow' }).start();
|
|
86
86
|
const result = await fetchModelsFromApi(provider.id, apiKey);
|
|
87
87
|
|
|
88
88
|
if (!result.success || result.models.length === 0) {
|
|
89
|
-
spinner.fail(result.error || '
|
|
89
|
+
spinner.fail(result.error || 'NO MODELS AVAILABLE');
|
|
90
90
|
await prompts.waitForEnter();
|
|
91
91
|
return null;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
spinner.succeed(`
|
|
94
|
+
spinner.succeed(`FOUND ${result.models.length} MODELS`);
|
|
95
95
|
return selectModelFromList(provider, result.models, boxWidth);
|
|
96
96
|
};
|
|
97
97
|
|
|
@@ -110,47 +110,47 @@ const handleCliProxyConnection = async (provider, config, boxWidth) => {
|
|
|
110
110
|
|
|
111
111
|
// Check if CLIProxyAPI is installed
|
|
112
112
|
if (!cliproxy.isInstalled()) {
|
|
113
|
-
console.log(chalk.yellow('
|
|
114
|
-
const spinner = ora({ text: '
|
|
113
|
+
console.log(chalk.yellow(' CLIPROXYAPI NOT INSTALLED. INSTALLING...'));
|
|
114
|
+
const spinner = ora({ text: 'DOWNLOADING CLIPROXYAPI...', color: 'yellow' }).start();
|
|
115
115
|
|
|
116
116
|
const installResult = await cliproxy.install((msg, percent) => {
|
|
117
|
-
spinner.text = `${msg} ${percent}%`;
|
|
117
|
+
spinner.text = `${msg.toUpperCase()} ${percent}%`;
|
|
118
118
|
});
|
|
119
119
|
|
|
120
120
|
if (!installResult.success) {
|
|
121
|
-
spinner.fail(`
|
|
121
|
+
spinner.fail(`INSTALLATION FAILED: ${installResult.error.toUpperCase()}`);
|
|
122
122
|
await prompts.waitForEnter();
|
|
123
123
|
return false;
|
|
124
124
|
}
|
|
125
|
-
spinner.succeed('
|
|
125
|
+
spinner.succeed('CLIPROXYAPI INSTALLED');
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
// Check if running, start if not
|
|
129
129
|
let status = await cliproxy.isRunning();
|
|
130
130
|
if (!status.running) {
|
|
131
|
-
const spinner = ora({ text: '
|
|
131
|
+
const spinner = ora({ text: 'STARTING CLIPROXYAPI...', color: 'yellow' }).start();
|
|
132
132
|
const startResult = await cliproxy.start();
|
|
133
133
|
|
|
134
134
|
if (!startResult.success) {
|
|
135
|
-
spinner.fail(`
|
|
135
|
+
spinner.fail(`FAILED TO START: ${startResult.error.toUpperCase()}`);
|
|
136
136
|
await prompts.waitForEnter();
|
|
137
137
|
return false;
|
|
138
138
|
}
|
|
139
|
-
spinner.succeed('
|
|
139
|
+
spinner.succeed('CLIPROXYAPI STARTED');
|
|
140
140
|
} else {
|
|
141
|
-
console.log(chalk.green(' ✓
|
|
141
|
+
console.log(chalk.green(' ✓ CLIPROXYAPI IS RUNNING'));
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
// Check if provider supports OAuth
|
|
145
145
|
const oauthProviders = ['anthropic', 'openai', 'google', 'qwen'];
|
|
146
146
|
if (!oauthProviders.includes(provider.id)) {
|
|
147
147
|
// Try to fetch models directly
|
|
148
|
-
console.log(chalk.gray(`
|
|
148
|
+
console.log(chalk.gray(` CHECKING AVAILABLE MODELS FOR ${provider.name.toUpperCase()}...`));
|
|
149
149
|
const modelsResult = await cliproxy.fetchProviderModels(provider.id);
|
|
150
150
|
|
|
151
151
|
if (!modelsResult.success || modelsResult.models.length === 0) {
|
|
152
|
-
console.log(chalk.red(`
|
|
153
|
-
console.log(chalk.gray('
|
|
152
|
+
console.log(chalk.red(` NO MODELS AVAILABLE FOR ${provider.name.toUpperCase()}`));
|
|
153
|
+
console.log(chalk.gray(' THIS PROVIDER MAY REQUIRE API KEY CONNECTION.'));
|
|
154
154
|
await prompts.waitForEnter();
|
|
155
155
|
return false;
|
|
156
156
|
}
|
|
@@ -165,28 +165,68 @@ const handleCliProxyConnection = async (provider, config, boxWidth) => {
|
|
|
165
165
|
});
|
|
166
166
|
|
|
167
167
|
if (saveConfig(config)) {
|
|
168
|
-
console.log(chalk.green(`\n ✓ ${provider.name}
|
|
169
|
-
console.log(chalk.cyan(`
|
|
168
|
+
console.log(chalk.green(`\n ✓ ${provider.name.toUpperCase()} CONNECTED VIA CLIPROXY`));
|
|
169
|
+
console.log(chalk.cyan(` MODEL: ${selectedModel.name.toUpperCase()}`));
|
|
170
170
|
}
|
|
171
171
|
await prompts.waitForEnter();
|
|
172
172
|
return true;
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
// OAuth flow - get login URL
|
|
176
|
-
console.log(chalk.cyan(`\n
|
|
176
|
+
console.log(chalk.cyan(`\n STARTING OAUTH LOGIN FOR ${provider.name.toUpperCase()}...`));
|
|
177
177
|
const loginResult = await cliproxy.getLoginUrl(provider.id);
|
|
178
178
|
|
|
179
179
|
if (!loginResult.success) {
|
|
180
|
-
console.log(chalk.red(`
|
|
180
|
+
console.log(chalk.red(` OAUTH ERROR: ${loginResult.error.toUpperCase()}`));
|
|
181
181
|
await prompts.waitForEnter();
|
|
182
182
|
return false;
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
console.log(chalk.cyan('\n
|
|
185
|
+
console.log(chalk.cyan('\n OPEN THIS URL IN YOUR BROWSER TO AUTHENTICATE:\n'));
|
|
186
186
|
console.log(chalk.yellow(` ${loginResult.url}\n`));
|
|
187
|
-
console.log(chalk.gray(' After authenticating, press Enter to continue...'));
|
|
188
187
|
|
|
189
|
-
|
|
188
|
+
// Different flow for VPS/headless vs local
|
|
189
|
+
if (loginResult.isHeadless) {
|
|
190
|
+
console.log(chalk.magenta(' ══════════════════════════════════════════════════════'));
|
|
191
|
+
console.log(chalk.magenta(' VPS/SSH DETECTED - MANUAL CALLBACK REQUIRED'));
|
|
192
|
+
console.log(chalk.magenta(' ══════════════════════════════════════════════════════\n'));
|
|
193
|
+
console.log(chalk.white(' AFTER AUTHORIZING IN BROWSER, YOU WILL SEE A BLANK PAGE.'));
|
|
194
|
+
console.log(chalk.white(' COPY THE URL FROM BROWSER ADDRESS BAR (STARTS WITH LOCALHOST)'));
|
|
195
|
+
console.log(chalk.white(' AND PASTE IT BELOW:\n'));
|
|
196
|
+
|
|
197
|
+
const callbackUrl = await prompts.textInput(chalk.cyan(' CALLBACK URL: '));
|
|
198
|
+
|
|
199
|
+
if (!callbackUrl || !callbackUrl.includes('localhost')) {
|
|
200
|
+
console.log(chalk.red('\n INVALID CALLBACK URL'));
|
|
201
|
+
if (loginResult.childProcess) loginResult.childProcess.kill();
|
|
202
|
+
await prompts.waitForEnter();
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Process the callback
|
|
207
|
+
const spinner = ora({ text: 'PROCESSING CALLBACK...', color: 'yellow' }).start();
|
|
208
|
+
const callbackResult = await cliproxy.processCallback(callbackUrl.trim());
|
|
209
|
+
|
|
210
|
+
if (!callbackResult.success) {
|
|
211
|
+
spinner.fail(`CALLBACK FAILED: ${callbackResult.error.toUpperCase()}`);
|
|
212
|
+
if (loginResult.childProcess) loginResult.childProcess.kill();
|
|
213
|
+
await prompts.waitForEnter();
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
spinner.succeed('AUTHENTICATION SUCCESSFUL!');
|
|
218
|
+
|
|
219
|
+
// Wait for CLIProxyAPI to process the token
|
|
220
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
221
|
+
} else {
|
|
222
|
+
console.log(chalk.gray(' AFTER AUTHENTICATING, PRESS ENTER TO CONTINUE...'));
|
|
223
|
+
await prompts.waitForEnter();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Kill the login child process if still running
|
|
227
|
+
if (loginResult.childProcess) {
|
|
228
|
+
try { loginResult.childProcess.kill(); } catch (e) { /* ignore */ }
|
|
229
|
+
}
|
|
190
230
|
|
|
191
231
|
// Try to fetch models after auth
|
|
192
232
|
const modelsResult = await cliproxy.fetchProviderModels(provider.id);
|
|
@@ -200,8 +240,8 @@ const handleCliProxyConnection = async (provider, config, boxWidth) => {
|
|
|
200
240
|
modelName: selectedModel.name
|
|
201
241
|
});
|
|
202
242
|
if (saveConfig(config)) {
|
|
203
|
-
console.log(chalk.green(`\n ✓ ${provider.name}
|
|
204
|
-
console.log(chalk.cyan(`
|
|
243
|
+
console.log(chalk.green(`\n ✓ ${provider.name.toUpperCase()} CONNECTED VIA PAID PLAN`));
|
|
244
|
+
console.log(chalk.cyan(` MODEL: ${selectedModel.name.toUpperCase()}`));
|
|
205
245
|
}
|
|
206
246
|
}
|
|
207
247
|
} else {
|
|
@@ -209,10 +249,10 @@ const handleCliProxyConnection = async (provider, config, boxWidth) => {
|
|
|
209
249
|
activateProvider(config, provider.id, {
|
|
210
250
|
connectionType: 'cliproxy',
|
|
211
251
|
modelId: null,
|
|
212
|
-
modelName: '
|
|
252
|
+
modelName: 'AUTO'
|
|
213
253
|
});
|
|
214
254
|
if (saveConfig(config)) {
|
|
215
|
-
console.log(chalk.green(`\n ✓ ${provider.name}
|
|
255
|
+
console.log(chalk.green(`\n ✓ ${provider.name.toUpperCase()} CONNECTED VIA PAID PLAN`));
|
|
216
256
|
}
|
|
217
257
|
}
|
|
218
258
|
|
|
@@ -223,19 +263,19 @@ const handleCliProxyConnection = async (provider, config, boxWidth) => {
|
|
|
223
263
|
/** Handle API Key connection */
|
|
224
264
|
const handleApiKeyConnection = async (provider, config) => {
|
|
225
265
|
clearWithBanner();
|
|
226
|
-
console.log(chalk.yellow(`\n
|
|
227
|
-
console.log(chalk.gray(' (
|
|
266
|
+
console.log(chalk.yellow(`\n ENTER YOUR ${provider.name.toUpperCase()} API KEY:`));
|
|
267
|
+
console.log(chalk.gray(' (PRESS ENTER TO CANCEL)\n'));
|
|
228
268
|
|
|
229
|
-
const apiKey = await prompts.textInput(chalk.cyan(' API
|
|
269
|
+
const apiKey = await prompts.textInput(chalk.cyan(' API KEY: '), true);
|
|
230
270
|
|
|
231
271
|
if (!apiKey || apiKey.trim() === '') {
|
|
232
|
-
console.log(chalk.gray('
|
|
272
|
+
console.log(chalk.gray(' CANCELLED'));
|
|
233
273
|
await prompts.waitForEnter();
|
|
234
274
|
return false;
|
|
235
275
|
}
|
|
236
276
|
|
|
237
277
|
if (apiKey.length < 20) {
|
|
238
|
-
console.log(chalk.red('
|
|
278
|
+
console.log(chalk.red(' INVALID API KEY FORMAT (TOO SHORT)'));
|
|
239
279
|
await prompts.waitForEnter();
|
|
240
280
|
return false;
|
|
241
281
|
}
|
|
@@ -251,10 +291,10 @@ const handleApiKeyConnection = async (provider, config) => {
|
|
|
251
291
|
});
|
|
252
292
|
|
|
253
293
|
if (saveConfig(config)) {
|
|
254
|
-
console.log(chalk.green(`\n ✓ ${provider.name}
|
|
255
|
-
console.log(chalk.cyan(`
|
|
294
|
+
console.log(chalk.green(`\n ✓ ${provider.name.toUpperCase()} CONNECTED VIA API KEY`));
|
|
295
|
+
console.log(chalk.cyan(` MODEL: ${selectedModel.name.toUpperCase()}`));
|
|
256
296
|
} else {
|
|
257
|
-
console.log(chalk.red('\n
|
|
297
|
+
console.log(chalk.red('\n FAILED TO SAVE CONFIG'));
|
|
258
298
|
}
|
|
259
299
|
await prompts.waitForEnter();
|
|
260
300
|
return true;
|
|
@@ -268,7 +308,7 @@ const handleProviderConfig = async (provider, config) => {
|
|
|
268
308
|
clearWithBanner();
|
|
269
309
|
drawProviderWindow(provider, config, boxWidth);
|
|
270
310
|
|
|
271
|
-
const input = await prompts.textInput(chalk.cyan('
|
|
311
|
+
const input = await prompts.textInput(chalk.cyan('SELECT OPTION: '));
|
|
272
312
|
const choice = (input || '').toLowerCase().trim();
|
|
273
313
|
|
|
274
314
|
if (choice === 'b' || choice === '') break;
|
|
@@ -276,7 +316,7 @@ const handleProviderConfig = async (provider, config) => {
|
|
|
276
316
|
if (choice === 'd' && config.providers[provider.id]) {
|
|
277
317
|
config.providers[provider.id].active = false;
|
|
278
318
|
saveConfig(config);
|
|
279
|
-
console.log(chalk.yellow(`\n ${provider.name}
|
|
319
|
+
console.log(chalk.yellow(`\n ${provider.name.toUpperCase()} DISCONNECTED`));
|
|
280
320
|
await prompts.waitForEnter();
|
|
281
321
|
continue;
|
|
282
322
|
}
|
|
@@ -320,17 +360,17 @@ const getActiveAgentCount = () => getActiveProvider() ? 1 : 0;
|
|
|
320
360
|
/** Show CLIProxy status */
|
|
321
361
|
const showCliProxyStatus = async () => {
|
|
322
362
|
clearWithBanner();
|
|
323
|
-
console.log(chalk.yellow('\n
|
|
363
|
+
console.log(chalk.yellow('\n CLIPROXYAPI STATUS\n'));
|
|
324
364
|
|
|
325
365
|
const installed = cliproxy.isInstalled();
|
|
326
|
-
console.log(chalk.gray('
|
|
366
|
+
console.log(chalk.gray(' INSTALLED: ') + (installed ? chalk.green('YES') : chalk.red('NO')));
|
|
327
367
|
|
|
328
368
|
if (installed) {
|
|
329
369
|
const status = await cliproxy.isRunning();
|
|
330
|
-
console.log(chalk.gray('
|
|
331
|
-
console.log(chalk.gray('
|
|
332
|
-
console.log(chalk.gray('
|
|
333
|
-
console.log(chalk.gray('
|
|
370
|
+
console.log(chalk.gray(' RUNNING: ') + (status.running ? chalk.green('YES') : chalk.red('NO')));
|
|
371
|
+
console.log(chalk.gray(' VERSION: ') + chalk.cyan(cliproxy.CLIPROXY_VERSION));
|
|
372
|
+
console.log(chalk.gray(' PORT: ') + chalk.cyan(cliproxy.DEFAULT_PORT));
|
|
373
|
+
console.log(chalk.gray(' INSTALL DIR: ') + chalk.cyan(cliproxy.INSTALL_DIR));
|
|
334
374
|
}
|
|
335
375
|
|
|
336
376
|
console.log();
|
|
@@ -345,10 +385,10 @@ const aiAgentsMenu = async () => {
|
|
|
345
385
|
while (true) {
|
|
346
386
|
clearWithBanner();
|
|
347
387
|
const status = await cliproxy.isRunning();
|
|
348
|
-
const statusText = status.running ? `
|
|
388
|
+
const statusText = status.running ? `LOCALHOST:${cliproxy.DEFAULT_PORT}` : 'NOT RUNNING';
|
|
349
389
|
drawProvidersTable(AI_PROVIDERS, config, boxWidth, statusText);
|
|
350
390
|
|
|
351
|
-
const input = await prompts.textInput(chalk.cyan('
|
|
391
|
+
const input = await prompts.textInput(chalk.cyan('SELECT (1-8/S/B): '));
|
|
352
392
|
const choice = (input || '').toLowerCase().trim();
|
|
353
393
|
|
|
354
394
|
if (choice === 'b' || choice === '') break;
|
|
@@ -364,7 +404,7 @@ const aiAgentsMenu = async () => {
|
|
|
364
404
|
continue;
|
|
365
405
|
}
|
|
366
406
|
|
|
367
|
-
console.log(chalk.red('
|
|
407
|
+
console.log(chalk.red(' INVALID OPTION'));
|
|
368
408
|
await new Promise(r => setTimeout(r, 1000));
|
|
369
409
|
}
|
|
370
410
|
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLIProxyAPI Installer
|
|
3
|
+
*
|
|
4
|
+
* Handles downloading and extracting CLIProxyAPI binary
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const https = require('https');
|
|
9
|
+
const http = require('http');
|
|
10
|
+
const { createGunzip } = require('zlib');
|
|
11
|
+
const tar = require('tar');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Download file from URL with redirect support
|
|
15
|
+
* @param {string} url - URL to download
|
|
16
|
+
* @param {string} destPath - Destination path
|
|
17
|
+
* @param {Function} onProgress - Progress callback (percent)
|
|
18
|
+
* @returns {Promise<boolean>}
|
|
19
|
+
*/
|
|
20
|
+
const downloadFile = (url, destPath, onProgress = null) => {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const file = fs.createWriteStream(destPath);
|
|
23
|
+
|
|
24
|
+
const request = (url.startsWith('https') ? https : http).get(url, (response) => {
|
|
25
|
+
// Handle redirects
|
|
26
|
+
if (response.statusCode === 302 || response.statusCode === 301) {
|
|
27
|
+
file.close();
|
|
28
|
+
fs.unlinkSync(destPath);
|
|
29
|
+
return downloadFile(response.headers.location, destPath, onProgress)
|
|
30
|
+
.then(resolve)
|
|
31
|
+
.catch(reject);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (response.statusCode !== 200) {
|
|
35
|
+
file.close();
|
|
36
|
+
fs.unlinkSync(destPath);
|
|
37
|
+
return reject(new Error(`HTTP ${response.statusCode}`));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const totalSize = parseInt(response.headers['content-length'], 10);
|
|
41
|
+
let downloadedSize = 0;
|
|
42
|
+
|
|
43
|
+
response.on('data', (chunk) => {
|
|
44
|
+
downloadedSize += chunk.length;
|
|
45
|
+
if (onProgress && totalSize) {
|
|
46
|
+
onProgress(Math.round((downloadedSize / totalSize) * 100));
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
response.pipe(file);
|
|
51
|
+
|
|
52
|
+
file.on('finish', () => {
|
|
53
|
+
file.close();
|
|
54
|
+
resolve(true);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
request.on('error', (err) => {
|
|
59
|
+
file.close();
|
|
60
|
+
if (fs.existsSync(destPath)) fs.unlinkSync(destPath);
|
|
61
|
+
reject(err);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
request.setTimeout(120000, () => {
|
|
65
|
+
request.destroy();
|
|
66
|
+
reject(new Error('Download timeout'));
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Extract tar.gz file
|
|
73
|
+
* @param {string} archivePath - Path to archive
|
|
74
|
+
* @param {string} destDir - Destination directory
|
|
75
|
+
* @returns {Promise<boolean>}
|
|
76
|
+
*/
|
|
77
|
+
const extractTarGz = (archivePath, destDir) => {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
fs.createReadStream(archivePath)
|
|
80
|
+
.pipe(createGunzip())
|
|
81
|
+
.pipe(tar.extract({ cwd: destDir }))
|
|
82
|
+
.on('finish', () => resolve(true))
|
|
83
|
+
.on('error', reject);
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Extract zip file (Windows)
|
|
89
|
+
* @param {string} archivePath - Path to archive
|
|
90
|
+
* @param {string} destDir - Destination directory
|
|
91
|
+
* @returns {Promise<boolean>}
|
|
92
|
+
*/
|
|
93
|
+
const extractZip = async (archivePath, destDir) => {
|
|
94
|
+
const { execSync } = require('child_process');
|
|
95
|
+
|
|
96
|
+
if (process.platform === 'win32') {
|
|
97
|
+
execSync(`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force"`, {
|
|
98
|
+
stdio: 'ignore'
|
|
99
|
+
});
|
|
100
|
+
} else {
|
|
101
|
+
execSync(`unzip -o "${archivePath}" -d "${destDir}"`, { stdio: 'ignore' });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return true;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
module.exports = {
|
|
108
|
+
downloadFile,
|
|
109
|
+
extractTarGz,
|
|
110
|
+
extractZip
|
|
111
|
+
};
|
|
@@ -8,11 +8,9 @@
|
|
|
8
8
|
const os = require('os');
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const fs = require('fs');
|
|
11
|
-
const https = require('https');
|
|
12
11
|
const http = require('http');
|
|
13
12
|
const { spawn } = require('child_process');
|
|
14
|
-
const {
|
|
15
|
-
const tar = require('tar');
|
|
13
|
+
const { downloadFile, extractTarGz, extractZip } = require('./installer');
|
|
16
14
|
|
|
17
15
|
// CLIProxyAPI version and download URLs
|
|
18
16
|
const CLIPROXY_VERSION = '6.6.88';
|
|
@@ -27,6 +25,29 @@ const AUTH_DIR = path.join(INSTALL_DIR, 'auths');
|
|
|
27
25
|
|
|
28
26
|
// Default port
|
|
29
27
|
const DEFAULT_PORT = 8317;
|
|
28
|
+
const CALLBACK_PORT = 54545;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Detect if running in headless/VPS environment (no display)
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
const isHeadless = () => {
|
|
35
|
+
// Check for common display environment variables
|
|
36
|
+
if (process.env.DISPLAY) return false;
|
|
37
|
+
if (process.env.WAYLAND_DISPLAY) return false;
|
|
38
|
+
|
|
39
|
+
// Check if running via SSH
|
|
40
|
+
if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION) return true;
|
|
41
|
+
|
|
42
|
+
// Check platform-specific indicators
|
|
43
|
+
if (process.platform === 'linux') {
|
|
44
|
+
// No DISPLAY usually means headless on Linux
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// macOS/Windows usually have a display
|
|
49
|
+
return false;
|
|
50
|
+
};
|
|
30
51
|
|
|
31
52
|
/**
|
|
32
53
|
* Get download URL for current platform
|
|
@@ -73,101 +94,6 @@ const isInstalled = () => {
|
|
|
73
94
|
return fs.existsSync(BINARY_PATH);
|
|
74
95
|
};
|
|
75
96
|
|
|
76
|
-
/**
|
|
77
|
-
* Download file from URL
|
|
78
|
-
* @param {string} url - URL to download
|
|
79
|
-
* @param {string} destPath - Destination path
|
|
80
|
-
* @param {Function} onProgress - Progress callback (percent)
|
|
81
|
-
* @returns {Promise<boolean>}
|
|
82
|
-
*/
|
|
83
|
-
const downloadFile = (url, destPath, onProgress = null) => {
|
|
84
|
-
return new Promise((resolve, reject) => {
|
|
85
|
-
const file = fs.createWriteStream(destPath);
|
|
86
|
-
|
|
87
|
-
const request = (url.startsWith('https') ? https : http).get(url, (response) => {
|
|
88
|
-
// Handle redirects
|
|
89
|
-
if (response.statusCode === 302 || response.statusCode === 301) {
|
|
90
|
-
file.close();
|
|
91
|
-
fs.unlinkSync(destPath);
|
|
92
|
-
return downloadFile(response.headers.location, destPath, onProgress)
|
|
93
|
-
.then(resolve)
|
|
94
|
-
.catch(reject);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (response.statusCode !== 200) {
|
|
98
|
-
file.close();
|
|
99
|
-
fs.unlinkSync(destPath);
|
|
100
|
-
return reject(new Error(`HTTP ${response.statusCode}`));
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const totalSize = parseInt(response.headers['content-length'], 10);
|
|
104
|
-
let downloadedSize = 0;
|
|
105
|
-
|
|
106
|
-
response.on('data', (chunk) => {
|
|
107
|
-
downloadedSize += chunk.length;
|
|
108
|
-
if (onProgress && totalSize) {
|
|
109
|
-
onProgress(Math.round((downloadedSize / totalSize) * 100));
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
response.pipe(file);
|
|
114
|
-
|
|
115
|
-
file.on('finish', () => {
|
|
116
|
-
file.close();
|
|
117
|
-
resolve(true);
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
request.on('error', (err) => {
|
|
122
|
-
file.close();
|
|
123
|
-
if (fs.existsSync(destPath)) fs.unlinkSync(destPath);
|
|
124
|
-
reject(err);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
request.setTimeout(120000, () => {
|
|
128
|
-
request.destroy();
|
|
129
|
-
reject(new Error('Download timeout'));
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Extract tar.gz file
|
|
136
|
-
* @param {string} archivePath - Path to archive
|
|
137
|
-
* @param {string} destDir - Destination directory
|
|
138
|
-
* @returns {Promise<boolean>}
|
|
139
|
-
*/
|
|
140
|
-
const extractTarGz = (archivePath, destDir) => {
|
|
141
|
-
return new Promise((resolve, reject) => {
|
|
142
|
-
fs.createReadStream(archivePath)
|
|
143
|
-
.pipe(createGunzip())
|
|
144
|
-
.pipe(tar.extract({ cwd: destDir }))
|
|
145
|
-
.on('finish', () => resolve(true))
|
|
146
|
-
.on('error', reject);
|
|
147
|
-
});
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Extract zip file (Windows)
|
|
152
|
-
* @param {string} archivePath - Path to archive
|
|
153
|
-
* @param {string} destDir - Destination directory
|
|
154
|
-
* @returns {Promise<boolean>}
|
|
155
|
-
*/
|
|
156
|
-
const extractZip = async (archivePath, destDir) => {
|
|
157
|
-
const { execSync } = require('child_process');
|
|
158
|
-
|
|
159
|
-
if (process.platform === 'win32') {
|
|
160
|
-
// Use PowerShell on Windows
|
|
161
|
-
execSync(`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force"`, {
|
|
162
|
-
stdio: 'ignore'
|
|
163
|
-
});
|
|
164
|
-
} else {
|
|
165
|
-
// Use unzip on Unix
|
|
166
|
-
execSync(`unzip -o "${archivePath}" -d "${destDir}"`, { stdio: 'ignore' });
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
return true;
|
|
170
|
-
};
|
|
171
97
|
|
|
172
98
|
/**
|
|
173
99
|
* Install CLIProxyAPI
|
|
@@ -388,7 +314,7 @@ const ensureRunning = async (onProgress = null) => {
|
|
|
388
314
|
/**
|
|
389
315
|
* Get OAuth login URL for a provider
|
|
390
316
|
* @param {string} provider - Provider ID (anthropic, openai, google, etc.)
|
|
391
|
-
* @returns {Promise<Object>} { success, url, childProcess, error }
|
|
317
|
+
* @returns {Promise<Object>} { success, url, childProcess, isHeadless, error }
|
|
392
318
|
*/
|
|
393
319
|
const getLoginUrl = async (provider) => {
|
|
394
320
|
const providerFlags = {
|
|
@@ -400,9 +326,11 @@ const getLoginUrl = async (provider) => {
|
|
|
400
326
|
|
|
401
327
|
const flag = providerFlags[provider];
|
|
402
328
|
if (!flag) {
|
|
403
|
-
return { success: false, url: null, childProcess: null, error: 'Provider not supported for OAuth' };
|
|
329
|
+
return { success: false, url: null, childProcess: null, isHeadless: false, error: 'Provider not supported for OAuth' };
|
|
404
330
|
}
|
|
405
331
|
|
|
332
|
+
const headless = isHeadless();
|
|
333
|
+
|
|
406
334
|
// For headless/VPS, use -no-browser flag
|
|
407
335
|
return new Promise((resolve) => {
|
|
408
336
|
const args = [flag, '-no-browser'];
|
|
@@ -419,7 +347,7 @@ const getLoginUrl = async (provider) => {
|
|
|
419
347
|
if (urlMatch) {
|
|
420
348
|
resolved = true;
|
|
421
349
|
// Return child process so caller can wait for auth completion
|
|
422
|
-
resolve({ success: true, url: urlMatch[0], childProcess: child, error: null });
|
|
350
|
+
resolve({ success: true, url: urlMatch[0], childProcess: child, isHeadless: headless, error: null });
|
|
423
351
|
}
|
|
424
352
|
};
|
|
425
353
|
|
|
@@ -436,31 +364,80 @@ const getLoginUrl = async (provider) => {
|
|
|
436
364
|
child.on('error', (err) => {
|
|
437
365
|
if (!resolved) {
|
|
438
366
|
resolved = true;
|
|
439
|
-
resolve({ success: false, url: null, childProcess: null, error: err.message });
|
|
367
|
+
resolve({ success: false, url: null, childProcess: null, isHeadless: headless, error: err.message });
|
|
440
368
|
}
|
|
441
369
|
});
|
|
442
370
|
|
|
443
371
|
child.on('close', (code) => {
|
|
444
372
|
if (!resolved) {
|
|
445
373
|
resolved = true;
|
|
446
|
-
resolve({ success: false, url: null, childProcess: null, error: `Process exited with code ${code}` });
|
|
374
|
+
resolve({ success: false, url: null, childProcess: null, isHeadless: headless, error: `Process exited with code ${code}` });
|
|
447
375
|
}
|
|
448
376
|
});
|
|
449
377
|
});
|
|
450
378
|
};
|
|
451
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Process OAuth callback URL manually (for VPS/headless)
|
|
382
|
+
* The callback URL looks like: http://localhost:54545/callback?code=xxx&state=yyy
|
|
383
|
+
* We need to forward this to the waiting CLIProxyAPI process
|
|
384
|
+
* @param {string} callbackUrl - The callback URL from the browser
|
|
385
|
+
* @returns {Promise<Object>} { success, error }
|
|
386
|
+
*/
|
|
387
|
+
const processCallback = (callbackUrl) => {
|
|
388
|
+
return new Promise((resolve) => {
|
|
389
|
+
try {
|
|
390
|
+
// Parse the callback URL
|
|
391
|
+
const url = new URL(callbackUrl);
|
|
392
|
+
const params = url.searchParams;
|
|
393
|
+
|
|
394
|
+
// Extract query string to forward
|
|
395
|
+
const queryString = url.search;
|
|
396
|
+
|
|
397
|
+
// Make request to local callback endpoint
|
|
398
|
+
const callbackPath = `/callback${queryString}`;
|
|
399
|
+
|
|
400
|
+
const req = http.get(`http://127.0.0.1:${CALLBACK_PORT}${callbackPath}`, (res) => {
|
|
401
|
+
let data = '';
|
|
402
|
+
res.on('data', chunk => data += chunk);
|
|
403
|
+
res.on('end', () => {
|
|
404
|
+
if (res.statusCode === 200 || res.statusCode === 302) {
|
|
405
|
+
resolve({ success: true, error: null });
|
|
406
|
+
} else {
|
|
407
|
+
resolve({ success: false, error: `Callback returned ${res.statusCode}: ${data}` });
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
req.on('error', (err) => {
|
|
413
|
+
resolve({ success: false, error: `Callback error: ${err.message}` });
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
req.setTimeout(10000, () => {
|
|
417
|
+
req.destroy();
|
|
418
|
+
resolve({ success: false, error: 'Callback timeout' });
|
|
419
|
+
});
|
|
420
|
+
} catch (err) {
|
|
421
|
+
resolve({ success: false, error: `Invalid URL: ${err.message}` });
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
};
|
|
425
|
+
|
|
452
426
|
module.exports = {
|
|
453
427
|
CLIPROXY_VERSION,
|
|
454
428
|
INSTALL_DIR,
|
|
455
429
|
BINARY_PATH,
|
|
456
430
|
AUTH_DIR,
|
|
457
431
|
DEFAULT_PORT,
|
|
432
|
+
CALLBACK_PORT,
|
|
458
433
|
getDownloadUrl,
|
|
459
434
|
isInstalled,
|
|
435
|
+
isHeadless,
|
|
460
436
|
install,
|
|
461
437
|
isRunning,
|
|
462
438
|
start,
|
|
463
439
|
stop,
|
|
464
440
|
ensureRunning,
|
|
465
|
-
getLoginUrl
|
|
441
|
+
getLoginUrl,
|
|
442
|
+
processCallback
|
|
466
443
|
};
|
package/src/ui/index.js
CHANGED
|
@@ -26,10 +26,10 @@ const {
|
|
|
26
26
|
const { createBoxMenu } = require('./menu');
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* Display HQX Banner (without closing border)
|
|
30
|
-
*
|
|
29
|
+
* Display HQX Banner (without closing border by default)
|
|
30
|
+
* @param {boolean} closed - If true, add bottom border
|
|
31
31
|
*/
|
|
32
|
-
const displayBanner = () => {
|
|
32
|
+
const displayBanner = (closed = false) => {
|
|
33
33
|
const termWidth = process.stdout.columns || 100;
|
|
34
34
|
const isMobile = termWidth < 60;
|
|
35
35
|
const boxWidth = isMobile ? Math.max(termWidth - 2, 40) : Math.max(getLogoWidth(), 98);
|
|
@@ -72,6 +72,11 @@ const displayBanner = () => {
|
|
|
72
72
|
console.log(chalk.cyan('╠' + '═'.repeat(innerWidth) + '╣'));
|
|
73
73
|
const tagline = isMobile ? `HQX v${version}` : `Prop Futures Algo Trading v${version}`;
|
|
74
74
|
console.log(chalk.cyan('║') + chalk.white(centerText(tagline, innerWidth)) + chalk.cyan('║'));
|
|
75
|
+
|
|
76
|
+
// Close the box if requested
|
|
77
|
+
if (closed) {
|
|
78
|
+
console.log(chalk.cyan('╚' + '═'.repeat(innerWidth) + '╝'));
|
|
79
|
+
}
|
|
75
80
|
};
|
|
76
81
|
|
|
77
82
|
/**
|