@miraj181/ipingyou 1.0.0

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.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * ============================================================
3
+ * Graceful Cleanup & Process Killer
4
+ * ============================================================
5
+ * Tracks all spawned child processes (cloudflared, ssh, etc.)
6
+ * and kills them on SIGINT/exit using tree-kill to ensure
7
+ * no orphan processes linger.
8
+ * ============================================================
9
+ */
10
+
11
+ import treeKill from 'tree-kill';
12
+ import chalk from 'chalk';
13
+
14
+ /** @type {Set<number>} โ€” Active child PIDs we manage */
15
+ const trackedPIDs = new Set();
16
+
17
+ /** @type {string|null} โ€” UID to revoke on shutdown */
18
+ let _revokeUID = null;
19
+
20
+ /** @type {string|null} โ€” Broker URL for revocation */
21
+ let _brokerUrl = null;
22
+
23
+ /**
24
+ * Register a spawned child PID for tracking.
25
+ * @param {number} pid
26
+ */
27
+ export function trackPID(pid) {
28
+ if (pid) trackedPIDs.add(pid);
29
+ }
30
+
31
+ /**
32
+ * Unregister a PID (after it exits naturally).
33
+ * @param {number} pid
34
+ */
35
+ export function untrackPID(pid) {
36
+ trackedPIDs.delete(pid);
37
+ }
38
+
39
+ /**
40
+ * Set UID + broker URL for automatic revocation on shutdown.
41
+ * @param {string} uid
42
+ * @param {string} brokerUrl
43
+ */
44
+ export function setRevokeOnExit(uid, brokerUrl) {
45
+ _revokeUID = uid;
46
+ _brokerUrl = brokerUrl;
47
+ }
48
+
49
+ /**
50
+ * Kill a single PID tree.
51
+ * @param {number} pid
52
+ * @returns {Promise<void>}
53
+ */
54
+ function killPID(pid) {
55
+ return new Promise((resolve) => {
56
+ treeKill(pid, 'SIGTERM', (err) => {
57
+ if (err) {
58
+ // Force kill if SIGTERM fails
59
+ treeKill(pid, 'SIGKILL', () => resolve());
60
+ } else {
61
+ resolve();
62
+ }
63
+ });
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Kill all tracked PIDs and revoke UID from broker.
69
+ */
70
+ export async function cleanupAll() {
71
+ console.log('');
72
+ console.log(chalk.yellow(' ๐Ÿงน Cleaning up...'));
73
+
74
+ // Kill all tracked processes
75
+ const kills = [];
76
+ for (const pid of trackedPIDs) {
77
+ console.log(chalk.dim(` Killing PID ${pid}...`));
78
+ kills.push(killPID(pid));
79
+ }
80
+ await Promise.allSettled(kills);
81
+ trackedPIDs.clear();
82
+
83
+ // Revoke UID from broker
84
+ if (_revokeUID && _brokerUrl) {
85
+ try {
86
+ const res = await fetch(`${_brokerUrl}/revoke/${_revokeUID}`, { method: 'DELETE' });
87
+ if (res.ok) {
88
+ console.log(chalk.dim(` Revoked UID ${_revokeUID} from broker`));
89
+ }
90
+ } catch {
91
+ // Best-effort โ€” broker might be down
92
+ }
93
+ }
94
+
95
+ console.log(chalk.green(' โœ… Cleanup complete. Goodbye!'));
96
+ console.log('');
97
+ }
98
+
99
+ /**
100
+ * Install SIGINT/SIGTERM handlers for graceful shutdown.
101
+ */
102
+ export function installShutdownHandlers() {
103
+ let shuttingDown = false;
104
+
105
+ const handler = async (signal) => {
106
+ if (shuttingDown) return; // Prevent double-cleanup
107
+ shuttingDown = true;
108
+ console.log('');
109
+ console.log(chalk.yellow(` โšก Received ${signal}`));
110
+ await cleanupAll();
111
+ process.exit(0);
112
+ };
113
+
114
+ process.on('SIGINT', () => handler('SIGINT'));
115
+ process.on('SIGTERM', () => handler('SIGTERM'));
116
+
117
+ // Also handle uncaught exceptions gracefully
118
+ process.on('uncaughtException', async (err) => {
119
+ console.error(chalk.red(` ๐Ÿ’ฅ Uncaught exception: ${err.message}`));
120
+ await cleanupAll();
121
+ process.exit(1);
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Get count of tracked PIDs.
127
+ * @returns {number}
128
+ */
129
+ export function getTrackedCount() {
130
+ return trackedPIDs.size;
131
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * ============================================================
3
+ * AES-256-CBC Encryption Utilities
4
+ * ============================================================
5
+ * Shared crypto module used by both broker and CLI.
6
+ * All sensitive data is encrypted before transit/storage.
7
+ * ============================================================
8
+ */
9
+
10
+ import crypto from 'node:crypto';
11
+
12
+ const DEFAULT_HEX_KEY = 'b374a26d71590483815c467a99623e1b7db95f269c2889279a32c4530fc4159f';
13
+
14
+ /**
15
+ * Get the encryption key as a Buffer.
16
+ * Reads from env var SECRET_KEY, falls back to the default dev key.
17
+ */
18
+ export function getKey() {
19
+ const hex = process.env.SECRET_KEY || DEFAULT_HEX_KEY;
20
+ return Buffer.from(hex, 'hex');
21
+ }
22
+
23
+ /**
24
+ * Encrypt a plaintext string with AES-256-CBC.
25
+ * @param {string} plaintext
26
+ * @returns {{ iv: string, ciphertext: string }}
27
+ */
28
+ export function encrypt(plaintext) {
29
+ const key = getKey();
30
+ const iv = crypto.randomBytes(16);
31
+ const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
32
+ let enc = cipher.update(plaintext, 'utf8', 'base64');
33
+ enc += cipher.final('base64');
34
+ return {
35
+ iv: iv.toString('hex'),
36
+ ciphertext: enc,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Decrypt a ciphertext with AES-256-CBC.
42
+ * @param {string} ivHex โ€” 32-char hex IV
43
+ * @param {string} cipherBase64
44
+ * @returns {string}
45
+ */
46
+ export function decrypt(ivHex, cipherBase64) {
47
+ const key = getKey();
48
+ const iv = Buffer.from(ivHex, 'hex');
49
+ const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
50
+ let dec = decipher.update(cipherBase64, 'base64', 'utf8');
51
+ dec += decipher.final('utf8');
52
+ return dec;
53
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * ============================================================
3
+ * Platform Detection & Dependency Checker
4
+ * ============================================================
5
+ * Detects OS, checks for ssh/cloudflared, and provides
6
+ * automated installation guidance per platform.
7
+ * ============================================================
8
+ */
9
+
10
+ import { execaCommand } from 'execa';
11
+ import chalk from 'chalk';
12
+ import ora from 'ora';
13
+ import os from 'node:os';
14
+
15
+ /**
16
+ * Detect the current operating system.
17
+ * @returns {{ platform: string, isLinux: boolean, isMac: boolean, isWindows: boolean, distro: string|null }}
18
+ */
19
+ export function detectOS() {
20
+ const platform = process.platform;
21
+ const result = {
22
+ platform,
23
+ isLinux: platform === 'linux',
24
+ isMac: platform === 'darwin',
25
+ isWindows: platform === 'win32',
26
+ distro: null,
27
+ arch: os.arch(),
28
+ hostname: os.hostname(),
29
+ };
30
+
31
+ return result;
32
+ }
33
+
34
+ /**
35
+ * Detect Linux distribution family.
36
+ * @returns {Promise<string>} 'debian' | 'arch' | 'fedora' | 'unknown'
37
+ */
38
+ export async function detectLinuxDistro() {
39
+ try {
40
+ const { stdout } = await execaCommand('cat /etc/os-release', { reject: false });
41
+ const lower = stdout.toLowerCase();
42
+ if (lower.includes('ubuntu') || lower.includes('debian') || lower.includes('kali') || lower.includes('mint')) {
43
+ return 'debian';
44
+ }
45
+ if (lower.includes('arch') || lower.includes('manjaro')) {
46
+ return 'arch';
47
+ }
48
+ if (lower.includes('fedora') || lower.includes('centos') || lower.includes('rhel')) {
49
+ return 'fedora';
50
+ }
51
+ } catch { /* ignore */ }
52
+ return 'unknown';
53
+ }
54
+
55
+ /**
56
+ * Check if a command exists on PATH.
57
+ * @param {string} cmd
58
+ * @returns {Promise<boolean>}
59
+ */
60
+ export async function commandExists(cmd) {
61
+ try {
62
+ const checkCmd = process.platform === 'win32' ? `where ${cmd}` : `which ${cmd}`;
63
+ await execaCommand(checkCmd, { reject: true });
64
+ return true;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Check if sudo is available (Linux/macOS).
72
+ * @returns {Promise<boolean>}
73
+ */
74
+ export async function hasSudo() {
75
+ if (process.platform === 'win32') return false;
76
+ return commandExists('sudo');
77
+ }
78
+
79
+ /**
80
+ * Install a dependency on the current platform.
81
+ * @param {string} pkg โ€” package name (e.g. 'openssh-server', 'cloudflared')
82
+ * @param {'debian'|'arch'|'fedora'|'mac'|'windows'} distro
83
+ */
84
+ export async function installDependency(pkg, distro) {
85
+ const spinner = ora(`Installing ${chalk.cyan(pkg)}...`).start();
86
+
87
+ const commands = {
88
+ debian: `sudo apt-get install -y ${pkg}`,
89
+ arch: `sudo pacman -S --noconfirm ${pkg}`,
90
+ fedora: `sudo dnf install -y ${pkg}`,
91
+ mac: `brew install ${pkg}`,
92
+ };
93
+
94
+ const cmd = commands[distro];
95
+ if (!cmd) {
96
+ spinner.fail(`No auto-install command for ${distro}. Please install ${pkg} manually.`);
97
+ return false;
98
+ }
99
+
100
+ try {
101
+ await execaCommand(cmd, { stdio: 'inherit' });
102
+ spinner.succeed(`${chalk.green(pkg)} installed successfully`);
103
+ return true;
104
+ } catch (err) {
105
+ spinner.fail(`Failed to install ${pkg}: ${err.message}`);
106
+ return false;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Run full dependency check for ssh and cloudflared.
112
+ * @returns {Promise<{ ssh: boolean, cloudflared: boolean }>}
113
+ */
114
+ export async function checkDependencies() {
115
+ const osInfo = detectOS();
116
+ const results = { ssh: false, cloudflared: false };
117
+
118
+ console.log('');
119
+ console.log(chalk.bold(' ๐Ÿ” Dependency Check'));
120
+ console.log(chalk.dim(' โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'));
121
+
122
+ // Check SSH
123
+ const sshCmd = osInfo.isWindows ? 'ssh' : 'ssh';
124
+ results.ssh = await commandExists(sshCmd);
125
+ console.log(` ${results.ssh ? chalk.green('โœ“') : chalk.red('โœ—')} ssh ${results.ssh ? chalk.dim('found') : chalk.red('missing')}`);
126
+
127
+ // Check cloudflared
128
+ results.cloudflared = await commandExists('cloudflared');
129
+ console.log(` ${results.cloudflared ? chalk.green('โœ“') : chalk.red('โœ—')} cloudflared ${results.cloudflared ? chalk.dim('found') : chalk.red('missing')}`);
130
+
131
+ console.log('');
132
+
133
+ // Auto-install logic for missing deps
134
+ if (!results.ssh || !results.cloudflared) {
135
+ if (osInfo.isLinux) {
136
+ const distro = await detectLinuxDistro();
137
+ const canSudo = await hasSudo();
138
+
139
+ if (canSudo) {
140
+ console.log(chalk.yellow(' โšก Attempting automatic installation...'));
141
+ console.log('');
142
+
143
+ if (!results.ssh) {
144
+ const sshPkg = distro === 'arch' ? 'openssh' : 'openssh-server';
145
+ results.ssh = await installDependency(sshPkg, distro);
146
+ }
147
+
148
+ if (!results.cloudflared) {
149
+ // cloudflared isn't always in default repos
150
+ console.log(chalk.yellow(' โ„น๏ธ cloudflared must be installed manually:'));
151
+ console.log(chalk.dim(' https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/'));
152
+ }
153
+ } else {
154
+ console.log(chalk.yellow(' โš ๏ธ sudo not available โ€” cannot auto-install.'));
155
+ console.log(chalk.dim(' Install ssh and cloudflared manually.'));
156
+ }
157
+ } else if (osInfo.isMac) {
158
+ if (!results.cloudflared) {
159
+ console.log(chalk.yellow(' โ„น๏ธ Install cloudflared via Homebrew:'));
160
+ console.log(chalk.cyan(' brew install cloudflared'));
161
+ }
162
+ if (!results.ssh) {
163
+ console.log(chalk.dim(' โ„น๏ธ macOS ships with SSH by default. Enable it in:'));
164
+ console.log(chalk.dim(' System Preferences โ†’ Sharing โ†’ Remote Login'));
165
+ }
166
+ } else if (osInfo.isWindows) {
167
+ console.log(chalk.yellow(' โš ๏ธ Windows detected โ€” manual install required:'));
168
+ if (!results.ssh) {
169
+ console.log(chalk.cyan(' winget install Microsoft.OpenSSH.Client'));
170
+ console.log(chalk.dim(' Or enable via: Settings โ†’ Apps โ†’ Optional Features โ†’ OpenSSH'));
171
+ }
172
+ if (!results.cloudflared) {
173
+ console.log(chalk.cyan(' winget install Cloudflare.cloudflared'));
174
+ }
175
+ }
176
+ }
177
+
178
+ return results;
179
+ }
package/src/lib/uid.js ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * ============================================================
3
+ * Session UID Generator
4
+ * ============================================================
5
+ * Generates cryptographically random 8-character UIDs.
6
+ * NOT based on hardware/MAC โ€” purely random per-session,
7
+ * so the "door" closes permanently when the session ends.
8
+ * ============================================================
9
+ */
10
+
11
+ import { customAlphabet } from 'nanoid';
12
+
13
+ // Use lowercase alphanumeric only โ€” easy to share verbally
14
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
15
+ const generate = customAlphabet(alphabet, 8);
16
+
17
+ /**
18
+ * Generate a random 8-character session UID.
19
+ * @returns {string}
20
+ */
21
+ export function generateUID() {
22
+ return generate();
23
+ }
@@ -0,0 +1,322 @@
1
+ /**
2
+ * ============================================================
3
+ * Client Mode โ€” "Access a Remote Machine"
4
+ * ============================================================
5
+ * 1. Prompt for the remote host's UID
6
+ * 2. Resolve UID โ†’ ENCRYPTED blob from the Broker
7
+ * 3. DECRYPT tunnel URL locally using shared key
8
+ * 4. Execute SSH/SCP through the Cloudflare tunnel proxy
9
+ *
10
+ * Security: The broker only returns { iv, ciphertext }.
11
+ * Decryption happens ONLY on this machine.
12
+ * ============================================================
13
+ */
14
+
15
+ import { execa } from 'execa';
16
+ import chalk from 'chalk';
17
+ import inquirer from 'inquirer';
18
+ import { decrypt } from '../lib/crypto.js';
19
+ import { trackPID, untrackPID } from '../lib/cleanup.js';
20
+ import { createSpinner, sshSpinner, networkSpinner, fileTransferSpinner, showConnectionTrace, animatedSteps, simulateTransferProgress } from '../lib/animations.js';
21
+
22
+ const BROKER_URL = process.env.BROKER_URL || 'http://localhost:4000';
23
+
24
+ /**
25
+ * Resolve a UID to a tunnel URL via the broker.
26
+ * The broker returns an encrypted blob; we decrypt locally.
27
+ *
28
+ * @param {string} uid
29
+ * @returns {Promise<string|null>} The decrypted tunnel URL, or null on failure
30
+ */
31
+ async function resolveUID(uid) {
32
+ const spinner = createSpinner(`Resolving UID ${chalk.cyan(uid)}...`, networkSpinner).start();
33
+
34
+ try {
35
+ const res = await fetch(`${BROKER_URL}/resolve/${uid}`);
36
+
37
+ if (res.status === 404) {
38
+ spinner.fail('UID not found โ€” the host may not be online or the session expired');
39
+ return null;
40
+ }
41
+ if (res.status === 410) {
42
+ spinner.fail('UID has expired โ€” ask the host for a new session');
43
+ return null;
44
+ }
45
+ if (!res.ok) {
46
+ const data = await res.json().catch(() => ({}));
47
+ throw new Error(data.error || `HTTP ${res.status}`);
48
+ }
49
+
50
+ const data = await res.json();
51
+
52
+ if (!data.iv || !data.ciphertext) {
53
+ spinner.fail('Broker returned invalid response โ€” missing encrypted data');
54
+ return null;
55
+ }
56
+
57
+ spinner.text = `Decrypting tunnel URL locally...`;
58
+
59
+ // Simulate decryption delay for effect
60
+ await new Promise(r => setTimeout(r, 600));
61
+
62
+ // Decrypt locally
63
+ let tunnelUrl;
64
+ try {
65
+ tunnelUrl = decrypt(data.iv, data.ciphertext);
66
+ } catch (decryptErr) {
67
+ spinner.fail('Decryption failed โ€” SECRET_KEY mismatch');
68
+ console.error(chalk.red(' โŒ Error: Could not decrypt tunnel URL'));
69
+ console.log(chalk.dim(' Make sure your SECRET_KEY matches the host\'s key.'));
70
+ return null;
71
+ }
72
+
73
+ if (!tunnelUrl.startsWith('https://')) {
74
+ spinner.fail('Decrypted data is not a valid tunnel URL');
75
+ return null;
76
+ }
77
+
78
+ spinner.succeed(`Resolved: ${chalk.dim(tunnelUrl)} ${chalk.green('[decrypted locally]')}`);
79
+ return tunnelUrl;
80
+ } catch (err) {
81
+ spinner.fail(`Broker lookup failed: ${err.message}`);
82
+ return null;
83
+ }
84
+ }
85
+
86
+ function extractHostname(url) {
87
+ try {
88
+ return new URL(url).hostname;
89
+ } catch {
90
+ return url.replace(/^https?:\/\//, '').replace(/\/$/, '');
91
+ }
92
+ }
93
+
94
+ async function promptUsername() {
95
+ const { username } = await inquirer.prompt([
96
+ {
97
+ type: 'input',
98
+ name: 'username',
99
+ message: 'SSH username on the remote machine:',
100
+ default: process.env.USER || process.env.USERNAME || 'root',
101
+ validate: (v) => v.trim().length > 0 || 'Username is required',
102
+ },
103
+ ]);
104
+ return username.trim();
105
+ }
106
+
107
+ /**
108
+ * Start SSH connection through the Cloudflare tunnel.
109
+ */
110
+ async function connectSSH(username, hostname) {
111
+ console.log('');
112
+ console.log(chalk.bold(' ๐Ÿ”— Establishing SSH Connection'));
113
+ console.log(chalk.dim(' โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'));
114
+
115
+ await showConnectionTrace('Local', 'Remote SSH');
116
+
117
+ const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
118
+
119
+ try {
120
+ const spinner = createSpinner('Handshaking...', sshSpinner).start();
121
+ await new Promise(r => setTimeout(r, 800));
122
+ spinner.succeed('Connection established! Handing over to terminal...');
123
+ console.log('');
124
+
125
+ const child = execa('ssh', [
126
+ '-o', `ProxyCommand=${proxyCommand}`,
127
+ '-o', 'StrictHostKeyChecking=accept-new',
128
+ '-o', 'ServerAliveInterval=30',
129
+ '-o', 'ServerAliveCountMax=3',
130
+ `${username}@${hostname}`,
131
+ ], {
132
+ stdio: 'inherit',
133
+ reject: false,
134
+ });
135
+
136
+ trackPID(child.pid);
137
+ const result = await child;
138
+ untrackPID(child.pid);
139
+
140
+ if (result.exitCode === 0) {
141
+ console.log('');
142
+ console.log(chalk.green(' โœ… SSH session ended cleanly'));
143
+ } else if (result.exitCode === 255) {
144
+ console.log('');
145
+ console.error(chalk.red(' โŒ SSH connection failed (exit code 255)'));
146
+ } else {
147
+ console.log('');
148
+ console.error(chalk.red(` โŒ SSH exited with code ${result.exitCode}`));
149
+ }
150
+ } catch (err) {
151
+ console.error(chalk.red(` โŒ SSH error: ${err.message}`));
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Perform an SCP file transfer through the Cloudflare tunnel.
157
+ */
158
+ async function performSCP(username, hostname, direction) {
159
+ console.log('');
160
+ console.log(chalk.bold(` ๐Ÿ“ฆ SCP Transfer (${direction})`));
161
+ console.log(chalk.dim(' โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'));
162
+
163
+ const { localPath, remotePath } = await inquirer.prompt([
164
+ {
165
+ type: 'input',
166
+ name: 'localPath',
167
+ message: `Local file/folder path:`,
168
+ validate: v => v.trim().length > 0 || 'Required',
169
+ },
170
+ {
171
+ type: 'input',
172
+ name: 'remotePath',
173
+ message: `Remote path (relative to ${username}'s home or absolute):`,
174
+ validate: v => v.trim().length > 0 || 'Required',
175
+ }
176
+ ]);
177
+
178
+ await showConnectionTrace('Local', 'Remote SCP');
179
+
180
+ const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
181
+
182
+ // Construct SCP args
183
+ const scpArgs = [
184
+ '-r', // recursive just in case
185
+ '-o', `ProxyCommand=${proxyCommand}`,
186
+ '-o', 'StrictHostKeyChecking=accept-new'
187
+ ];
188
+
189
+ if (direction === 'upload') {
190
+ scpArgs.push(localPath, `${username}@${hostname}:${remotePath}`);
191
+ } else {
192
+ scpArgs.push(`${username}@${hostname}:${remotePath}`, localPath);
193
+ }
194
+
195
+ try {
196
+ const transferSpinner = createSpinner(`Transferring via SCP...`, fileTransferSpinner).start();
197
+
198
+ const child = execa('scp', scpArgs, {
199
+ stdio: ['inherit', 'pipe', 'pipe'],
200
+ reject: false,
201
+ });
202
+
203
+ trackPID(child.pid);
204
+ const result = await child;
205
+ untrackPID(child.pid);
206
+
207
+ transferSpinner.stop();
208
+
209
+ if (result.exitCode === 0) {
210
+ await simulateTransferProgress(direction === 'upload' ? localPath : remotePath, direction, 1500);
211
+ console.log(chalk.green(` โœ… Transfer completed successfully!`));
212
+ } else {
213
+ console.error(chalk.red(' โŒ SCP transfer failed'));
214
+ if (result.stderr) console.error(chalk.dim(` ${result.stderr.trim()}`));
215
+ }
216
+ } catch (err) {
217
+ console.error(chalk.red(` โŒ SCP error: ${err.message}`));
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Main Client Mode entry point.
223
+ */
224
+ export async function startClientMode() {
225
+ console.log('');
226
+ console.log(chalk.bold.cyan(' ๐ŸŒ CLIENT MODE โ€” Access a Remote Machine'));
227
+ console.log(chalk.dim(' โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'));
228
+ console.log('');
229
+
230
+ const answer = await inquirer.prompt([
231
+ {
232
+ type: 'input',
233
+ name: 'uid',
234
+ message: 'Enter the remote host\'s UID:',
235
+ validate: (v) => {
236
+ const trimmed = v.trim();
237
+ if (trimmed.length < 6 || trimmed.length > 16) return 'UID must be 6-16 characters';
238
+ if (!/^[a-z0-9]+$/.test(trimmed)) return 'UID should be lowercase alphanumeric';
239
+ return true;
240
+ },
241
+ },
242
+ ]);
243
+ const uid = answer.uid.trim();
244
+
245
+ const tunnelUrl = await resolveUID(uid);
246
+ if (!tunnelUrl) {
247
+ process.exit(1);
248
+ }
249
+
250
+ const username = await promptUsername();
251
+ const hostname = extractHostname(tunnelUrl);
252
+
253
+ const { action } = await inquirer.prompt([
254
+ {
255
+ type: 'list',
256
+ name: 'action',
257
+ message: 'What would you like to do?',
258
+ choices: [
259
+ { name: '๐Ÿ–ฅ๏ธ Connect via SSH (Interactive Shell)', value: 'ssh' },
260
+ { name: '๐Ÿ“ค Upload file/folder via SCP', value: 'upload' },
261
+ { name: '๐Ÿ“ฅ Download file/folder via SCP', value: 'download' }
262
+ ]
263
+ }
264
+ ]);
265
+
266
+ if (action === 'ssh') {
267
+ await connectSSH(username, hostname);
268
+ } else {
269
+ await performSCP(username, hostname, action);
270
+ }
271
+
272
+ console.log('');
273
+ const { reconnect } = await inquirer.prompt([
274
+ {
275
+ type: 'confirm',
276
+ name: 'reconnect',
277
+ message: 'Perform another action with the same host?',
278
+ default: false,
279
+ },
280
+ ]);
281
+
282
+ if (reconnect) {
283
+ await handleSubsequentActions(username, hostname);
284
+ }
285
+ }
286
+
287
+ async function handleSubsequentActions(username, hostname) {
288
+ const { action } = await inquirer.prompt([
289
+ {
290
+ type: 'list',
291
+ name: 'action',
292
+ message: 'What would you like to do next?',
293
+ choices: [
294
+ { name: '๐Ÿ–ฅ๏ธ Connect via SSH', value: 'ssh' },
295
+ { name: '๐Ÿ“ค Upload file/folder via SCP', value: 'upload' },
296
+ { name: '๐Ÿ“ฅ Download file/folder via SCP', value: 'download' },
297
+ { name: 'โŒ Exit', value: 'exit' }
298
+ ]
299
+ }
300
+ ]);
301
+
302
+ if (action === 'exit') return;
303
+
304
+ if (action === 'ssh') {
305
+ await connectSSH(username, hostname);
306
+ } else {
307
+ await performSCP(username, hostname, action);
308
+ }
309
+
310
+ const { reconnect } = await inquirer.prompt([
311
+ {
312
+ type: 'confirm',
313
+ name: 'reconnect',
314
+ message: 'Perform another action?',
315
+ default: false,
316
+ },
317
+ ]);
318
+
319
+ if (reconnect) {
320
+ await handleSubsequentActions(username, hostname);
321
+ }
322
+ }