@miraj181/ipingyou 2.0.14 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,11 +21,12 @@ import os from 'node:os';
21
21
  import { cleanupAll, trackPID, untrackPID, addCleanupHook } from '../lib/cleanup.js';
22
22
  import { createSpinner, sshSpinner, networkSpinner, fileTransferSpinner, showConnectionTrace, simulateTransferProgress } from '../lib/animations.js';
23
23
  import { getConfig, saveAlias } from '../lib/config.js';
24
- import { pushTelemetry, resolveUID } from '../lib/broker.js';
24
+ import { pushTelemetry, requestHostApproval, resolveUID, revokeUID, waitForApproval } from '../lib/broker.js';
25
25
  import { calculateChecksum } from '../lib/checksum.js';
26
26
  import { promptLocalPath, promptRemotePath } from '../lib/path-browser.js';
27
27
  import { buildSshArgs, extractHostname, formatScpRemotePath, getSshControlOptions, quoteRemoteShell } from '../lib/ssh.js';
28
28
  import open from 'open';
29
+ import { cleanupSessionLog, initSessionLog, logSessionEvent, recordEvent } from '../lib/session-log.js';
29
30
 
30
31
  let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
31
32
 
@@ -57,7 +58,7 @@ async function writeEphemeralPrivateKey(privateKey) {
57
58
  });
58
59
 
59
60
  if (result.exitCode !== 0) {
60
- try { fs.unlinkSync(keyPath); } catch {}
61
+ try { fs.unlinkSync(keyPath); } catch { }
61
62
  throw new Error(result.stderr.trim() || 'OpenSSH could not parse the host-provided private key');
62
63
  }
63
64
 
