@miraj181/ipingyou 2.0.9 → 2.0.12

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": "@miraj181/ipingyou",
3
- "version": "2.0.9",
3
+ "version": "2.0.12",
4
4
  "description": "SecureLink-CLI — Secure peer-to-peer remote access via SSH & Cloudflare Tunnels",
5
5
  "main": "src/cli.js",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -21,12 +21,17 @@
21
21
  import { Command } from 'commander';
22
22
  import inquirer from 'inquirer';
23
23
  import chalk from 'chalk';
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
24
27
 
25
28
  import { detectOS, checkDependencies } from './lib/platform.js';
26
- import { installShutdownHandlers, executePanicMode } from './lib/cleanup.js';
29
+ import { cleanupAll, installShutdownHandlers, executePanicMode } from './lib/cleanup.js';
27
30
  import { startHostMode } from './modes/host.js';
28
31
  import { startClientMode } from './modes/client.js';
29
32
 
33
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
34
+ const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8'));
30
35
 
31
36
  // ─── ASCII Banner ────────────────────────────────────────────
32
37
  const wolfAscii = [
@@ -146,19 +151,9 @@ function fatal(context, err) {
146
151
  stackLines.forEach(line => console.error(chalk.dim(` ${line.trim()}`)));
147
152
  }
148
153
  console.error('');
149
- process.exit(1);
154
+ cleanupAll().finally(() => process.exit(1));
150
155
  }
151
156
 
152
- // ─── Process-Level Error Catching ────────────────────────────
153
- process.on('uncaughtException', (err) => {
154
- fatal('uncaughtException', err);
155
- });
156
-
157
- process.on('unhandledRejection', (reason) => {
158
- const err = reason instanceof Error ? reason : new Error(String(reason));
159
- fatal('unhandledRejection', err);
160
- });
161
-
162
157
  // ─── Interactive Mode Selection ──────────────────────────────
