hedgequantx 2.7.28 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedgequantx",
3
- "version": "2.7.28",
3
+ "version": "2.7.30",
4
4
  "description": "HedgeQuantX - Prop Futures Trading CLI",
5
5
  "main": "src/app.js",
6
6
  "bin": {
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', () => { restoreTerminal(); process.exit(0); });
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 spinner
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
- spinner.stop();
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] Exit', innerWidth)) + chalk.cyan('║'));
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('Select number (or X):'));
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('Goodbye!'));
257
+ console.log(chalk.gray('GOODBYE!'));
244
258
  process.exit(0);
245
259
  }
246
260
 
@@ -11,12 +11,18 @@ const path = require('path');
11
11
  const fs = require('fs');
12
12
  const ora = require('ora');
13
13
 
14
- const { getLogoWidth } = require('../ui');
14
+ const { getLogoWidth, displayBanner } = require('../ui');
15
15
  const { prompts } = require('../utils');
16
16
  const { fetchModelsFromApi } = require('./ai-models');
17
17
  const { drawProvidersTable, drawModelsTable, drawProviderWindow } = require('./ai-agents-ui');
18
18
  const cliproxy = require('../services/cliproxy');
19
19
 
20
+ /** Clear screen and show banner */
21
+ const clearWithBanner = () => {
22
+ console.clear();
23
+ displayBanner();
24
+ };
25
+
20
26
  // Config file path
21
27
  const CONFIG_DIR = path.join(os.homedir(), '.hqx');
22
28
  const CONFIG_FILE = path.join(CONFIG_DIR, 'ai-config.json');