@@ -100,12 +101,15 @@ async function connectSSH(username, hostname, privateKeyPath) {
100
101
  if (result.exitCode === 0) {
101
102
  console.log('');
102
103
  console.log(chalk.green(' ✅ SSH session ended cleanly'));
104
+ recordEvent('ssh_session_ended', { hostname, exitCode: 0 });
103
105
  } else if (result.exitCode === 255) {
104
106
  console.log('');
105
107
  console.error(chalk.red(' ❌ SSH connection failed (exit code 255)'));
108
+ recordEvent('ssh_session_failed', { hostname, exitCode: 255 });
106
109
  } else {
107
110
  console.log('');
108
111
  console.error(chalk.red(` ❌ SSH exited with code ${result.exitCode}`));
112
+ recordEvent('ssh_session_ended', { hostname, exitCode: result.exitCode });
109
113
  }
110
114
  } catch (err) {
111
115
  console.error(chalk.red(` ❌ SSH error: ${err.message}`));
@@ -115,7 +119,48 @@ async function connectSSH(username, hostname, privateKeyPath) {
115
119
  /**
116
120
  * Perform an SCP file transfer through the Cloudflare tunnel.
117
121
  */
118
- async function performSCP(username, hostname, direction, privateKeyPath) {
122
+ async function chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath) {
123
+ if (!sharedDropPath) {
124
+ return promptRemotePath(username, hostname, privateKeyPath, direction === 'upload' ? 'destination' : 'source');
125
+ }
126
+
127
+ const { dropChoice } = await inquirer.prompt([{
128
+ type: 'list',
129
+ name: 'dropChoice',
130
+ message: direction === 'upload'
131
+ ? 'Where should the file/folder go on the host?'
132
+ : 'Where should browsing start on the host?',
133
+ choices: direction === 'upload'
134
+ ? [
135
+ { name: `📥 Use host shared drop folder (${sharedDropPath})`, value: 'drop' },
136
+ { name: '🔍 Browse host folders', value: 'browse' },
137
+ { name: '⌨️ Type host destination path manually', value: 'manual' }
138
+ ]
139
+ : [
140
+ { name: `📥 Start in host shared drop folder (${sharedDropPath})`, value: 'drop_browse' },
141
+ { name: '🔍 Browse from host home folder', value: 'browse' },
142
+ { name: '⌨️ Type host file/folder path manually', value: 'manual' }
143
+ ]
144
+ }]);
145
+
146
+ if (dropChoice === 'drop') return sharedDropPath;
147
+ if (dropChoice === 'drop_browse') {
148
+ return promptRemotePath(username, hostname, privateKeyPath, 'source', sharedDropPath);
149
+ }
150
+ if (dropChoice === 'manual') {
151
+ const { remotePath } = await inquirer.prompt([{
152
+ type: 'input',
153
+ name: 'remotePath',
154
+ message: direction === 'upload' ? 'Host destination path:' : 'Host file/folder path:',
155
+ validate: v => v.trim().length > 0 || 'Required',
156
+ }]);
157
+ return remotePath.trim();
158
+ }
159
+
160
+ return promptRemotePath(username, hostname, privateKeyPath, direction === 'upload' ? 'destination' : 'source');
161
+ }
162
+
163
+ async function performSCP(username, hostname, direction, privateKeyPath, sharedDropPath = null) {
119
164
  console.log('');
120
165
  console.log(chalk.bold(` 📦 SCP Transfer (${direction})`));
121
166
  console.log(chalk.dim(' ─────────────────────────────────'));
@@ -124,11 +169,11 @@ async function performSCP(username, hostname, direction, privateKeyPath) {
124
169
  let remotePath;
125
170
 
126
171
  if (direction === 'upload') {
127
- remotePath = await promptRemotePath(username, hostname, privateKeyPath, 'destination');
172
+ remotePath = await chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath);
128
173
  localPath = await promptLocalPath('client file/folder to upload');
129
174
  } else {
130
175
  localPath = await promptLocalPath('client destination');
131
- remotePath = await promptRemotePath(username, hostname, privateKeyPath, 'source');
176
+ remotePath = await chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath);
132
177
  }
133
178
 
134
179
  await showConnectionTrace('Local', 'Remote SCP');
@@ -179,7 +224,7 @@ async function performSCP(username, hostname, direction, privateKeyPath) {
179
224
 
180
225
  if (result.exitCode === 0) {
181
226
  await simulateTransferProgress(direction === 'upload' ? localPath : remotePath, direction, 1500);
182
-
227
+
183
228
  // Verify Checksum
184
229
  if (direction === 'upload' && localHash) {
185
230
  console.log(chalk.dim(' 🔍 Verifying remote SHA-256 checksum...'));
@@ -193,10 +238,10 @@ async function performSCP(username, hostname, direction, privateKeyPath) {
193
238
  ];
194
239
  if (privateKeyPath) sshArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
195
240
  sshArgs.push(`${username}@${hostname}`, `shasum -a 256 ${quoteRemoteShell(remoteChecksumPath)} 2>/dev/null || sha256sum ${quoteRemoteShell(remoteChecksumPath)} 2>/dev/null || shasum -a 256 ${quoteRemoteShell(remotePath)} 2>/dev/null || sha256sum ${quoteRemoteShell(remotePath)}`);
196
-
241
+
197
242
  const { stdout } = await execa('ssh', sshArgs, { reject: false });
198
243
  const remoteHash = stdout.split(' ')[0].trim();
199
-
244
+
200
245
  if (remoteHash === localHash) {
201
246
  console.log(chalk.green(` ✅ Zero-Trust File Integrity: Hash match (${remoteHash.substring(0, 16)}...)`));
202
247
  } else {
@@ -212,15 +257,33 @@ async function performSCP(username, hostname, direction, privateKeyPath) {
212
257
  }
213
258
 
214
259
  console.log(chalk.green(` ✅ Transfer completed successfully!`));
260
+ recordEvent('scp_transfer_success', { direction, localPath, remotePath, hostname });
215
261
  } else {
216
262
  console.error(chalk.red(' ❌ SCP transfer failed'));
217
263
  if (result.stderr) console.error(chalk.dim(` ${result.stderr.trim()}`));
264
+ recordEvent('scp_transfer_failed', { direction, localPath, remotePath, hostname, error: result.stderr });
218
265
  }
219
266
  } catch (err) {
220
267
  console.error(chalk.red(` ❌ SCP error: ${err.message}`));
221
268
  }
222
269
  }
223
270
 
271
+ async function downloadSpecificRemotePath(username, hostname, privateKeyPath, remotePath, localPath) {
272
+ await showConnectionTrace('Local', 'Remote SCP');
273
+ const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
274
+ const scpArgs = [
275
+ '-r',
276
+ '-o', `ProxyCommand=${proxyCommand}`,
277
+ '-o', 'StrictHostKeyChecking=accept-new',
278
+ '-o', 'IdentitiesOnly=yes',
279
+ ...getSshControlOptions(hostname),
280
+ ];
281
+ if (privateKeyPath) scpArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
282
+ scpArgs.push(`${username}@${hostname}:${formatScpRemotePath(remotePath)}`, localPath);
283
+ const result = await execa('scp', scpArgs, { stdio: 'inherit', reject: false });
284
+ return result.exitCode === 0;
285
+ }
286
+
224
287
  function joinRemotePath(parent, child) {
225
288
  const cleanParent = String(parent || '').replace(/\/+$/, '');
226
289
  if (!cleanParent) return child;
@@ -237,6 +300,13 @@ export async function startClientMode(options = {}) {
237
300
  console.log(chalk.dim(' ──────────────────────────────────────────'));
238
301
  console.log('');
239
302
 
303
+ const sessionLogPath = initSessionLog('client');
304
+ if (sessionLogPath) {
305
+ console.log(chalk.dim(` 📜 Session log: ${sessionLogPath}`));
306
+ addCleanupHook(() => cleanupSessionLog());
307
+ }
308
+ logSessionEvent('client_mode_started');
309
+
240
310
  // Allow setting a custom broker URL if process.env isn't overridden by CLI
241
311
  if (process.env.BROKER_URL) {
242
312
  BROKER_URL = process.env.BROKER_URL;
@@ -264,11 +334,12 @@ export async function startClientMode(options = {}) {
264
334
  BROKER_URL = customBroker.trim();
265
335
  process.env.BROKER_URL = BROKER_URL; // Update for consistency
266
336
  }
337
+ logSessionEvent('client_broker_selected', { choice: brokerChoice, broker: BROKER_URL });
267
338
  }
268
339
 
269
340
  const config = getConfig();
270
341
  const aliasKeys = Object.keys(config.aliases || {});
271
-
342
+
272
343
  let targetUid = null;
273
344
  let targetPassword = null;
274
345
  let targetUsername = null;
@@ -291,6 +362,7 @@ export async function startClientMode(options = {}) {
291
362
  targetUid = aliasData.uid;
292
363
  targetPassword = aliasData.password;
293
364
  targetUsername = aliasData.username;
365
+ logSessionEvent('client_alias_selected', { alias: useAlias, uid: targetUid });
294
366
  }
295
367
  }
296
368
 
@@ -303,6 +375,7 @@ export async function startClientMode(options = {}) {
303
375
  validate: (v) => v.trim().length > 0 || 'Password is required to decrypt',
304
376
  }]);
305
377
  targetPassword = password.trim();
378
+ logSessionEvent('client_uid_provided', { uid: targetUid });
306
379
  }
307
380
 
308
381
  if (!targetUid) {
@@ -327,10 +400,61 @@ export async function startClientMode(options = {}) {
327
400
  ]);
328
401
  targetUid = answer.uid.trim();
329
402
  targetPassword = answer.password.trim();
403
+ logSessionEvent('client_uid_provided', { uid: targetUid });
404
+ }
405
+
406
+ let payload = await resolveUID(BROKER_URL, targetUid, targetPassword);
407
+
408
+ // ─── Approval Flow ──────────────────────────────────────────
409
+ if (payload && payload.needsApproval) {
410
+ console.log('');
411
+ console.log(chalk.bold.yellow(' 🔐 Host Approval Required'));
412
+ console.log(chalk.dim(' ──────────────────────────────────────'));
413
+ console.log(chalk.dim(' The host has enabled approval gating. Submitting your access request...'));
414
+
415
+ try {
416
+ const approvalDetails = {
417
+ username: os.userInfo().username,
418
+ hostname: os.hostname(),
419
+ os: `${os.type()} ${os.release()} (${os.arch()})`,
420
+ intent: 'connect',
421
+ time: new Date().toISOString(),
422
+ };
423
+
424
+ const { requestId, status: reqStatus, approvalRequired } = await requestHostApproval(
425
+ BROKER_URL, targetUid, targetPassword, approvalDetails
426
+ );
427
+
428
+ logSessionEvent('client_approval_submitted', { uid: targetUid, requestId });
429
+
430
+ if (!approvalRequired || reqStatus === 'approved') {
431
+ console.log(chalk.green(' ✅ Access auto-approved (host does not require manual approval for this session).'));
432
+ payload = await resolveUID(BROKER_URL, targetUid, targetPassword, false, requestId);
433
+ } else {
434
+ console.log(chalk.yellow(` ⏳ Waiting for host to approve your request (ID: ${requestId})...`));
435
+ console.log(chalk.dim(' This may take a few minutes. Press Ctrl+C to cancel.'));
436
+ console.log('');
437
+
438
+ const approved = await waitForApproval(BROKER_URL, targetUid, requestId, 300000);
439
+
440
+ if (approved) {
441
+ console.log(chalk.green(' ✅ Host approved your access request!'));
442
+ logSessionEvent('client_approval_granted', { uid: targetUid, requestId });
443
+ payload = await resolveUID(BROKER_URL, targetUid, targetPassword, false, requestId);
444
+ } else {
445
+ console.log(chalk.red(' ❌ Host denied your access request.'));
446
+ logSessionEvent('client_approval_denied', { uid: targetUid, requestId });
447
+ process.exit(1);
448
+ }
449
+ }
450
+ } catch (err) {
451
+ console.log(chalk.red(` ❌ Approval flow failed: ${err.message}`));
452
+ logSessionEvent('client_approval_error', { uid: targetUid, error: err.message }, 'error');
453
+ process.exit(1);
454
+ }
330
455
  }
