peer-term 1.3.10 → 1.4.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.
package/src/prompts.js ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * PeerTerm — Interactive Prompts
3
+ *
4
+ * Terminal prompts for session expiry and rejoin window configuration.
5
+ * Handles three modes: CLI flag, interactive TTY prompt, silent default.
6
+ */
7
+
8
+ import readline from 'readline';
9
+ import { parseDuration } from './utils.js';
10
+ import { argv, expiryFlagPassed, rejoinFlagPassed } from './config.js';
11
+ import logger from './logger.js';
12
+
13
+ // ─── Generic Duration Prompt ─────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Prompt the user for a duration value in the terminal.
17
+ * Re-prompts on invalid input until a valid value is entered or Enter is pressed for default.
18
+ *
19
+ * @param {Object} opts
20
+ * @param {string} opts.label - Main prompt question
21
+ * @param {string} opts.description - Additional context shown below the question
22
+ * @param {number} opts.defaultMs - Default value in milliseconds
23
+ * @param {string} opts.defaultLabel - Human-readable default (e.g. "5 minutes")
24
+ * @param {number} opts.minMs - Minimum allowed value in milliseconds
25
+ * @param {number} opts.maxMs - Maximum allowed value in milliseconds
26
+ * @param {string} opts.minLabel - Human-readable minimum (e.g. "2m")
27
+ * @param {string} opts.maxLabel - Human-readable maximum (e.g. "24h")
28
+ * @returns {Promise<number>} Resolved duration in milliseconds
29
+ */
30
+ function promptDuration({ label, description, defaultMs, defaultLabel, minMs, maxMs, minLabel, maxLabel }) {
31
+ return new Promise((resolve) => {
32
+ const rl = readline.createInterface({
33
+ input: process.stdin,
34
+ output: process.stdout,
35
+ });
36
+
37
+ const ask = () => {
38
+ console.log('');
39
+ console.log(` ${label}`);
40
+ if (description) console.log(` ${description}`);
41
+ console.log(` Enter a value between ${minLabel} and ${maxLabel} (e.g. ${minLabel}, 30m, 2h)`);
42
+ console.log(` Default is ${defaultLabel}. Press Enter to use default.`);
43
+ rl.question(' > ', (answer) => {
44
+ const input = answer.trim();
45
+
46
+ // Empty input — use default
47
+ if (!input) {
48
+ rl.close();
49
+ resolve(defaultMs);
50
+ return;
51
+ }
52
+
53
+ const parsed = parseDuration(input);
54
+ if (!parsed || parsed < minMs || parsed > maxMs) {
55
+ console.log(` Invalid value. Please enter a time between ${minLabel} and ${maxLabel} (e.g. 5m, 1h).`);
56
+ ask();
57
+ return;
58
+ }
59
+
60
+ rl.close();
61
+ resolve(parsed);
62
+ });
63
+ };
64
+
65
+ ask();
66
+ });
67
+ }
68
+
69
+ // ─── Expiry ──────────────────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Get the session code expiry — from flag, interactive prompt, or silent default.
73
+ *
74
+ * @returns {Promise<number>} Expiry in milliseconds
75
+ */
76
+ export async function getExpiryInteractive() {
77
+ const DEFAULT_EXPIRY = 5 * 60 * 1000; // 5 minutes
78
+ const MIN_EXPIRY = 2 * 60 * 1000; // 2 minutes
79
+ const MAX_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours
80
+
81
+ // If --expiry flag was explicitly passed, use it (validate and exit on error)
82
+ if (expiryFlagPassed) {
83
+ const parsed = parseDuration(argv.expiry);
84
+ if (!parsed || parsed < MIN_EXPIRY || parsed > MAX_EXPIRY) {
85
+ logger.error(`Invalid expiry: "${argv.expiry}". Must be between 2m and 24h.`);
86
+ process.exit(1);
87
+ }
88
+ return parsed;
89
+ }
90
+
91
+ // Non-interactive — use default silently
92
+ if (!process.stdin.isTTY) return DEFAULT_EXPIRY;
93
+
94
+ // Interactive prompt
95
+ return promptDuration({
96
+ label: 'How long should the session code be valid?',
97
+ description: null,
98
+ defaultMs: DEFAULT_EXPIRY,
99
+ defaultLabel: '5 minutes',
100
+ minMs: MIN_EXPIRY,
101
+ maxMs: MAX_EXPIRY,
102
+ minLabel: '2m',
103
+ maxLabel: '24h',
104
+ });
105
+ }
106
+
107
+ // ─── Rejoin Window ───────────────────────────────────────────────────────────
108
+
109
+ /**
110
+ * Get the reconnection window — from flag, interactive prompt, or silent default.
111
+ *
112
+ * @returns {Promise<number>} Rejoin window in milliseconds
113
+ */
114
+ export async function getRejoinInteractive() {
115
+ const DEFAULT_REJOIN = 5 * 60 * 1000; // 5 minutes
116
+ const MIN_REJOIN = 5 * 60 * 1000; // 5 minutes
117
+ const MAX_REJOIN = 6 * 60 * 60 * 1000; // 6 hours
118
+
119
+ // If --rejoin flag was explicitly passed, use it
120
+ if (rejoinFlagPassed) {
121
+ const parsed = parseDuration(argv.rejoin);
122
+ if (!parsed || parsed < MIN_REJOIN || parsed > MAX_REJOIN) {
123
+ logger.error(`Invalid rejoin window: "${argv.rejoin}". Must be between 5m and 6h.`);
124
+ process.exit(1);
125
+ }
126
+ return parsed;
127
+ }
128
+
129
+ // Non-interactive — use default silently
130
+ if (!process.stdin.isTTY) return DEFAULT_REJOIN;
131
+
132
+ // Interactive prompt
133
+ return promptDuration({
134
+ label: 'How long should the reconnection window be?',
135
+ description: 'This is how long a disconnected client or host has to rejoin before the session ends.',
136
+ defaultMs: DEFAULT_REJOIN,
137
+ defaultLabel: '2 minutes',
138
+ minMs: MIN_REJOIN,
139
+ maxMs: MAX_REJOIN,
140
+ minLabel: '5m',
141
+ maxLabel: '6h',
142
+ });
143
+ }
package/src/ui.js CHANGED
@@ -11,6 +11,7 @@
11
11
  import fs from 'fs';