@@ -57,10 +63,10 @@ const saveConfig = (config) => {
57
63
  /** Select a model from a pre-fetched list */
58
64
  const selectModelFromList = async (provider, models, boxWidth) => {
59
65
  while (true) {
60
- console.clear();
66
+ clearWithBanner();
61
67
  drawModelsTable(provider, models, boxWidth);
62
68
 
63
- const input = await prompts.textInput(chalk.cyan('Select model: '));
69
+ const input = await prompts.textInput(chalk.cyan('SELECT MODEL: '));
64
70
  const choice = (input || '').toLowerCase().trim();
65
71
 
66
72
  if (choice === 'b' || choice === '') return null;
@@ -68,7 +74,7 @@ const selectModelFromList = async (provider, models, boxWidth) => {
68
74
  const num = parseInt(choice);
69
75
  if (!isNaN(num) && num >= 1 && num <= models.length) return models[num - 1];
70
76
 
71
- console.log(chalk.red(' Invalid option.'));
77
+ console.log(chalk.red(' INVALID OPTION'));
72
78
  await new Promise(r => setTimeout(r, 1000));
73
79
  }
74
80
  };
@@ -76,16 +82,16 @@ const selectModelFromList = async (provider, models, boxWidth) => {
76
82
  /** Select a model for a provider (fetches from API) */
77
83
  const selectModel = async (provider, apiKey) => {
78
84
  const boxWidth = getLogoWidth();
79
- const spinner = ora({ text: 'Fetching models from API...', color: 'yellow' }).start();
85
+ const spinner = ora({ text: 'FETCHING MODELS FROM API...', color: 'yellow' }).start();
80
86
  const result = await fetchModelsFromApi(provider.id, apiKey);
81
87
 
82
88
  if (!result.success || result.models.length === 0) {
83
- spinner.fail(result.error || 'No models available');
89
+ spinner.fail(result.error || 'NO MODELS AVAILABLE');
84
90
  await prompts.waitForEnter();
85
91
  return null;
86
92
  }
87
93
 
88
- spinner.succeed(`Found ${result.models.length} models`);
94
+ spinner.succeed(`FOUND ${result.models.length} MODELS`);
89
95
  return selectModelFromList(provider, result.models, boxWidth);
90
96
  };
91
97
 
@@ -104,47 +110,47 @@ const handleCliProxyConnection = async (provider, config, boxWidth) => {
104
110
 
105
111
  // Check if CLIProxyAPI is installed
106
112
  if (!cliproxy.isInstalled()) {
107
- console.log(chalk.yellow(' CLIProxyAPI not installed. Installing...'));
108
- const spinner = ora({ text: 'Downloading CLIProxyAPI...', color: 'yellow' }).start();
113
+ console.log(chalk.yellow(' CLIPROXYAPI NOT INSTALLED. INSTALLING...'));
114
+ const spinner = ora({ text: 'DOWNLOADING CLIPROXYAPI...', color: 'yellow' }).start();
109
115
 
110
116
  const installResult = await cliproxy.install((msg, percent) => {
111
- spinner.text = `${msg} ${percent}%`;
117
+ spinner.text = `${msg.toUpperCase()} ${percent}%`;
112
118
  });
113
119
 
114
120
  if (!installResult.success) {
115
- spinner.fail(`Installation failed: ${installResult.error}`);
121
+ spinner.fail(`INSTALLATION FAILED: ${installResult.error.toUpperCase()}`);
116
122
  await prompts.waitForEnter();
117
123
  return false;
118
124
  }
119
- spinner.succeed('CLIProxyAPI installed');
125
+ spinner.succeed('CLIPROXYAPI INSTALLED');
120
126
  }
121
127
 
122
128
  // Check if running, start if not
123
129
  let status = await cliproxy.isRunning();
124
130
  if (!status.running) {
125
- const spinner = ora({ text: 'Starting CLIProxyAPI...', color: 'yellow' }).start();
131
+ const spinner = ora({ text: 'STARTING CLIPROXYAPI...', color: 'yellow' }).start();
126
132
  const startResult = await cliproxy.start();
127
133
 
128
134
  if (!startResult.success) {
129
- spinner.fail(`Failed to start: ${startResult.error}`);
135
+ spinner.fail(`FAILED TO START: ${startResult.error.toUpperCase()}`);
130
136
  await prompts.waitForEnter();
131
137
  return false;
132
138
  }
133
- spinner.succeed('CLIProxyAPI started');
139
+ spinner.succeed('CLIPROXYAPI STARTED');
134
140
  } else {
135
- console.log(chalk.green(' ✓ CLIProxyAPI is running'));
141
+ console.log(chalk.green(' ✓ CLIPROXYAPI IS RUNNING'));
136
142
  }
137
143
 
138
144
  // Check if provider supports OAuth
139
145
  const oauthProviders = ['anthropic', 'openai', 'google', 'qwen'];
140
146
  if (!oauthProviders.includes(provider.id)) {
141
147
  // Try to fetch models directly
142
- console.log(chalk.gray(` Checking available models for ${provider.name}...`));
148
+ console.log(chalk.gray(` CHECKING AVAILABLE MODELS FOR ${provider.name.toUpperCase()}...`));
143
149
  const modelsResult = await cliproxy.fetchProviderModels(provider.id);
144
150
 
145
151
  if (!modelsResult.success || modelsResult.models.length === 0) {
146
- console.log(chalk.red(` No models available for ${provider.name}`));
147
- console.log(chalk.gray(' This provider may require API key connection.'));
152
+ console.log(chalk.red(` NO MODELS AVAILABLE FOR ${provider.name.toUpperCase()}`));
153
+ console.log(chalk.gray(' THIS PROVIDER MAY REQUIRE API KEY CONNECTION.'));
148
154
  await prompts.waitForEnter();
149
155
  return false;
150
156
  }
@@ -159,28 +165,68 @@ const handleCliProxyConnection = async (provider, config, boxWidth) => {
159
165
  });
160
166
 
161
167
  if (saveConfig(config)) {
162
- console.log(chalk.green(`\n ✓ ${provider.name} connected via CLIProxy.`));
163
- console.log(chalk.cyan(` Model: ${selectedModel.name}`));
168
+ console.log(chalk.green(`\n ✓ ${provider.name.toUpperCase()} CONNECTED VIA CLIPROXY`));
169
+ console.log(chalk.cyan(` MODEL: ${selectedModel.name.toUpperCase()}`));
164
170
  }
165
171
  await prompts.waitForEnter();
166
172
  return true;
167
173
  }
168
174
 
169
175
  // OAuth flow - get login URL
170
- console.log(chalk.cyan(`\n Starting OAuth login for ${provider.name}...`));
176
+ console.log(chalk.cyan(`\n STARTING OAUTH LOGIN FOR ${provider.name.toUpperCase()}...`));
171
177
  const loginResult = await cliproxy.getLoginUrl(provider.id);
172
178
 
173
179
  if (!loginResult.success) {
174
- console.log(chalk.red(` OAuth error: ${loginResult.error}`));
180
+ console.log(chalk.red(` OAUTH ERROR: ${loginResult.error.toUpperCase()}`));
175
181
  await prompts.waitForEnter();
176
182
  return false;
177
183
  }
178
184
 
179
- console.log(chalk.cyan('\n Open this URL in your browser to authenticate:\n'));
185
+ console.log(chalk.cyan('\n OPEN THIS URL IN YOUR BROWSER TO AUTHENTICATE:\n'));
180
186
  console.log(chalk.yellow(` ${loginResult.url}\n`));
181
- console.log(chalk.gray(' After authenticating, press Enter to continue...'));
182
187
 
183
- await prompts.waitForEnter();
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
+ }
184
230
 
185
231
  // Try to fetch models after auth
186
232
  const modelsResult = await cliproxy.fetchProviderModels(provider.id);
@@ -194,8 +240,8 @@ const handleCliProxyConnection = async (provider, config, boxWidth) => {
194
240
  modelName: selectedModel.name
195
241
  });
196
242
  if (saveConfig(config)) {
197
- console.log(chalk.green(`\n ✓ ${provider.name} connected via Paid Plan.`));
198
- console.log(chalk.cyan(` Model: ${selectedModel.name}`));
243
+ console.log(chalk.green(`\n ✓ ${provider.name.toUpperCase()} CONNECTED VIA PAID PLAN`));
244
+ console.log(chalk.cyan(` MODEL: ${selectedModel.name.toUpperCase()}`));
199
245
  }
200
246
  }
