hedgequantx 2.7.29 → 2.7.31

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.29",
3
+ "version": "2.7.31",
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
 
@@ -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('Select model: '));
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(' Invalid option.'));
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: 'Fetching models from API...', color: 'yellow' }).start();
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 || 'No models available');
89
+ spinner.fail(result.error || 'NO MODELS AVAILABLE');
90
90
  await prompts.waitForEnter();
91
91
  return null;
92
92
  }
93
93
 
94
- spinner.succeed(`Found ${result.models.length} models`);
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(' CLIProxyAPI not installed. Installing...'));
114
- 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();
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(`Installation failed: ${installResult.error}`);
121
+ spinner.fail(`INSTALLATION FAILED: ${installResult.error.toUpperCase()}`);
122
122
  await prompts.waitForEnter();
123
123
  return false;
124
124
  }
125
- spinner.succeed('CLIProxyAPI installed');
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: 'Starting CLIProxyAPI...', color: 'yellow' }).start();
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(`Failed to start: ${startResult.error}`);
135
+ spinner.fail(`FAILED TO START: ${startResult.error.toUpperCase()}`);
136
136
  await prompts.waitForEnter();
137
137
  return false;
138
138
  }
139
- spinner.succeed('CLIProxyAPI started');
139
+ spinner.succeed('CLIPROXYAPI STARTED');
140
140
  } else {
141
- console.log(chalk.green(' ✓ CLIProxyAPI is running'));
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(` Checking available models for ${provider.name}...`));
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(` No models available for ${provider.name}`));
153
- 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.'));
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} connected via CLIProxy.`));
169
- 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()}`));
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 Starting OAuth login for ${provider.name}...`));
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(` OAuth error: ${loginResult.error}`));
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 Open this URL in your browser to authenticate:\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
- 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
+ }
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} connected via Paid Plan.`));
204
- 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()}`));
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: 'Auto'
252
+ modelName: 'AUTO'
213
253
  });
214
254
  if (saveConfig(config)) {
215
- console.log(chalk.green(`\n ✓ ${provider.name} connected via Paid Plan.`));
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 Enter your ${provider.name} API key:`));
227
- console.log(chalk.gray(' (Press Enter to cancel)\n'));
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 Key: '), true);
269
+ const apiKey = await prompts.textInput(chalk.cyan(' API KEY: '), true);
230
270
 