163
158
  async function interactiveMode() {
164
159
  showBanner();
@@ -225,7 +220,7 @@ const program = new Command();
225
220
  program
226
221
  .name('ipingyou')
227
222
  .description('SecureLink-CLI — Secure P2P remote access via SSH & Cloudflare Tunnels')
228
- .version('1.0.0')
223
+ .version(packageJson.version)
229
224
  .option('-b, --broker <url>', 'Override the central broker URL')
230
225
  .addHelpText('beforeAll', () => {
231
226
  showBanner();
@@ -255,7 +250,7 @@ program
255
250
  .command('connect')
256
251
  .description('Connect to a remote machine via its UID (SSH or SCP)')
257
252
  .option('-u, --uid <uid>', 'The remote host UID')
258
- .action(async () => {
253
+ .action(async (commandOptions) => {
259
254
  try {
260
255
  const opts = program.opts();
261
256
  if (opts.broker) process.env.BROKER_URL = opts.broker;
@@ -264,7 +259,7 @@ program
264
259
  showSystemInfo();
265
260
  installShutdownHandlers();
266
261
  await checkDependencies();
267
- await startClientMode();
262
+ await startClientMode({ uid: commandOptions.uid });
268
263
  } catch (err) {
269
264
  fatal('connect', err);
270
265
  }
@@ -0,0 +1,144 @@
1
+ import chalk from 'chalk';
2
+ import os from 'node:os';
3
+ import { decrypt, encrypt } from './crypto.js';
4
+ import { createSpinner, cryptoSpinner, networkSpinner } from './animations.js';
5
+
6
+ export async function pingBroker(url) {
7
+ try {
8
+ const controller = new AbortController();
9
+ const id = setTimeout(() => controller.abort(), 3000);
10
+ const res = await fetch(`${url}/health`, { signal: controller.signal });
11
+ clearTimeout(id);
12
+ return res.ok;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ export async function registerWithBroker(brokerUrl, uid, tunnelUrl, password, serviceConfig) {
19
+ const spinner = createSpinner('Encrypting session data...', cryptoSpinner).start();
20
+
21
+ try {
22
+ await new Promise(r => setTimeout(r, 600));
23
+ const payload = JSON.stringify({ url: tunnelUrl, ...serviceConfig });
24
+ const encrypted = encrypt(payload, password);
25
+
26
+ spinner.text = 'Registering with broker...';
27
+
28
+ const res = await fetch(`${brokerUrl}/register`, {
29
+ method: 'POST',
30
+ headers: { 'Content-Type': 'application/json' },
31
+ body: JSON.stringify({
32
+ uid,
33
+ iv: encrypted.iv,
34
+ ciphertext: encrypted.ciphertext,
35
+ salt: encrypted.salt,
36
+ }),
37
+ });
38
+
39
+ if (!res.ok) {
40
+ const data = await res.json().catch(() => ({}));
41
+ throw new Error(data.error || `HTTP ${res.status}`);
42
+ }
43
+
44
+ spinner.succeed(`Registered with broker ${chalk.dim(`(${brokerUrl})`)} ${chalk.green('[E2E encrypted]')}`);
45
+ return true;
46
+ } catch (err) {
47
+ spinner.fail(`Broker registration failed: ${err.message}`);
48
+ console.error(chalk.red(` ❌ Error: ${err.message}`));
49
+ console.log(chalk.yellow(' ⚠️ Remote clients won\'t be able to find you without the broker.'));
50
+ console.log(chalk.dim(' Share the tunnel URL directly if needed.'));
51
+ return false;
52
+ }
53
+ }
54
+
55
+ export async function resolveUID(brokerUrl, uid, password, silent = false) {
56
+ const spinner = !silent ? createSpinner(`Resolving UID ${chalk.cyan(uid)}...`, networkSpinner).start() : null;
57
+
58
+ try {
59
+ const res = await fetch(`${brokerUrl}/resolve/${uid}`);
60
+
61
+ if (res.status === 404) {
62
+ if (spinner) spinner.fail('UID not found — the host may not be online or the session expired');
63
+ else console.error(chalk.red(' ❌ UID not found or expired.'));
64
+ return null;
65
+ }
66
+ if (res.status === 410) {
67
+ if (spinner) spinner.fail('UID has expired — ask the host for a new session');
68
+ else console.error(chalk.red(' ❌ UID expired.'));
69
+ return null;
70
+ }
71
+ if (!res.ok) {
72
+ const data = await res.json().catch(() => ({}));
73
+ throw new Error(data.error || `HTTP ${res.status}`);
74
+ }
75
+
76
+ const data = await res.json();
77
+
78
+ if (!data.iv || !data.ciphertext || !data.salt) {
79
+ if (spinner) spinner.fail('Broker returned invalid response — missing encrypted data or salt');
80
+ return null;
81
+ }
82
+
83
+ if (spinner) spinner.text = 'Decrypting tunnel URL locally...';
84
+ if (!silent) await new Promise(r => setTimeout(r, 600));
85
+
86
+ let decryptedPayload;
87
+ try {
88
+ decryptedPayload = decrypt(data.iv, data.ciphertext, password, data.salt);
89
+ } catch {
90
+ if (spinner) spinner.fail('Decryption failed — incorrect password or corrupted data');
91
+ if (!spinner) console.error(chalk.red(' ❌ Error: Could not decrypt tunnel data. Incorrect password.'));
92
+ return null;
93
+ }
94
+
95
+ let payloadConfig;
96
+ try {
97
+ payloadConfig = JSON.parse(decryptedPayload);
98
+ if (typeof payloadConfig !== 'object' || !payloadConfig.url) {
99
+ payloadConfig = { url: decryptedPayload, type: 'ssh' };
100
+ }
101
+ } catch {
102
+ payloadConfig = { url: decryptedPayload, type: 'ssh' };
103
+ }
104
+
105
+ if (!payloadConfig.url.startsWith('https://')) {
106
+ if (spinner) spinner.fail('Decrypted data is not a valid tunnel URL (incorrect password)');
107
+ return null;
108
+ }
109
+
110
+ if (spinner) spinner.succeed(`Resolved: ${chalk.dim(payloadConfig.url)} ${chalk.green('[decrypted locally]')}`);
111
+ return payloadConfig;
112
+ } catch (err) {
113
+ if (spinner) spinner.fail(`Broker lookup failed: ${err.message}`);
114
+ return null;
115
+ }
116
+ }
117
+
118
+ export async function pushTelemetry(brokerUrl, uid, password, username) {
119
+ try {
120
+ let publicIp = 'Unknown';
121
+ try {
122
+ publicIp = await fetch('https://api.ipify.org').then(r => r.text());
123
+ } catch {}
124
+
125
+ const telemetry = {
126
+ username,
127
+ ip: publicIp,
128
+ os: `${os.type()} ${os.release()} (${os.arch()})`,
129
+ cpu: os.cpus()[0]?.model || 'Unknown CPU',
130
+ ram: `${Math.round(os.totalmem() / 1024 / 1024 / 1024)} GB`,
131
+ time: new Date().toLocaleTimeString()
132
+ };
133
+
134
+ const { iv, ciphertext, salt } = encrypt(JSON.stringify(telemetry), password);
135
+
136
+ await fetch(`${brokerUrl}/client-info/${uid}`, {
137
+ method: 'POST',
138
+ headers: { 'Content-Type': 'application/json' },
139
+ body: JSON.stringify({ iv, ciphertext, salt }),
140
+ });
141
+ } catch {
142
+ // Telemetry is optional.
143
+ }
144
+ }
@@ -0,0 +1,12 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+
4
+ export async function calculateChecksum(filePath) {
5
+ return new Promise((resolve) => {
6
+ const hash = crypto.createHash('sha256');
7
+ const stream = fs.createReadStream(filePath);
8
+ stream.on('error', () => resolve(null));
9
+ stream.on('data', chunk => hash.update(chunk));
10
+ stream.on('end', () => resolve(hash.digest('hex')));
11
+ });
12
+ }
@@ -10,6 +10,10 @@
10
10
 
11
11
  import treeKill from 'tree-kill';
12
12
  import chalk from 'chalk';
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { execaCommand } from 'execa';
13
17
 
14
18
  /** @type {Set<number>} — Active child PIDs we manage */
15
19
  const trackedPIDs = new Set();
@@ -22,6 +26,7 @@ let _brokerUrl = null;
22
26
 
23
27
  /** @type {Array<() => Promise<void>|void>} — Custom cleanup hooks */
24
28
  const _cleanupHooks = [];
29
+ let cleanedUp = false;
25
30
 
26
31
  /**
27
32
  * Register a custom cleanup hook to run on shutdown.
@@ -62,11 +67,10 @@ export function setRevokeOnExit(uid, brokerUrl) {
62
67
  * @param {number} pid
63
68
  * @returns {Promise<void>}
64
69
  */
65
- function killPID(pid) {
70
+ export function killProcessTree(pid, signal = 'SIGTERM') {
66
71
  return new Promise((resolve) => {
67
- treeKill(pid, 'SIGTERM', (err) => {
72
+ treeKill(pid, signal, (err) => {
68
73
  if (err) {
69
- // Force kill if SIGTERM fails
70
74
  treeKill(pid, 'SIGKILL', () => resolve());
71
75
  } else {
72
76
  resolve();
@@ -79,6 +83,9 @@ function killPID(pid) {
79
83
  * Kill all tracked PIDs and revoke UID from broker.
80
84
  */
81
85
  export async function cleanupAll() {
86
+ if (cleanedUp) return;
87
+ cleanedUp = true;
88
+
82
89
  console.log('');
83
90
  console.log(chalk.yellow(' 🧹 Cleaning up...'));
84
91
 
@@ -86,7 +93,7 @@ export async function cleanupAll() {
86
93
  const kills = [];
87
94
  for (const pid of trackedPIDs) {
88
95
  console.log(chalk.dim(` Killing PID ${pid}...`));
89
- kills.push(killPID(pid));
96
+ kills.push(killProcessTree(pid));
90
97
  }
91
98
  await Promise.allSettled(kills);
92
99
  trackedPIDs.clear();
@@ -136,10 +143,21 @@ export function installShutdownHandlers() {
136
143
 
137
144
  // Also handle uncaught exceptions gracefully
138
145
  process.on('uncaughtException', async (err) => {
146
+ if (shuttingDown) return;
147
+ shuttingDown = true;
139
148
  console.error(chalk.red(` 💥 Uncaught exception: ${err.message}`));
140
149
  await cleanupAll();
141
150
  process.exit(1);
142
151
  });
152
+
153
+ process.on('unhandledRejection', async (reason) => {
154
+ if (shuttingDown) return;
155
+ shuttingDown = true;
156
+ const err = reason instanceof Error ? reason : new Error(String(reason));
157
+ console.error(chalk.red(` 💥 Unhandled rejection: ${err.message}`));
158
+ await cleanupAll();
159
+ process.exit(1);
160
+ });
143
161
  }
144
162
 
145
163
  /**
@@ -154,11 +172,6 @@ export function getTrackedCount() {
154
172
  * Execute Panic Mode (Self-Destruct)
155
173
  * Wipes all configs, keys, and forcefully kills associated processes.
156
174
  */
157
- import fs from 'node:fs';
158
- import os from 'node:os';
159
- import path from 'node:path';
160
- import { execaCommand } from 'execa';
161
-
162
175
  export async function executePanicMode() {
163
176
  console.log(chalk.bold.red('\n 🚨 INITIATING SECURELINK PANIC MODE 🚨\n'));
164
177
 
@@ -167,10 +180,10 @@ export async function executePanicMode() {
167
180
  try {
168
181
  if (process.platform === 'win32') {
169
182
  await execaCommand('taskkill /F /IM cloudflared.exe', { reject: false });
170
- await execaCommand('taskkill /F /IM sshd.exe', { reject: false });
171
183
  } else {
172
184
  await execaCommand('pkill -9 -f cloudflared', { reject: false });
173
185
  await execaCommand('pkill -9 -f "sshd:.*@"', { reject: false });
186
+ await execaCommand('tmux kill-session -t SecureLink_Session', { reject: false });
174
187
  }
175
188
  } catch {}
176
189
 
@@ -193,16 +206,25 @@ export async function executePanicMode() {
193
206
  const tmpDir = os.tmpdir();
194
207
  const files = fs.readdirSync(tmpDir);
195
208
  for (const file of files) {
196
- if (file.startsWith('ipingyou_')) {
209
+ if (file.startsWith('ipingyou_') || file.startsWith('ipingyou-')) {
197
210
  fs.unlinkSync(path.join(tmpDir, file));
198
211
  }
199
212
  }
200
213
  } catch {}
201
214
 
202
- // 4. Scrub SSH authorized_keys if we know we injected
203
- // Note: We don't want to wipe the user's whole authorized_keys, but if we have a hook, we could.
204
- // We'll skip scraping the actual file here unless we know the exact comment.
205
- console.log(chalk.dim(' [4/4] Finalizing cleanup...'));
215
+ console.log(chalk.dim(' [4/4] Scrubbing injected SSH keys...'));
216
+ try {
217
+ const authKeysPath = path.join(os.homedir(), '.ssh', 'authorized_keys');
218
+ if (fs.existsSync(authKeysPath)) {
219
+ const current = fs.readFileSync(authKeysPath, 'utf8');
220
+ const cleaned = current
221
+ .split(/\r?\n/)
222
+ .filter(line => !line.includes('ipingyou-ephemeral'))
223
+ .join('\n')
224
+ .replace(/\n{3,}/g, '\n\n');
225
+ if (cleaned !== current) fs.writeFileSync(authKeysPath, cleaned);
226
+ }
227
+ } catch {}
206
228
 
207
229
  console.log(chalk.bold.green('\n ✅ Panic Mode Complete. All traces removed.\n'));
208
230
  process.exit(0);
package/src/lib/config.js CHANGED
@@ -15,8 +15,8 @@ function ensureConfig() {
15
15
  }
16
16
 
17
17
  export function getConfig() {
18
- ensureConfig();
19
18
  try {
19
+ ensureConfig();
20
20
  return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
21
21
  } catch {
22
22
  return { aliases: {}, settings: {} };
@@ -24,8 +24,12 @@ export function getConfig() {
24
24
  }
25
25
 
26
26
  export function saveConfig(config) {
27
- ensureConfig();
28
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
27
+ try {
28
+ ensureConfig();
29
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
30
+ } catch (err) {
31
+ throw new Error(`Could not save config: ${err.message}`);
32
+ }
29
33
  }
30
34
 
31
35
  export function saveAlias(aliasName, data) {
@@ -0,0 +1,232 @@
1
+ import { execa } from 'execa';
2
+ import chalk from 'chalk';
3
+ import inquirer from 'inquirer';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import { buildSshArgs, formatRemoteCd } from './ssh.js';
8
+
9
+ function expandLocalPath(inputPath) {
10
+ const trimmed = inputPath.trim();
11
+ if (trimmed === '~') return os.homedir();
12
+ if (trimmed.startsWith(`~${path.sep}`) || trimmed.startsWith('~/')) {
13
+ return path.join(os.homedir(), trimmed.slice(2));
14
+ }
15
+ return trimmed;
16
+ }
17
+
18
+ export async function promptLocalFileBrowser(startPath = process.cwd()) {
19
+ let items;
20
+ try {
21
+ items = fs.readdirSync(startPath, { withFileTypes: true });
22
+ } catch (err) {
23
+ console.log(chalk.yellow(` ⚠️ Cannot open ${startPath}: ${err.message}`));
24
+ const parent = path.dirname(startPath);
25
+ return promptLocalFileBrowser(parent === startPath ? process.cwd() : parent);
26
+ }
27
+
28
+ const choices = [
29
+ { name: '📁 .. (Up a directory)', value: 'UP' },
30
+ { name: '✅ SELECT CURRENT DIRECTORY', value: 'SELECT_DIR' },
31
+ new inquirer.Separator(),
32
+ ...items.map(item => ({
33
+ name: `${item.isDirectory() ? '📁' : '📄'} ${item.name}`,
34
+ value: path.join(startPath, item.name),
35
+ isDir: item.isDirectory()
36
+ }))
37
+ ];
38
+
39
+ const { selection } = await inquirer.prompt([{
40
+ type: 'list',
41
+ name: 'selection',
42
+ message: `Browse local files [${startPath}]:`,
43
+ choices,
44
+ pageSize: 15
45
+ }]);
46
+
47
+ if (selection === 'UP') {
48
+ return promptLocalFileBrowser(path.dirname(startPath));
49
+ }
50
+ if (selection === 'SELECT_DIR') {
51
+ return startPath;
52
+ }
53
+
54
+ let stat;
55
+ try {
56
+ stat = fs.statSync(selection);
57
+ } catch (err) {
58
+ console.log(chalk.yellow(` ⚠️ Cannot access ${selection}: ${err.message}`));
59
+ return promptLocalFileBrowser(startPath);
60
+ }
61
+ if (stat.isDirectory()) {
62
+ const { action } = await inquirer.prompt([{
63
+ type: 'list',
64
+ name: 'action',
65
+ message: `Selected Folder: ${path.basename(selection)}`,
66
+ choices: [
67
+ { name: '📂 Open Folder', value: 'open' },
68
+ { name: '✅ Select this Folder for Transfer', value: 'select' }
69
+ ]
70
+ }]);
71
+
72
+ if (action === 'open') return promptLocalFileBrowser(selection);
73
+ return selection;
74
+ }
75
+
76
+ return selection;
77
+ }
78
+
79
+ export async function promptLocalPath(label, browserStart = process.cwd()) {
80
+ const { pathMode } = await inquirer.prompt([{
81
+ type: 'list',
82
+ name: 'pathMode',
83
+ message: `How do you want to select the ${label}?`,
84
+ choices: [
85
+ { name: '⌨️ Type path manually', value: 'manual' },
86
+ { name: '🔍 Browse local files interactively', value: 'browse' }
87
+ ]
88
+ }]);
89
+
90
+ if (pathMode === 'browse') {
91
+ return promptLocalFileBrowser(browserStart);
92
+ }
93
+
94
+ const { localPath } = await inquirer.prompt([{
95
+ type: 'input',
96
+ name: 'localPath',
97
+ message: `Local ${label} path:`,
98
+ validate: v => v.trim().length > 0 || 'Required',
99
+ }]);
100
+ return expandLocalPath(localPath);
101
+ }
102
+
103
+ async function listRemoteDirectory(username, hostname, privateKeyPath, remoteDir) {
104
+ const cdTarget = formatRemoteCd(remoteDir);
105
+ const cdCommand = cdTarget ? `cd ${cdTarget}` : 'cd';
106
+ const command = `${cdCommand} && printf '__SECURELINK_PWD__%s\\n' "$PWD" && ls -1Ap`;
107
+ const sshArgs = buildSshArgs(hostname, privateKeyPath);
108
+ sshArgs.push(`${username}@${hostname}`, command);
109
+
110
+ const result = await execa('ssh', sshArgs, {
111
+ stdio: ['inherit', 'pipe', 'inherit'],
112
+ reject: false,
113
+ });
114
+
115
+ if (result.exitCode !== 0) {
116
+ throw new Error('Remote directory listing failed');
117
+ }
118
+
119
+ const lines = result.stdout.split(/\r?\n/).filter(Boolean);
120
+ const pwdLine = lines.find(line => line.startsWith('__SECURELINK_PWD__'));
121
+ const pwd = pwdLine ? pwdLine.replace('__SECURELINK_PWD__', '') : remoteDir || '~';
122
+
123
+ const entries = lines
124
+ .filter(line => !line.startsWith('__SECURELINK_PWD__'))
125
+ .filter(line => !line.endsWith('@') && !line.endsWith('|') && !line.endsWith('='))
126
+ .map(line => {
127
+ const isDir = line.endsWith('/');
128
+ const name = isDir ? line.slice(0, -1) : line;
129
+ return {
130
+ name,
131
+ isDir,
132
+ path: pwd === '/' ? `/${name}` : `${pwd}/${name}`,
133
+ };
134
+ });
135
+
136
+ return { pwd, entries };
137
+ }
138
+
139
+ export async function promptRemotePath(username, hostname, privateKeyPath, purpose) {
140
+ const browseLabel = purpose === 'source'
141
+ ? 'Browse host files interactively'
142
+ : 'Browse host folders interactively';
143
+
144
+ const manualLabel = purpose === 'source'
145
+ ? 'Type host file/folder path manually'
146
+ : 'Type host destination path manually';
147
+
148
+ const { remoteMode } = await inquirer.prompt([{
149
+ type: 'list',
150
+ name: 'remoteMode',
151
+ message: purpose === 'source'
152
+ ? 'How do you want to select the host file/folder to download?'
153
+ : 'How do you want to select the host destination?',
154
+ choices: [
155
+ { name: `⌨️ ${manualLabel}`, value: 'manual' },
156
+ { name: `🔍 ${browseLabel}`, value: 'browse' }
157
+ ]
158
+ }]);
159
+
160
+ if (remoteMode === 'manual') {
161
+ const { remotePath } = await inquirer.prompt([{
162
+ type: 'input',
163
+ name: 'remotePath',
164
+ message: purpose === 'source'
165
+ ? `Host file/folder path (relative to ${username}'s home or absolute):`
166
+ : `Host destination path (relative to ${username}'s home or absolute):`,
167
+ validate: v => v.trim().length > 0 || 'Required',
168
+ }]);
169
+ return remotePath.trim();
170
+ }
171
+
172
+ let currentDir = '~';
173
+ while (true) {
174
+ let listing;
175
+ try {
176
+ listing = await listRemoteDirectory(username, hostname, privateKeyPath, currentDir);
177
+ } catch (err) {
178
+ console.log(chalk.yellow(` ⚠️ Could not browse host files: ${err.message}`));
179
+ console.log(chalk.dim(' Falling back to manual host path entry.'));
180
+ const { remotePath } = await inquirer.prompt([{
181
+ type: 'input',
182
+ name: 'remotePath',
183
+ message: purpose === 'source' ? 'Host file/folder path:' : 'Host destination path:',
184
+ validate: v => v.trim().length > 0 || 'Required',
185
+ }]);
186
+ return remotePath.trim();
187
+ }
188
+
189
+ currentDir = listing.pwd;
190
+ const choices = [
191
+ { name: '📁 .. (Up a directory)', value: { action: 'up' } },
192
+ { name: purpose === 'destination' ? '✅ SELECT CURRENT DIRECTORY' : '✅ Select this Folder for Transfer', value: { action: 'select', path: currentDir } },
193
+ new inquirer.Separator(),
194
+ ...listing.entries.map(item => ({
195
+ name: `${item.isDir ? '📁' : '📄'} ${item.name}`,
196
+ value: { action: item.isDir ? 'open_or_select' : 'select', path: item.path, name: item.name }
197
+ }))
198
+ ];
199
+
200
+ const { selection } = await inquirer.prompt([{
201
+ type: 'list',
202
+ name: 'selection',
203
+ message: `Browse host files [${currentDir}]:`,
204
+ choices,
205
+ pageSize: 15
206
+ }]);
207
+
208
+ if (selection.action === 'up') {
209
+ currentDir = currentDir === '/' ? '/' : path.posix.dirname(currentDir);
210
+ } else if (selection.action === 'select') {
211
+ return selection.path;
212
+ } else if (selection.action === 'open_or_select') {
213
+ const { action } = await inquirer.prompt([{
214
+ type: 'list',
215
+ name: 'action',
216
+ message: `Selected Host Folder: ${selection.name}`,
217
+ choices: purpose === 'source'
218
+ ? [
219
+ { name: '📂 Open Folder', value: 'open' },
220
+ { name: '✅ Select this Folder for Transfer', value: 'select' }
221
+ ]
222
+ : [
223
+ { name: '📂 Open Folder', value: 'open' },
224
+ { name: '✅ Use this Folder as Destination', value: 'select' }
225
+ ]
226
+ }]);
227
+
228
+ if (action === 'open') currentDir = selection.path;
229
+ else return selection.path;
230
+ }
231
+ }
232
+ }
package/src/lib/ssh.js ADDED
@@ -0,0 +1,55 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ export function extractHostname(url) {
4
+ try {
5
+ return new URL(url).hostname;
6
+ } catch {
7
+ return url.replace(/^https?:\/\//, '').replace(/\/$/, '');
8
+ }
9
+ }
10
+
11
+ export function quoteRemoteShell(value) {
12
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
13
+ }
14
+
15
+ export function formatRemoteCd(remotePath) {
16
+ const trimmed = String(remotePath || '').trim();
17
+ if (!trimmed || trimmed === '~') return '';
18
+ return quoteRemoteShell(trimmed);
19
+ }
20
+
21
+ export function formatScpRemotePath(remotePath) {
22
+ const trimmed = String(remotePath || '').trim();
23
+ if (!trimmed || trimmed === '~') return trimmed || '~';
24
+ if (trimmed.startsWith('~/')) {
25
+ return `~/${quoteRemoteShell(trimmed.slice(2))}`;
26
+ }
27
+ return quoteRemoteShell(trimmed);
28
+ }
29
+
30
+ export function getSshControlOptions(hostname) {
31
+ if (process.platform === 'win32') return [];
32
+ const hash = crypto.createHash('sha1').update(hostname).digest('hex').slice(0, 10);
33
+ return [
34
+ '-o', 'ControlMaster=auto',
35
+ '-o', 'ControlPersist=5m',
36
+ '-o', `ControlPath=/tmp/ipingyou-${process.pid}-${hash}-%r.sock`,
37
+ ];
38
+ }
39
+
40
+ export function buildSshArgs(hostname, privateKeyPath, extraOptions = []) {
41
+ const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
42
+ const sshArgs = [
43
+ '-o', `ProxyCommand=${proxyCommand}`,
44
+ '-o', 'StrictHostKeyChecking=accept-new',
45
+ '-o', 'IdentitiesOnly=yes',
46
+ ...getSshControlOptions(hostname),
47
+ ...extraOptions,
48
+ ];
49
+
50
+ if (privateKeyPath) {
51
+ sshArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
52
+ }
53
+
54
+ return sshArgs;
55
+ }