201
247
  } else {
@@ -203,10 +249,10 @@ const handleCliProxyConnection = async (provider, config, boxWidth) => {
203
249
  activateProvider(config, provider.id, {
204
250
  connectionType: 'cliproxy',
205
251
  modelId: null,
206
- modelName: 'Auto'
252
+ modelName: 'AUTO'
207
253
  });
208
254
  if (saveConfig(config)) {
209
- console.log(chalk.green(`\n ✓ ${provider.name} connected via Paid Plan.`));
255
+ console.log(chalk.green(`\n ✓ ${provider.name.toUpperCase()} CONNECTED VIA PAID PLAN`));
210
256
  }
211
257
  }
212
258
 
@@ -216,20 +262,20 @@ const handleCliProxyConnection = async (provider, config, boxWidth) => {
216
262
 
217
263
  /** Handle API Key connection */
218
264
  const handleApiKeyConnection = async (provider, config) => {
219
- console.clear();
220
- console.log(chalk.yellow(`\n Enter your ${provider.name} API key:`));
221
- console.log(chalk.gray(' (Press Enter to cancel)\n'));
265
+ clearWithBanner();
266
+ console.log(chalk.yellow(`\n ENTER YOUR ${provider.name.toUpperCase()} API KEY:`));
267
+ console.log(chalk.gray(' (PRESS ENTER TO CANCEL)\n'));
222
268
 
223
- const apiKey = await prompts.textInput(chalk.cyan(' API Key: '), true);
269
+ const apiKey = await prompts.textInput(chalk.cyan(' API KEY: '), true);
224
270
 
225
271
  if (!apiKey || apiKey.trim() === '') {
226
- console.log(chalk.gray(' Cancelled.'));
272
+ console.log(chalk.gray(' CANCELLED'));
227
273
  await prompts.waitForEnter();
228
274
  return false;
229
275
  }
230
276
 
231
277
  if (apiKey.length < 20) {
232
- console.log(chalk.red(' Invalid API key format (too short).'));
278
+ console.log(chalk.red(' INVALID API KEY FORMAT (TOO SHORT)'));
233
279
  await prompts.waitForEnter();
234
280
  return false;
235
281
  }
@@ -245,10 +291,10 @@ const handleApiKeyConnection = async (provider, config) => {
245
291
  });
246
292
 
247
293
  if (saveConfig(config)) {
248
- console.log(chalk.green(`\n ✓ ${provider.name} connected via API Key.`));
249
- console.log(chalk.cyan(` Model: ${selectedModel.name}`));
294
+ console.log(chalk.green(`\n ✓ ${provider.name.toUpperCase()} CONNECTED VIA API KEY`));
295
+ console.log(chalk.cyan(` MODEL: ${selectedModel.name.toUpperCase()}`));
250
296
  } else {
251
- console.log(chalk.red('\n Failed to save config.'));
297
+ console.log(chalk.red('\n FAILED TO SAVE CONFIG'));
252
298
  }
253
299
  await prompts.waitForEnter();
254
300
  return true;
@@ -259,10 +305,10 @@ const handleProviderConfig = async (provider, config) => {
259
305
  const boxWidth = getLogoWidth();
260
306
 
261
307
  while (true) {
262
- console.clear();
308
+ clearWithBanner();
263
309
  drawProviderWindow(provider, config, boxWidth);
264
310
 
265
- const input = await prompts.textInput(chalk.cyan('Select option: '));
311
+ const input = await prompts.textInput(chalk.cyan('SELECT OPTION: '));
266
312
  const choice = (input || '').toLowerCase().trim();
267
313
 
268
314
  if (choice === 'b' || choice === '') break;
@@ -270,7 +316,7 @@ const handleProviderConfig = async (provider, config) => {
270
316
  if (choice === 'd' && config.providers[provider.id]) {
271
317
  config.providers[provider.id].active = false;
272
318
  saveConfig(config);
273
- console.log(chalk.yellow(`\n ${provider.name} disconnected.`));
319
+ console.log(chalk.yellow(`\n ${provider.name.toUpperCase()} DISCONNECTED`));
274
320
  await prompts.waitForEnter();
275
321
  continue;
276
322
  }
@@ -313,18 +359,18 @@ const getActiveAgentCount = () => getActiveProvider() ? 1 : 0;
313
359
 
314
360
  /** Show CLIProxy status */
315
361
  const showCliProxyStatus = async () => {
316
- console.clear();
317
- console.log(chalk.yellow('\n CLIProxyAPI Status\n'));
362
+ clearWithBanner();
363
+ console.log(chalk.yellow('\n CLIPROXYAPI STATUS\n'));
318
364
 
319
365
  const installed = cliproxy.isInstalled();
320
- console.log(chalk.gray(' Installed: ') + (installed ? chalk.green('Yes') : chalk.red('No')));
366
+ console.log(chalk.gray(' INSTALLED: ') + (installed ? chalk.green('YES') : chalk.red('NO')));
321
367
 
322
368
  if (installed) {
323
369
  const status = await cliproxy.isRunning();
324
- console.log(chalk.gray(' Running: ') + (status.running ? chalk.green('Yes') : chalk.red('No')));
325
- console.log(chalk.gray(' Version: ') + chalk.cyan(cliproxy.CLIPROXY_VERSION));
326
- console.log(chalk.gray(' Port: ') + chalk.cyan(cliproxy.DEFAULT_PORT));
327
- console.log(chalk.gray(' Install dir: ') + chalk.cyan(cliproxy.INSTALL_DIR));
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));
328
374
  }
329
375
 
330
376
  console.log();
@@ -337,12 +383,12 @@ const aiAgentsMenu = async () => {
337
383
  const boxWidth = getLogoWidth();
338
384
 
339
385
  while (true) {
340
- console.clear();
386
+ clearWithBanner();
341
387
  const status = await cliproxy.isRunning();
342
- const statusText = status.running ? `localhost:${cliproxy.DEFAULT_PORT}` : 'Not running';
388
+ const statusText = status.running ? `LOCALHOST:${cliproxy.DEFAULT_PORT}` : 'NOT RUNNING';
343
389
  drawProvidersTable(AI_PROVIDERS, config, boxWidth, statusText);
344
390
 
345
- const input = await prompts.textInput(chalk.cyan('Select (1-8/S/B): '));
391
+ const input = await prompts.textInput(chalk.cyan('SELECT (1-8/S/B): '));
346
392
  const choice = (input || '').toLowerCase().trim();
347
393
 
348
394
  if (choice === 'b' || choice === '') break;
@@ -358,7 +404,7 @@ const aiAgentsMenu = async () => {
358
404
  continue;
359
405
  }
360
406
 
361
- console.log(chalk.red(' Invalid option.'));
407
+ console.log(chalk.red(' INVALID OPTION'));
362
408
  await new Promise(r => setTimeout(r, 1000));
363
409
  }
364
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 { createGunzip } = require('zlib');
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,48 +314,112 @@ 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, error }
317
+ * @returns {Promise<Object>} { success, url, childProcess, isHeadless, error }
392
318
  */
393
319
  const getLoginUrl = async (provider) => {
394
320
  const providerFlags = {
395
- anthropic: '--claude-login',
396
- openai: '--codex-login',
397
- google: '--gemini-login',
398
- qwen: '--qwen-login'
321
+ anthropic: '-claude-login',
322
+ openai: '-codex-login',
323
+ google: '-gemini-login',
324
+ qwen: '-qwen-login'
399
325
  };
400
326
 
401
327
  const flag = providerFlags[provider];
402
328
  if (!flag) {
403
- return { success: false, url: 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
 
406
- // For headless/VPS, use --no-browser flag
332
+ const headless = isHeadless();
333
+
334
+ // For headless/VPS, use -no-browser flag
407
335
  return new Promise((resolve) => {
408
- const args = [flag, '--no-browser'];
336
+ const args = [flag, '-no-browser'];
409
337
  const child = spawn(BINARY_PATH, args, {
410
- cwd: INSTALL_DIR,
411
- env: { ...process.env, AUTH_DIR: AUTH_DIR }
338
+ cwd: INSTALL_DIR
412
339
  });
413
340
 
414
341
  let output = '';
342
+ let resolved = false;
343
+
344
+ const checkForUrl = () => {
345
+ if (resolved) return;
346
+ const urlMatch = output.match(/https?:\/\/[^\s]+/);
347
+ if (urlMatch) {
348
+ resolved = true;
349
+ // Return child process so caller can wait for auth completion
350
+ resolve({ success: true, url: urlMatch[0], childProcess: child, isHeadless: headless, error: null });
351
+ }
352
+ };
415
353
 
416
354
  child.stdout.on('data', (data) => {
417
355
  output += data.toString();
356
+ checkForUrl();
418
357
  });
419
358
 
420
359
  child.stderr.on('data', (data) => {
421
360
  output += data.toString();
361
+ checkForUrl();
422
362
  });
423
363
 
424
- // Look for URL in output
425
- setTimeout(() => {
426
- const urlMatch = output.match(/https?:\/\/[^\s]+/);
427
- if (urlMatch) {
428
- resolve({ success: true, url: urlMatch[0], error: null });
429
- } else {
430
- resolve({ success: false, url: null, error: 'Could not get login URL' });
364
+ child.on('error', (err) => {
365
+ if (!resolved) {
366
+ resolved = true;
367
+ resolve({ success: false, url: null, childProcess: null, isHeadless: headless, error: err.message });
431
368
  }
432
- }, 3000);
369
+ });
370
+
371
+ child.on('close', (code) => {
372
+ if (!resolved) {
373
+ resolved = true;
374
+ resolve({ success: false, url: null, childProcess: null, isHeadless: headless, error: `Process exited with code ${code}` });
375
+ }
376
+ });
377
+ });
378
+ };
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
+ }
433
423
  });
434
424
  };
435
425
 
@@ -439,12 +429,15 @@ module.exports = {
439
429
  BINARY_PATH,
440
430
  AUTH_DIR,
441
431
  DEFAULT_PORT,
432
+ CALLBACK_PORT,
442
433
  getDownloadUrl,
443
434
  isInstalled,
435
+ isHeadless,
444
436
  install,
445
437
  isRunning,
446
438
  start,
447
439
  stop,
448
440
  ensureRunning,
449
- getLoginUrl
441
+ getLoginUrl,
442
+ processCallback
450
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
- * Note: console.clear() is handled by app.js banner() to avoid terminal bugs
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
  /**