231
271
  if (!apiKey || apiKey.trim() === '') {
232
- console.log(chalk.gray(' Cancelled.'));
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(' Invalid API key format (too short).'));
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} connected via API Key.`));
255
- 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()}`));
256
296
  } else {
257
- console.log(chalk.red('\n Failed to save config.'));
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('Select option: '));
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} disconnected.`));
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 CLIProxyAPI Status\n'));
363
+ console.log(chalk.yellow('\n CLIPROXYAPI STATUS\n'));
324
364
 
325
365
  const installed = cliproxy.isInstalled();
326
- 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')));
327
367
 
328
368
  if (installed) {
329
369
  const status = await cliproxy.isRunning();
330
- console.log(chalk.gray(' Running: ') + (status.running ? chalk.green('Yes') : chalk.red('No')));
331
- console.log(chalk.gray(' Version: ') + chalk.cyan(cliproxy.CLIPROXY_VERSION));
332
- console.log(chalk.gray(' Port: ') + chalk.cyan(cliproxy.DEFAULT_PORT));
333
- 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));
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 ? `localhost:${cliproxy.DEFAULT_PORT}` : 'Not 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('Select (1-8/S/B): '));
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(' Invalid option.'));
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 { 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,52 @@ 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 browser access)
32
+ * @returns {boolean}
33
+ */
34
+ const isHeadless = () => {
35
+ // 1. SSH connection = definitely VPS/remote
36
+ if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION) {
37
+ return true;
38
+ }
39
+
40
+ // 2. Docker/container environment
41
+ if (process.env.DOCKER_CONTAINER || process.env.KUBERNETES_SERVICE_HOST) {
42
+ return true;
43
+ }
44
+
45
+ // 3. Common CI/CD environments
46
+ if (process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI) {
47
+ return true;
48
+ }
49
+
50
+ // 4. Check for display on Linux
51
+ if (process.platform === 'linux') {
52
+ // Has display = local with GUI
53
+ if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) {
54
+ return false;
55
+ }
56
+ // No display on Linux = likely headless/VPS
57
+ return true;
58
+ }
59
+
60
+ // 5. macOS - check if running in terminal with GUI access
61
+ if (process.platform === 'darwin') {
62
+ // macOS always has GUI unless SSH (checked above)
63
+ return false;
64
+ }
65
+
66
+ // 6. Windows - usually has GUI
67
+ if (process.platform === 'win32') {
68
+ return false;
69
+ }
70
+
71
+ // Default: assume local
72
+ return false;
73
+ };
30
74
 
31
75
  /**
32
76
  * Get download URL for current platform
@@ -73,101 +117,6 @@ const isInstalled = () => {
73
117
  return fs.existsSync(BINARY_PATH);
74
118
  };
75
119
 
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
120
 
172
121
  /**
173
122
  * Install CLIProxyAPI
@@ -388,7 +337,7 @@ const ensureRunning = async (onProgress = null) => {
388
337
  /**
389
338
  * Get OAuth login URL for a provider
390
339
  * @param {string} provider - Provider ID (anthropic, openai, google, etc.)
391
- * @returns {Promise<Object>} { success, url, childProcess, error }
340
+ * @returns {Promise<Object>} { success, url, childProcess, isHeadless, error }
392
341
  */
393
342
  const getLoginUrl = async (provider) => {
394
343
  const providerFlags = {
@@ -400,9 +349,11 @@ const getLoginUrl = async (provider) => {
400
349
 
401
350
  const flag = providerFlags[provider];
402
351
  if (!flag) {
403
- return { success: false, url: null, childProcess: null, error: 'Provider not supported for OAuth' };
352
+ return { success: false, url: null, childProcess: null, isHeadless: false, error: 'Provider not supported for OAuth' };
404
353
  }
405
354
 
355
+ const headless = isHeadless();
356
+
406
357
  // For headless/VPS, use -no-browser flag
407
358
  return new Promise((resolve) => {
408
359
  const args = [flag, '-no-browser'];
@@ -419,7 +370,7 @@ const getLoginUrl = async (provider) => {
419
370
  if (urlMatch) {
420
371
  resolved = true;
421
372
  // Return child process so caller can wait for auth completion
422
- resolve({ success: true, url: urlMatch[0], childProcess: child, error: null });
373
+ resolve({ success: true, url: urlMatch[0], childProcess: child, isHeadless: headless, error: null });
423
374
  }
424
375
  };
425
376
 
@@ -436,31 +387,80 @@ const getLoginUrl = async (provider) => {
436
387
  child.on('error', (err) => {
437
388
  if (!resolved) {
438
389
  resolved = true;
439
- resolve({ success: false, url: null, childProcess: null, error: err.message });
390
+ resolve({ success: false, url: null, childProcess: null, isHeadless: headless, error: err.message });
440
391
  }
441
392
  });
442
393
 
443
394
  child.on('close', (code) => {
444
395
  if (!resolved) {
445
396
  resolved = true;
446
- resolve({ success: false, url: null, childProcess: null, error: `Process exited with code ${code}` });
397
+ resolve({ success: false, url: null, childProcess: null, isHeadless: headless, error: `Process exited with code ${code}` });
447
398
  }
448
399
  });
449
400
  });
450
401
  };
451
402
 
403
+ /**
404
+ * Process OAuth callback URL manually (for VPS/headless)
405
+ * The callback URL looks like: http://localhost:54545/callback?code=xxx&state=yyy
406
+ * We need to forward this to the waiting CLIProxyAPI process
407
+ * @param {string} callbackUrl - The callback URL from the browser
408
+ * @returns {Promise<Object>} { success, error }
409
+ */
410
+ const processCallback = (callbackUrl) => {
411
+ return new Promise((resolve) => {
412
+ try {
413
+ // Parse the callback URL
414
+ const url = new URL(callbackUrl);
415
+ const params = url.searchParams;
416
+
417
+ // Extract query string to forward
418
+ const queryString = url.search;
419
+
420
+ // Make request to local callback endpoint
421
+ const callbackPath = `/callback${queryString}`;
422
+
423
+ const req = http.get(`http://127.0.0.1:${CALLBACK_PORT}${callbackPath}`, (res) => {
424
+ let data = '';
425
+ res.on('data', chunk => data += chunk);
426
+ res.on('end', () => {
427
+ if (res.statusCode === 200 || res.statusCode === 302) {
428
+ resolve({ success: true, error: null });
429
+ } else {
430
+ resolve({ success: false, error: `Callback returned ${res.statusCode}: ${data}` });
431
+ }
432
+ });
433
+ });
434
+
435
+ req.on('error', (err) => {
436
+ resolve({ success: false, error: `Callback error: ${err.message}` });
437
+ });
438
+
439
+ req.setTimeout(10000, () => {
440
+ req.destroy();
441
+ resolve({ success: false, error: 'Callback timeout' });
442
+ });
443
+ } catch (err) {
444
+ resolve({ success: false, error: `Invalid URL: ${err.message}` });
445
+ }
446
+ });
447
+ };
448
+
452
449
  module.exports = {
453
450
  CLIPROXY_VERSION,
454
451
  INSTALL_DIR,
455
452
  BINARY_PATH,
456
453
  AUTH_DIR,
457
454
  DEFAULT_PORT,
455
+ CALLBACK_PORT,
458
456
  getDownloadUrl,
459
457
  isInstalled,
458
+ isHeadless,
460
459
  install,
461
460
  isRunning,
462
461
  start,
463
462
  stop,
464
463
  ensureRunning,
465
- getLoginUrl
464
+ getLoginUrl,
465
+ processCallback
466
466
  };
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
  /**