331
456
 
332
- const payload = await resolveUID(BROKER_URL, targetUid, targetPassword);
333
- if (!payload) {
457
+ if (!payload || payload.needsApproval) {
334
458
  process.exit(1);
335
459
  }
336
460
 
@@ -338,9 +462,17 @@ export async function startClientMode(options = {}) {
338
462
  console.log('');
339
463
  console.log(chalk.bold(' 🌐 HTTP Service Exposed'));
340
464
  console.log(chalk.dim(' ─────────────────────────────────'));
341
- console.log(chalk.green(` Open this URL in your browser:\n 👉 ${chalk.bold.cyan(payload.url)}`));
465
+ console.log(chalk.green(` Opening in browser: ${chalk.bold.cyan(payload.url)}`));
342
466
  console.log('');
343
- process.exit(0);
467
+ logSessionEvent('client_http_mode', { uid: targetUid, port: payload.port || null });
468
+ try {
469
+ await open(payload.url);
470
+ } catch {
471
+ console.log(chalk.dim(` Could not auto-open. Please visit: ${payload.url}`));
472
+ }
473
+ console.log(chalk.dim(' Press Ctrl+C to exit.'));
474
+ // Keep process alive so cleanup handlers work
475
+ await new Promise(() => {});
344
476
  }
345
477
 
346
478
  if (payload.type === 'tcp') {
@@ -348,12 +480,40 @@ export async function startClientMode(options = {}) {
348
480
  console.log(chalk.bold(' 🔌 Custom TCP Port Exposed'));
349
481
  console.log(chalk.dim(' ─────────────────────────────────'));
350
482
  console.log(` The host is exposing a generic TCP service on port ${payload.port}.`);
351
- console.log(` To connect, run this command in a separate terminal:`);
352
- console.log(chalk.cyan(` cloudflared access tcp --hostname ${extractHostname(payload.url)} --url 127.0.0.1:${payload.port}`));
353
- console.log('');
354
- console.log(` Then connect your local client (e.g. Postgres, VNC, RDP) to ${chalk.green('127.0.0.1:' + payload.port)}`);
483
+
484
+ const hostname = extractHostname(payload.url);
485
+ const localPort = payload.port;
486
+
487
+ console.log(chalk.dim(` Starting local cloudflared proxy to ${hostname}...`));
355
488
  console.log('');
356
- process.exit(0);
489
+
490
+ try {
491
+ const { execa: execaFn } = await import('execa');
492
+ const child = execaFn('cloudflared', ['access', 'tcp', '--hostname', hostname, '--url', `127.0.0.1:${localPort}`], {
493
+ stdio: 'inherit',
494
+ reject: false,
495
+ });
496
+
497
+ trackPID(child.pid);
498
+ console.log(chalk.green(` ✅ Local proxy active! Connect your client to ${chalk.bold('127.0.0.1:' + localPort)}`));
499
+ console.log(chalk.dim(' Press Ctrl+C to terminate the tunnel.'));
500
+ logSessionEvent('client_tcp_mode', { uid: targetUid, port: localPort, hostname });
501
+
502
+ const result = await child;
503
+ untrackPID(child.pid);
504
+
505
+ if (result.exitCode !== 0) {
506
+ console.log(chalk.red(` ❌ Cloudflared exited with code ${result.exitCode}`));
507
+ }
508
+ } catch (err) {
509
+ console.log(chalk.red(` ❌ Could not start cloudflared proxy: ${err.message}`));
510
+ console.log(chalk.dim(' Fallback: run manually in another terminal:'));
511
+ console.log(chalk.cyan(` cloudflared access tcp --hostname ${hostname} --url 127.0.0.1:${localPort}`));
512
+ console.log('');
513
+ console.log(` Then connect your local client to ${chalk.green('127.0.0.1:' + localPort)}`);
514
+ }
515
+ await cleanupAll();
516
+ return;
357
517
  }
358
518
 
359
519
  const tunnelUrl = payload.url;
@@ -367,15 +527,56 @@ export async function startClientMode(options = {}) {
367
527
  try {
368
528
  privateKeyPath = await writeEphemeralPrivateKey(payload.privateKey);
369
529
  addCleanupHook(() => {
370
- try { fs.unlinkSync(privateKeyPath); } catch {}
530
+ try { fs.unlinkSync(privateKeyPath); } catch { }
371
531
  });
532
+ logSessionEvent('client_ephemeral_key_ready');
372
533
  } catch (err) {
373
534
  console.log(chalk.yellow(` ⚠️ Could not use ephemeral SSH key: ${err.message}`));
374
535
  console.log(chalk.dim(' Falling back to standard OS password.'));
536
+ logSessionEvent('client_ephemeral_key_failed', { error: err.message }, 'warn');
375
537
  privateKeyPath = null;
376
538
  }
377
539
  }
378
540
 
541
+ // ─── One-Time File Share Auto-Download ────────────────────
542
+ if (payload.oneTime && payload.oneTimeSharePath) {
543
+ console.log('');
544
+ console.log(chalk.bold.magenta(' 📦 One-Time File Share'));
545
+ console.log(chalk.dim(' ──────────────────────────────────────'));
546
+ console.log(chalk.dim(` Host is sharing: ${chalk.cyan(payload.oneTimeSharePath)}`));
547
+ console.log(chalk.dim(' This is a one-time transfer — the session will be revoked after download.'));
548
+ console.log('');
549
+
550
+ const localDest = await promptLocalPath('download destination');
551
+
552
+ await pushTelemetry(BROKER_URL, targetUid, targetPassword, username, 'one-time-download');
553
+ logSessionEvent('client_one_time_download_start', { uid: targetUid, remotePath: payload.oneTimeSharePath });
554
+
555
+ const success = await downloadSpecificRemotePath(username, hostname, privateKeyPath, payload.oneTimeSharePath, localDest);
556
+
557
+ if (success) {
558
+ console.log(chalk.green(' ✅ One-time file transfer completed successfully!'));
559
+
560
+ // Verify download checksum
561
+ const dlHash = await calculateChecksum(localDest);
562
+ if (dlHash) console.log(chalk.green(` 🔍 File Intact. SHA-256: ${dlHash.substring(0, 16)}...`));
563
+
564
+ // Auto-revoke the UID from broker
565
+ console.log(chalk.dim(' Revoking session from broker...'));
566
+ await revokeUID(BROKER_URL, targetUid);
567
+ console.log(chalk.green(' 🔒 Session revoked. No further access is possible.'));
568
+
569
+ logSessionEvent('client_one_time_download_complete', { uid: targetUid });
570
+ recordEvent('one_time_transfer_complete', { uid: targetUid, localDest });
571
+ } else {
572
+ console.log(chalk.red(' ❌ One-time file transfer failed.'));
573
+ logSessionEvent('client_one_time_download_failed', { uid: targetUid }, 'error');
574
+ }
575
+
576
+ await cleanupAll();
577
+ return;
578
+ }
579
+
379
580
  // Ask to save alias if we entered manually
380
581
  if (!targetUsername) {
381
582
  const { saveIt } = await inquirer.prompt([{
@@ -394,12 +595,10 @@ export async function startClientMode(options = {}) {
394
595
  }]);
395
596
  saveAlias(aliasName.trim(), { uid: targetUid, password: targetPassword, username });
396
597
  console.log(chalk.green(` ✓ Saved as alias: ${chalk.bold(aliasName.trim())}\n`));
598
+ logSessionEvent('client_alias_saved', { alias: aliasName.trim(), uid: targetUid });
397
599
  }
398
600
  }
399
601
 
400
- // Push secure telemetry to host
401
- await pushTelemetry(BROKER_URL, targetUid, targetPassword, username);
402
-
403
602
  const { action } = await inquirer.prompt([
404
603
  {
405
604
  type: 'list',
@@ -415,6 +614,9 @@ export async function startClientMode(options = {}) {
415
614
  }
416
615
  ]);
417
616
 
617
+ await pushTelemetry(BROKER_URL, targetUid, targetPassword, username, action);
618
+ logSessionEvent('client_action_selected', { action });
619
+
418
620
  if (action === 'chat') {
419
621
  await handleClientChat(targetUid, targetPassword, payload.chatUrl);
420
622
  } else if (action === 'ssh') {
@@ -422,7 +624,7 @@ export async function startClientMode(options = {}) {
422
624
  } else if (action === 'reverse') {
423
625
  await performReverseForward(username, hostname, privateKeyPath);
424
626
  } else {
425
- await performSCP(username, hostname, action, privateKeyPath);
627
+ await performSCP(username, hostname, action, privateKeyPath, payload.sharedDropPath);
426
628
  }
427
629
 
428
630
  console.log('');
@@ -436,16 +638,57 @@ export async function startClientMode(options = {}) {
436
638
  ]);
437
639
 
438
640
  if (reconnect) {
439
- await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword);
641
+ await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, payload.sharedDropPath);
440
642
  }
441
643
 
644
+ logSessionEvent('client_session_exit');
442
645
  await cleanupAll();
443
646
  }