12
12
  import path from 'path';
13
13
  import { fileURLToPath } from 'url';
14
+ import qrcode from 'qrcode-terminal';
14
15
 
15
16
  const __filename = fileURLToPath(import.meta.url);
16
17
  const __dirname = path.dirname(__filename);
@@ -47,9 +48,10 @@ export function printBanner() {
47
48
  * @param {string} [opts.startPath] - Starting directory for the session
48
49
  * @param {string} [opts.shareUrl] - URL to share (e.g. "https://peerterm.dev")
49
50
  */
50
- export function printSessionBox({ code, expiry, rejoinWindow, mode, shell, startPath, shareUrl }) {
51
+ export async function printSessionBox({ code, expiry, rejoinWindow, mode, shell, startPath, shareUrl }) {
51
52
  const spacedCode = code.split('').join(' ');
52
53
  const url = shareUrl || 'https://peerterm.dev';
54
+ const joinUrl = `${url}?code=${code}`;
53
55
 
54
56
  // Truncate long shell paths to keep box readable
55
57
  const shellDisplay = shell.length > 24 ? '...' + shell.slice(-21) : shell;
@@ -90,6 +92,28 @@ export function printSessionBox({ code, expiry, rejoinWindow, mode, shell, start
90
92
  console.log(` │${emptyLine}│`);
91
93
  console.log(` └${hr}┘`);
92
94
  console.log('');
95
+
96
+ // Render QR code for instant scan-to-join
97
+ try {
98
+ const qrString = await new Promise((resolve) => {
99
+ qrcode.generate(joinUrl, { small: true }, (qr) => {
100
+ resolve(qr);
101
+ });
102
+ });
103
+
104
+ console.log(' Scan to join:');
105
+ console.log('');
106
+ // Indent each QR line for alignment with the box
107
+ const qrLines = qrString.split('\n');
108
+ for (const line of qrLines) {
109
+ if (line.length > 0) console.log(` ${line}`);
110
+ }
111
+ console.log('');
112
+ console.log(` Or open: ${joinUrl}`);
113
+ console.log('');
114
+ } catch {
115
+ // QR generation failed — not critical, code is still shown in the box
116
+ }
93
117
  }
94
118
 
95
119
  // ─── Help Text ───────────────────────────────────────────────────────────────
package/src/utils.js ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * PeerTerm — Utility Functions
3
+ *
4
+ * Generic helpers with no domain dependencies:
5
+ * - Duration parsing and formatting
6
+ * - Path resolution (tilde expansion, start path validation)
7
+ * - Shell detection
8
+ */
9
+
10
+ import os from 'os';
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import logger from './logger.js';
14
+
15
+ // ─── Duration Parsing ────────────────────────────────────────────────────────
16
+
17
+ /**
18
+ * Parse a human-readable duration string into milliseconds.
19
+ * Accepted formats: "30s", "5m", "1h" (case-insensitive).
20
+ *
21
+ * @param {string} str - Duration string
22
+ * @returns {number|null} Duration in milliseconds, or null if invalid
23
+ */
24
+ export function parseDuration(str) {
25
+ const match = str.match(/^(\d+)(s|m|h)$/i);
26
+ if (!match) return null;
27
+ const value = parseInt(match[1], 10);
28
+ const unit = match[2].toLowerCase();
29
+ switch (unit) {
30
+ case 's': return value * 1000;
31
+ case 'm': return value * 60 * 1000;
32
+ case 'h': return value * 60 * 60 * 1000;
33
+ default: return null;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Format a duration in milliseconds to a human-readable string.
39
+ *
40
+ * @param {number} ms - Duration in milliseconds
41
+ * @returns {string} Formatted string (e.g. "5 minute(s)")
42
+ */
43
+ export function formatDuration(ms) {
44
+ if (ms >= 3600000) return `${Math.round(ms / 3600000)} hour(s)`;
45
+ if (ms >= 60000) return `${Math.round(ms / 60000)} minute(s)`;
46
+ return `${Math.round(ms / 1000)} second(s)`;
47
+ }
48
+
49
+ // ─── Path Resolution ─────────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Expand a leading ~ to the user's home directory.
53
+ *
54
+ * @param {string} inputPath
55
+ * @returns {string}
56
+ */
57
+ export function expandTilde(inputPath) {
58
+ if (inputPath.startsWith('~/') || inputPath === '~') {
59
+ return inputPath.replace(/^~/, process.env.HOME || process.env.USERPROFILE || '.');
60
+ }
61
+ return inputPath;
62
+ }
63
+
64
+ /**
65
+ * Resolve and validate a starting directory path.
66
+ * Falls back to the user's home directory if no path is provided.
67
+ * Exits the process if the path doesn't exist or isn't a directory.
68
+ *
69
+ * @param {string|undefined} inputPath
70
+ * @returns {string} Resolved absolute path
71
+ */
72
+ export function resolveStartPath(inputPath) {
73
+ if (!inputPath) return process.env.HOME || process.env.USERPROFILE || process.cwd();
74
+
75
+ const resolved = path.resolve(expandTilde(inputPath));
76
+
77
+ if (!fs.existsSync(resolved)) {
78
+ logger.error(`Path does not exist: ${resolved}`);
79
+ process.exit(1);
80
+ }
81
+
82
+ const stat = fs.statSync(resolved);
83
+ if (!stat.isDirectory()) {
84
+ logger.error(`Path is not a directory: ${resolved}`);
85
+ process.exit(1);
86
+ }
87
+
88
+ return resolved;
89
+ }
90
+
91
+ // ─── Shell Detection ─────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Detect the user's default shell.
95
+ * Checks $SHELL, Windows COMSPEC, /etc/passwd, and falls back to bash/powershell.
96
+ *
97
+ * @returns {string} Shell executable path or name
98
+ */
99
+ export function detectShell() {
100
+ const platform = os.platform();
101
+ if (process.env.SHELL) return process.env.SHELL;
102
+ if (platform === 'win32') return process.env.COMSPEC || 'powershell.exe';
103
+ try {
104
+ const passwd = fs.readFileSync('/etc/passwd', 'utf-8');
105
+ const username = os.userInfo().username;
106
+ const line = passwd.split('\n').find((l) => l.startsWith(username + ':'));
107
+ if (line) {
108
+ const shell = line.split(':').pop().trim();
109
+ if (shell) return shell;
110
+ }
111
+ } catch {}
112
+ return platform === 'win32' ? 'powershell.exe' : 'bash';
113
+ }
package/src/viewer.js ADDED
@@ -0,0 +1,259 @@
1
+ /**
2
+ * PeerTerm — Local Viewer Server
3
+ *
4
+ * Token-authenticated TCP server that opens a new terminal window
5
+ * for the host to view the shared session locally.
6
+ *
7
+ * Cross-platform terminal opener (Windows, macOS, Linux).
8
+ */
9
+
10
+ import crypto from 'crypto';
11
+ import net from 'net';
12
+ import os from 'os';
13
+ import path from 'path';
14
+ import { fileURLToPath } from 'url';
15
+ import { execFile, spawn } from 'child_process';
16
+
17
+ const __filename = fileURLToPath(import.meta.url);
18
+ const __dirname = path.dirname(__filename);
19
+
20
+ export class ViewerServer {
21
+ /**
22
+ * @param {Object} opts
23
+ * @param {string} opts.code - Session code (for window title)
24
+ * @param {function} opts.log - Info logger
25
+ * @param {function} opts.logDebug - Debug logger
26
+ * @param {function} opts.onResize - Called with (cols, rows) when viewer resizes
27
+ * @param {function} opts.onKeystroke - Called with (data) for host keystrokes → PTY
28
+ */
29
+ constructor({ code, log, logDebug, onResize, onKeystroke }) {
30
+ this._code = code;
31
+ this._log = log;
32
+ this._logDebug = logDebug;
33
+ this._onResize = onResize;
34
+ this._onKeystroke = onKeystroke;
35
+
36
+ this._server = null;
37
+ this._socket = null;
38
+ this._token = null;
39
+ }
40
+
41
+ /**
42
+ * The active viewer socket (if connected and authenticated).
43
+ * Used by Session to mirror PTY output to the local viewer.
44
+ * @type {net.Socket|null}
45
+ */
46
+ get socket() {
47
+ return this._socket;
48
+ }
49
+
50
+ /**
51
+ * Start the TCP viewer server on a random port and open a terminal window.
52
+ */
53
+ start() {
54
+ // Generate a random nonce for viewer authentication
55
+ this._token = crypto.randomBytes(16).toString('hex');
56
+
57
+ this._server = net.createServer((socket) => {
58
+ // Close any existing viewer connection before accepting a new one
59
+ if (this._socket) {
60
+ this._logDebug('Replacing existing viewer connection.');
61
+ this._socket.removeAllListeners();
62
+ try { this._socket.destroy(); } catch {}
63
+ }
64
+
65
+ // ── Token authentication gate ──────────────────────────────────
66
+ // The first data packet must be the viewer token followed by a newline.
67
+ // Reject the connection if no valid token arrives within 2 seconds.
68
+ let authenticated = false;
69
+ let authBuf = '';
70
+
71
+ const authTimeout = setTimeout(() => {
72
+ if (!authenticated) {
73
+ this._logDebug('Viewer auth timeout — closing connection.');
74
+ try { socket.destroy(); } catch {}
75
+ }
76
+ }, 2000);
77
+
78
+ const onAuthData = (data) => {
79
+ authBuf += data.toString();
80
+ const newlineIdx = authBuf.indexOf('\n');
81
+ if (newlineIdx === -1) {
82
+ // Accumulated too much data without a newline — reject
83
+ if (authBuf.length > 256) {
84
+ clearTimeout(authTimeout);
85
+ this._logDebug('Viewer auth buffer overflow — closing connection.');
86
+ try { socket.destroy(); } catch {}
87
+ }
88
+ return;
89
+ }
90
+
91
+ const token = authBuf.slice(0, newlineIdx).trim();
92
+ const remaining = authBuf.slice(newlineIdx + 1);
93
+ clearTimeout(authTimeout);
94
+ socket.removeListener('data', onAuthData);
95
+
96
+ if (token !== this._token) {
97
+ this._logDebug('Viewer auth failed — wrong token.');
98
+ try { socket.destroy(); } catch {}
99
+ return;
100
+ }
101
+
102
+ // Authenticated — promote to active viewer
103
+ authenticated = true;
104
+ this._socket = socket;
105
+ this._log('Host viewer connected (authenticated).');
106
+
107
+ // Install the real data handler
108
+ let recvBuf = '';
109
+
110
+ // Process any leftover data that arrived after the token line
111
+ if (remaining.length > 0) {
112
+ handleViewerData(remaining);
113
+ }
114
+
115
+ socket.on('data', (chunk) => handleViewerData(chunk.toString()));
116
+
117
+ const handleViewerData = (chunk) => {
118
+ // Control messages start with \x00{
119
+ if (recvBuf.length > 0 || chunk.startsWith('\x00{')) {
120
+ recvBuf += chunk;
121
+ let nlIdx;
122
+ while ((nlIdx = recvBuf.indexOf('\n')) !== -1) {
123
+ const line = recvBuf.slice(0, nlIdx).trim();
124
+ recvBuf = recvBuf.slice(nlIdx + 1);
125
+
126
+ if (line.startsWith('\x00')) {
127
+ try {
128
+ const msg = JSON.parse(line.slice(1));
129
+ if (msg.type === 'resize' &&
130
+ Number.isInteger(msg.cols) && msg.cols > 0 &&
131
+ Number.isInteger(msg.rows) && msg.rows > 0) {
132
+ this._onResize(msg.cols, msg.rows);
133
+ this._logDebug(`Viewer resized to ${msg.cols}x${msg.rows}`);
134
+ }
135
+ } catch {}
136
+ } else {
137
+ this._onKeystroke(line + '\n');
138
+ }
139
+ }
140
+ // Flush any non-control data left in the buffer
141
+ if (recvBuf.length > 0 && !recvBuf.startsWith('\x00')) {
142
+ this._onKeystroke(recvBuf);
143
+ recvBuf = '';
144
+ }
145
+ return;
146
+ }
147
+
148
+ // Host keystrokes → PTY (host always has access)
149
+ this._onKeystroke(chunk);
150
+ };
151
+
152
+ socket.on('close', () => {
153
+ this._socket = null;
154
+ this._logDebug('Host viewer disconnected.');
155
+ });
156
+
157
+ socket.on('error', () => {
158
+ this._socket = null;
159
+ });
160
+ };
161
+
162
+ socket.on('data', onAuthData);
163
+
164
+ socket.on('error', () => {
165
+ clearTimeout(authTimeout);
166
+ });
167
+ });
168
+
169
+ this._server.listen(0, '127.0.0.1', () => {
170
+ const port = this._server.address().port;
171
+ this._logDebug(`Viewer server on port ${port}`);
172
+ this._openTerminal(port, this._token);
173
+ });
174
+
175
+ this._server.on('error', (err) => {
176
+ this._log(`Viewer server error: ${err.message}`);
177
+ this._server = null;
178
+ });
179
+ }
180
+
181
+ /**
182
+ * Write data to the viewer socket (for mirroring PTY output).
183
+ *
184
+ * @param {string|Buffer} data
185
+ */
186
+ write(data) {
187
+ if (this._socket) {
188
+ try { this._socket.write(data); } catch {}
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Clean up the viewer server and socket.
194
+ */
195
+ destroy() {
196
+ if (this._socket) {
197
+ try { this._socket.destroy(); } catch {}
198
+ this._socket = null;
199
+ }
200
+ if (this._server) {
201
+ try { this._server.close(); } catch {}
202
+ this._server = null;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Open a new terminal window connected to the viewer server.
208
+ * @private
209
+ */
210
+ _openTerminal(port, token) {
211
+ if (!/^[A-Za-z0-9_-]+$/.test(this._code)) {
212
+ this._log('Invalid session code format. Aborting viewer terminal.');
213
+ return;
214
+ }
215
+
216
+ const viewerScript = path.join(__dirname, 'session-viewer.js');
217
+ const platform = os.platform();
218
+ const portStr = String(port);
219
+ const nodeArgs = [viewerScript, portStr, this._code, token];
220
+
221
+ if (platform === 'win32') {
222
+ // spawn with 'cmd' to open a new window; arguments are passed as an
223
+ // array so the install path is never interpreted by the shell.
224
+ spawn('cmd', ['/c', 'start', `PeerTerm - ${this._code}`, 'cmd', '/c', 'node', ...nodeArgs], {
225
+ stdio: 'ignore',
226
+ detached: true,
227
+ windowsHide: false,
228
+ }).unref();
229
+ } else if (platform === 'darwin') {
230
+ // osascript receives the AppleScript source as a single -e argument;
231
+ // node args are baked into the script string, but execFile does NOT
232
+ // invoke a shell so the outer path cannot break out.
233
+ const script = `tell app "Terminal" to do script "node '${viewerScript.replace(/'/g, "'\\''")}' ${portStr} ${this._code} ${token}"`;
234
+ execFile('osascript', ['-e', script], { stdio: 'ignore' }, () => {});
235
+ } else {
236
+ // Linux: try common terminal emulators in order, falling through on
237
+ // failure. Each call uses execFile (no shell), so paths with
238
+ // metacharacters are safe.
239
+ const tryTerminals = [
240
+ ['x-terminal-emulator', ['-e', 'node', ...nodeArgs]],
241
+ ['gnome-terminal', ['--', 'node', ...nodeArgs]],
242
+ ['xterm', ['-e', 'node', ...nodeArgs]],
243
+ ];
244
+
245
+ const tryNext = (index) => {
246
+ if (index >= tryTerminals.length) {
247
+ this._log('Could not open any terminal emulator for viewer.');
248
+ return;
249
+ }
250
+ const [cmd, args] = tryTerminals[index];
251
+ execFile(cmd, args, { stdio: 'ignore' }, (err) => {
252
+ if (err) tryNext(index + 1);
253
+ });
254
+ };
255
+ tryNext(0);
256
+ }
257
+ this._log('Opening terminal viewer...');
258
+ }
259
+ }