peer-term 1.3.9 → 1.4.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.
package/bin/peer-term.js CHANGED
@@ -47,7 +47,7 @@ process.on('unhandledRejection', (reason) => {
47
47
 
48
48
  // ─── Launch Main ─────────────────────────────────────────────────────────────
49
49
 
50
- import('../src/index.js').catch((err) => {
50
+ import('../src/cli.js').catch((err) => {
51
51
  logFatalError('Failed to start PeerTerm', err);
52
52
  process.exit(1);
53
53
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peer-term",
3
- "version": "1.3.9",
3
+ "version": "1.4.0",
4
4
  "license": "MIT",
5
5
  "description": "Share your terminal instantly using a 6-digit code. Encrypted. No config.",
6
6
  "repository": {
package/src/cli.js ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * PeerTerm CLI — Interactive session manager
3
+ *
4
+ * Top-level orchestration:
5
+ * 1. Detects shell, resolves start path, runs interactive prompts
6
+ * 2. Creates a SessionManager and starts the first session
7
+ * 3. Provides a readline-based menu (n/l/k/a/q) for managing sessions
8
+ * 4. Handles SIGINT and global error handlers
9
+ */
10
+
11
+ import readline from 'readline';
12
+ import logger from './logger.js';
13
+ import { printBanner } from './ui.js';
14
+ import { argv, RELAY_URLS } from './config.js';
15
+ import { formatDuration, detectShell, resolveStartPath } from './utils.js';
16
+ import { getExpiryInteractive, getRejoinInteractive } from './prompts.js';
17
+ import { SessionManager } from './index.js';
18
+
19
+ // ─── Main ────────────────────────────────────────────────────────────────────
20
+
21
+ async function main() {
22
+ const shell = detectShell();
23
+ const readOnly = argv.readonly;
24
+ const startPath = resolveStartPath(argv.path);
25
+
26
+ // Print startup banner
27
+ printBanner();
28
+
29
+ // Interactive prompts — always ask unless flags are passed or non-interactive
30
+ const expiryMs = await getExpiryInteractive();
31
+ const rejoinMs = await getRejoinInteractive();
32
+ console.log('');
33
+
34
+ logger.info(`Shell: ${shell}`);
35
+ logger.info(`Relays: ${RELAY_URLS.join(', ')}`);
36
+ logger.info(`Path: ${startPath}`);
37
+ logger.info(`Expiry: ${formatDuration(expiryMs)}`);
38
+ logger.info(`Rejoin Window: ${formatDuration(rejoinMs)}`);
39
+ if (readOnly) logger.info('Mode: READ-ONLY');
40
+ if (argv.verbose) logger.info('Verbose logging enabled');
41
+ console.log('');
42
+
43
+ const manager = new SessionManager(shell, expiryMs, rejoinMs, readOnly, startPath);
44
+
45
+ // Create first session automatically
46
+ const firstCode = await manager.createSession();
47
+ if (!firstCode) {
48
+ logger.error('Failed to start. Is the relay server running?');
49
+ process.exit(1);
50
+ }
51
+
52
+ // ─── Interactive CLI menu ──────────────────────────────────────────
53
+ console.log(' ─────────────────────────────────────────');
54
+ console.log(' Commands:');
55
+ console.log(' [n] New session');
56
+ console.log(' [l] List sessions');
57
+ console.log(' [a <code>] Authorize verified fingerprint');
58
+ console.log(' [k <code>] Kill session');
59
+ console.log(' [q] Quit all');
60
+ console.log(' ─────────────────────────────────────────');
61
+ console.log('');
62
+
63
+ const rl = readline.createInterface({
64
+ input: process.stdin,
65
+ output: process.stdout,
66
+ prompt: ' > ',
67
+ });
68
+
69
+ rl.prompt();
70
+
71
+ rl.on('line', async (line) => {
72
+ const input = line.trim();
73
+
74
+ if (input === 'n') {
75
+ logger.info('Creating new session...');
76
+ await manager.createSession();
77
+ } else if (input === 'l') {
78
+ manager.listSessions();
79
+ } else if (input.startsWith('a ')) {
80
+ const code = input.slice(2).trim();
81
+ await manager.authorizeSession(code);
82
+ } else if (input.startsWith('k ')) {
83
+ const code = input.slice(2).trim();
84
+ manager.killSession(code);
85
+ } else if (input === 'q') {
86
+ logger.info('Shutting down all sessions...');
87
+ manager.killAll();
88
+ rl.close();
89
+ process.exit(0);
90
+ } else if (input) {
91
+ console.log(' Unknown command. Use n, l, a <code>, k <code>, or q.');
92
+ }
93
+
94
+ rl.prompt();
95
+ });
96
+
97
+ rl.on('close', () => {
98
+ manager.killAll();
99
+ process.exit(0);
100
+ });
101
+
102
+ process.on('SIGINT', () => {
103
+ console.log('');
104
+ logger.info('Shutting down...');
105
+ manager.killAll();
106
+ rl.close();
107
+ process.exit(0);
108
+ });
109
+ }
110
+
111
+ // ─── Global Error Handling ───────────────────────────────────────────────────
112
+
113
+ process.on('uncaughtException', (err) => {
114
+ logger.error(`Uncaught exception: ${err.message}`, err);
115
+ process.exit(1);
116
+ });
117
+
118
+ process.on('unhandledRejection', (reason) => {
119
+ const err = reason instanceof Error ? reason : new Error(String(reason));
120
+ logger.error(`Unhandled rejection: ${err.message}`, err);
121
+ });
122
+
123
+ // ─── Run ─────────────────────────────────────────────────────────────────────
124
+
125
+ main().catch((err) => {
126
+ logger.error(`Fatal error: ${err.message}`, err);
127
+ process.exit(1);
128
+ });
package/src/config.js ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * PeerTerm — Configuration Module
3
+ *
4
+ * CLI argument parsing, flag detection, and static configuration constants.
5
+ * Single source of truth for user-provided options and app-wide constants.
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { fileURLToPath } from 'url';
11
+ import * as dotenv from 'dotenv';
12
+ import minimist from 'minimist';
13
+ import logger from './logger.js';
14
+ import { printHelp, printVersion } from './ui.js';
15
+
16
+ // ─── File paths ──────────────────────────────────────────────────────────────
17
+ const __filename = fileURLToPath(import.meta.url);
18
+ const __dirname = path.dirname(__filename);
19
+
20
+ // Load .env from host/ directory (parent of src/)
21
+ dotenv.config({ path: path.join(__dirname, '..', '.env') });
22
+
23
+ // ─── Version ─────────────────────────────────────────────────────────────────
24
+
25
+ let version = 'unknown';
26
+ try {
27
+ const pkgPath = path.join(__dirname, '..', 'package.json');
28
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
29
+ version = pkg.version;
30
+ } catch {}
31
+
32
+ // ─── CLI Argument Parsing ────────────────────────────────────────────────────
33
+
34
+ const argv = minimist(process.argv.slice(2), {
35
+ boolean: ['readonly', 'verbose', 'help', 'version', 'secure'],
36
+ string: ['expiry', 'rejoin', 'relay', 'path'],
37
+ alias: { h: 'help', v: 'version', V: 'verbose' },
38
+ });
39
+
40
+ // Detect if --expiry or --rejoin were explicitly passed on the command line
41
+ const expiryFlagPassed = process.argv.some(a => a === '--expiry' || a.startsWith('--expiry='));
42
+ const rejoinFlagPassed = process.argv.some(a => a === '--rejoin' || a.startsWith('--rejoin='));
43
+
44
+ // Handle --help
45
+ if (argv.help) {
46
+ printHelp();
47
+ process.exit(0);
48
+ }
49
+
50
+ // Handle --version
51
+ if (argv.version) {
52
+ printVersion();
53
+ process.exit(0);
54
+ }
55
+
56
+ // Enable verbose logging
57
+ if (argv.verbose) {
58
+ logger.setVerbose(true);
59
+ }
60
+
61
+ // ─── Static Configuration ────────────────────────────────────────────────────
62
+
63
+ const DEFAULT_RELAYS = [
64
+ 'wss://relay.dhananjaybalekar.in'
65
+ ];
66
+
67
+ const RELAY_URLS = argv.relay
68
+ ? argv.relay.split(',').map(s => s.trim())
69
+ : (process.env.RELAY_URL ? process.env.RELAY_URL.split(',').map(s => s.trim()) : DEFAULT_RELAYS);
70
+
71
+ const HEARTBEAT_INTERVAL_MS = 5000;
72
+ const MAX_MISSED_PINGS = 2;
73
+
74
+ const UPLOAD_MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB max
75
+ const UPLOAD_MAX_CHUNKS_PER_FILE = Math.ceil(UPLOAD_MAX_FILE_SIZE / 16384);
76
+ const UPLOAD_MAX_CONCURRENT = 3;
77
+
78
+ // ─── Exports ─────────────────────────────────────────────────────────────────
79
+
80
+ export {
81
+ argv,
82
+ version,
83
+ expiryFlagPassed,
84
+ rejoinFlagPassed,
85
+ RELAY_URLS,
86
+ HEARTBEAT_INTERVAL_MS,
87
+ MAX_MISSED_PINGS,
88
+ UPLOAD_MAX_FILE_SIZE,
89
+ UPLOAD_MAX_CHUNKS_PER_FILE,
90
+ UPLOAD_MAX_CONCURRENT,
91
+ };
@@ -0,0 +1,230 @@
1
+ /**
2
+ * PeerTerm — File Transfer Handler
3
+ *
4
+ * Encapsulates all file upload protocol logic:
5
+ * - Routing file-start, file-chunk, file-end, file-cancel messages
6
+ * - Chunk validation and size enforcement
7
+ * - File reassembly and disk writes
8
+ * - Ack/error responses back to the client
9
+ */
10
+
11
+ import os from 'os';
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import { encrypt } from './crypto.js';
15
+ import {
16
+ UPLOAD_MAX_FILE_SIZE,
17
+ UPLOAD_MAX_CHUNKS_PER_FILE,
18
+ UPLOAD_MAX_CONCURRENT,
19
+ } from './config.js';
20
+
21
+ /**
22
+ * @typedef {Object} SessionContext
23
+ * @property {CryptoKey|null} sharedKey - Current E2E shared key
24
+ * @property {boolean} readOnly - Whether the session is read-only
25
+ * @property {Object|null} ptyProcess - The PTY process instance
26
+ * @property {function} log - Session-scoped info logger
27
+ * @property {function} logDebug - Session-scoped debug logger
28
+ * @property {function} sendEncrypted - Send encrypted payload to client
29
+ */
30
+
31
+ export class FileTransferHandler {
32
+ /**
33
+ * @param {SessionContext} ctx - Session context for callbacks and state access
34
+ */
35
+ constructor(ctx) {
36
+ this._ctx = ctx;
37
+
38
+ /** @type {Object.<string, {name: string, size: number, totalChunks: number, receivedSize: number, chunks: Map}>} */
39
+ this._incomingFiles = {};
40
+ }
41
+
42
+ /**
43
+ * Update the session context (e.g. after key re-exchange).
44
+ * @param {SessionContext} ctx
45
+ */
46
+ updateContext(ctx) {
47
+ this._ctx = ctx;
48
+ }
49
+
50
+ /**
51
+ * Clean up all in-progress transfers.
52
+ */
53
+ cleanup() {
54
+ this._incomingFiles = {};
55
+ }
56
+
57
+ /**
58
+ * Route a file transfer message to the appropriate handler.
59
+ *
60
+ * @param {Object} msg - Parsed file message with type, id, and payload fields
61
+ */
62
+ handleMessage(msg) {
63
+ // Defense-in-depth: reject all file operations in read-only sessions
64
+ if (this._ctx.readOnly) {
65
+ this._sendError(msg.id, 'File uploads are disabled in read-only mode');
66
+ return;
67
+ }
68
+
69
+ switch (msg.type) {
70
+ case 'file-start': {
71
+ // Validate required fields
72
+ if (!msg.id || !msg.name || msg.size === undefined || msg.totalChunks === undefined) {
73
+ this._sendError(msg.id, 'Invalid file-start message');
74
+ return;
75
+ }
76
+
77
+ if (msg.size > UPLOAD_MAX_FILE_SIZE) {
78
+ this._sendError(msg.id, `File exceeds maximum size of ${UPLOAD_MAX_FILE_SIZE / 1024 / 1024}MB`);
79
+ return;
80
+ }
81
+
82
+ if (msg.totalChunks > UPLOAD_MAX_CHUNKS_PER_FILE) {
83
+ this._sendError(msg.id, 'File requires too many chunks');
84
+ return;
85
+ }
86
+
87
+ if (Object.keys(this._incomingFiles).length >= UPLOAD_MAX_CONCURRENT) {
88
+ this._sendError(msg.id, 'Too many concurrent uploads');
89
+ return;
90
+ }
91
+
92
+ this._ctx.log(`File upload started: "${msg.name}" (${msg.size} bytes, ${msg.totalChunks} chunks)`);
93
+ this._incomingFiles[msg.id] = {
94
+ name: msg.name,
95
+ size: msg.size,
96
+ totalChunks: msg.totalChunks,
97
+ receivedSize: 0,
98
+ chunks: new Map(),
99
+ };
100
+ break;
101
+ }
102
+ case 'file-chunk': {
103
+ const transfer = this._incomingFiles[msg.id];
104
+ if (!transfer) {
105
+ this._sendError(msg.id, 'Unknown transfer ID');
106
+ return;
107
+ }
108
+
109
+ const chunkData = Buffer.from(msg.data, 'base64');
110
+
111
+ if (transfer.chunks.size >= transfer.totalChunks) {
112
+ this._sendError(msg.id, 'Too many chunks received');
113
+ delete this._incomingFiles[msg.id];
114
+ return;
115
+ }
116
+
117
+ if (transfer.receivedSize + chunkData.length > transfer.size) {
118
+ this._sendError(msg.id, 'Received size exceeds declared size');
119
+ delete this._incomingFiles[msg.id];
120
+ return;
121
+ }
122
+
123
+ transfer.chunks.set(msg.index, chunkData);
124
+ transfer.receivedSize += chunkData.length;
125
+ this._ctx.logDebug(`File chunk ${msg.index + 1}/${transfer.totalChunks} received for "${transfer.name}"`);
126
+ break;
127
+ }
128
+ case 'file-end': {
129
+ this._finalizeFile(msg.id);
130
+ break;
131
+ }
132
+ case 'file-cancel': {
133
+ if (this._incomingFiles[msg.id]) {
134
+ delete this._incomingFiles[msg.id];
135
+ this._ctx.log(`File upload cancelled by client: ${msg.id}`);
136
+ }
137
+ break;
138
+ }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Reassemble all chunks in order and write the completed file to disk.
144
+ * Saves to ~/peerterm-uploads/<filename>.
145
+ */
146
+ async _finalizeFile(id) {
147
+ const transfer = this._incomingFiles[id];
148
+ if (!transfer) {
149
+ this._sendError(id, 'Unknown transfer ID');
150
+ return;
151
+ }
152
+
153
+ // Check all chunks are present
154
+ for (let i = 0; i < transfer.totalChunks; i++) {
155
+ if (!transfer.chunks.has(i)) {
156
+ this._ctx.log(`File "${transfer.name}" missing chunk ${i}`);
157
+ this._sendError(id, `Missing chunk ${i}`);
158
+ delete this._incomingFiles[id];
159
+ return;
160
+ }
161
+ }
162
+
163
+ // Reassemble in order using Buffer.concat
164
+ const ordered = [];
165
+ for (let i = 0; i < transfer.totalChunks; i++) {
166
+ ordered.push(transfer.chunks.get(i));
167
+ }
168
+ const fileData = Buffer.concat(ordered);
169
+
170
+ // Determine save path: ~/peerterm-uploads/<filename>
171
+ const homeDir = os.homedir();
172
+ const uploadDir = path.join(homeDir, 'peerterm-uploads');
173
+ // Sanitize filename to prevent path traversal (e.g., "../../../etc/passwd")
174
+ const baseName = path.basename(transfer.name);
175
+ if (!baseName || baseName === '.' || baseName === '..') {
176
+ this._sendError(id, 'Invalid filename');
177
+ delete this._incomingFiles[id];
178
+ return;
179
+ }
180
+ const savePath = path.join(uploadDir, baseName);
181
+
182
+ try {
183
+ await fs.promises.mkdir(uploadDir, { recursive: true });
184
+ await fs.promises.writeFile(savePath, fileData);
185
+
186
+ this._ctx.log(`File saved: ${savePath}`);
187
+
188
+ // Write green ANSI message into the PTY to show the save path
189
+ if (this._ctx.ptyProcess) {
190
+ this._ctx.ptyProcess.write(`\r\n\x1b[32m[PeerTerm] File saved: ${savePath}\x1b[0m\r\n`);
191
+ }
192
+
193
+ // Send ack to client
194
+ this._sendAck(id, transfer.name);
195
+ } catch (err) {
196
+ this._ctx.log(`Failed to save file "${transfer.name}": ${err.message}`);
197
+ this._sendError(id, `Save failed: ${err.message}`);
198
+ }
199
+
200
+ delete this._incomingFiles[id];
201
+ }
202
+
203
+ /**
204
+ * Send file-ack to the client confirming successful file save.
205
+ */
206
+ async _sendAck(id, name) {
207
+ if (!this._ctx.sharedKey) return;
208
+ try {
209
+ const ackJson = JSON.stringify({ type: 'file-ack', id, name });
210
+ const payload = await encrypt(this._ctx.sharedKey, ackJson);
211
+ this._ctx.sendEncrypted(payload);
212
+ } catch (err) {
213
+ this._ctx.log(`Failed to send file-ack: ${err.message}`);
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Send file-error to the client with an error message.
219
+ */
220
+ async _sendError(id, msg) {
221
+ if (!this._ctx.sharedKey) return;
222
+ try {
223
+ const errJson = JSON.stringify({ type: 'file-error', id, msg });
224
+ const payload = await encrypt(this._ctx.sharedKey, errJson);
225
+ this._ctx.sendEncrypted(payload);
226
+ } catch (err) {
227
+ this._ctx.log(`Failed to send file-error: ${err.message}`);
228
+ }
229
+ }
230
+ }