444
647
 
648
+ /**
649
+ * Perform a non-interactive SCP transfer (used by AI Transfer Assistant).
650
+ * params: { brokerUrl, uid, password, username, direction, localPath, remotePath }
651
+ */
652
+ export async function performSCPNonInteractive(params = {}) {
653
+ const { brokerUrl, uid, password, username, direction, localPath, remotePath } = params;
654
+ if (!brokerUrl || !uid || !password) throw new Error('Missing broker connection info');
655
+
656
+ const payload = await resolveUID(brokerUrl, uid, password);
657
+ if (!payload) throw new Error('Could not resolve UID/payload');
658
+
659
+ const tunnelUrl = payload.url;
660
+ const hostname = extractHostname(tunnelUrl);
661
+ const privateKeyPath = payload.privateKey ? await writeEphemeralPrivateKey(payload.privateKey) : null;
662
+
663
+ // Build scp args similar to performSCP
664
+ const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
665
+ const scpArgs = ['-r', '-o', `ProxyCommand=${proxyCommand}`, '-o', 'StrictHostKeyChecking=accept-new', '-o', 'IdentitiesOnly=yes', ...getSshControlOptions(hostname)];
666
+ if (privateKeyPath) scpArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
667
+
668
+ const remoteSpec = `${username}@${hostname}:${formatScpRemotePath(remotePath)}`;
669
+ if (direction === 'upload') {
670
+ scpArgs.push(localPath, remoteSpec);
671
+ } else {
672
+ scpArgs.push(remoteSpec, localPath);
673
+ }
674
+
675
+ try {
676
+ const result = await execa('scp', scpArgs, { stdio: 'inherit', reject: false });
677
+ if (result.exitCode === 0) {
678
+ recordEvent('scp_transfer_success', { direction, localPath, remotePath, hostname, automated: true });
679
+ return true;
680
+ }
681
+ recordEvent('scp_transfer_failed', { direction, localPath, remotePath, hostname, error: result.stderr, automated: true });
682
+ return false;
683
+ } finally {
684
+ try { if (privateKeyPath) fs.unlinkSync(privateKeyPath); } catch { }
685
+ }
686
+ }
687
+
445
688
  async function handleClientChat(uid, password, cachedChatUrl) {
446
689
  let chatUrl = cachedChatUrl;
447
690
  const spinner = createSpinner('Checking for active chat room...', networkSpinner).start();
448
-
691
+
449
692
  const payload = await resolveUID(BROKER_URL, uid, password, true); // true = silent if possible, or just re-resolve
450
693
  if (payload && payload.chatUrl) {
451
694
  chatUrl = payload.chatUrl;
@@ -464,7 +707,7 @@ async function handleClientChat(uid, password, cachedChatUrl) {
464
707
  }
465
708
  }
466
709
 
467
- async function handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword) {
710
+ async function handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, sharedDropPath = null) {
468
711
  const { action } = await inquirer.prompt([
469
712
  {
470
713
  type: 'list',
@@ -483,6 +726,9 @@ async function handleSubsequentActions(username, hostname, privateKeyPath, targe
483
726
 
484
727
  if (action === 'exit') return;
485
728
 
729
+ await pushTelemetry(BROKER_URL, targetUid, targetPassword, username, action);
730
+ logSessionEvent('client_action_selected', { action });
731
+
486
732
  if (action === 'chat') {
487
733
  await handleClientChat(targetUid, targetPassword, null);
488
734
  } else if (action === 'ssh') {
@@ -490,7 +736,7 @@ async function handleSubsequentActions(username, hostname, privateKeyPath, targe
490
736
  } else if (action === 'reverse') {
491
737
  await performReverseForward(username, hostname, privateKeyPath);
492
738
  } else {
493
- await performSCP(username, hostname, action, privateKeyPath);
739
+ await performSCP(username, hostname, action, privateKeyPath, sharedDropPath);
494
740
  }
495
741
 
496
742
  const { reconnect } = await inquirer.prompt([
@@ -503,7 +749,7 @@ async function handleSubsequentActions(username, hostname, privateKeyPath, targe
503
749
  ]);
504
750
 
505
751
  if (reconnect) {
506
- await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword);
752
+ await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, sharedDropPath);
507
753
  }
508
754
  }
509
755
 
@@ -542,16 +788,27 @@ async function performReverseForward(username, hostname, privateKeyPath) {
542
788
  console.log('');
543
789
  const spinner = createSpinner(`Forwarding Host:${remotePort} ➔ Localhost:${localPort}...`, networkSpinner).start();
544
790
 
791
+ let child = null;
545
792
  try {
546
- const child = execa('ssh', sshArgs, { stdio: 'inherit' });
793
+ child = execa('ssh', sshArgs, { stdio: 'inherit', reject: false });
547
794
  trackPID(child.pid);
548
795
  spinner.succeed(`Reverse tunnel active! Host can access your app at ${chalk.bold.green('localhost:' + remotePort)}`);
549
796
  console.log(chalk.dim(' Press Ctrl+C to terminate the reverse tunnel.'));
550
-
551
- await child;
797
+
798
+ recordEvent('reverse_forward_started', { localPort, remotePort, hostname });
799
+ const result = await child;
800
+ if (result.exitCode === 0) {
801
+ recordEvent('reverse_forward_ended', { localPort, remotePort, hostname, exitCode: 0 });
802
+ } else {
803
+ console.log(chalk.red(`\n ❌ Reverse tunnel exited with code ${result.exitCode}`));
804
+ recordEvent('reverse_forward_failed', { localPort, remotePort, hostname, exitCode: result.exitCode });
805
+ }
552
806
  } catch (err) {
553
807
  if (err.isCanceled) return;
554
808
  if (err.killed) return;
809
+ spinner.fail('Reverse tunnel failed to start');
555
810
  console.log(chalk.red(`\n ❌ Tunnel disconnected: ${err.message}`));
811
+ } finally {
812
+ if (child?.pid) untrackPID(child.pid);
556
813
  }
557
814
  }