peer-term 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.
package/src/logger.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * PeerTerm — Logger Module
3
+ *
4
+ * Structured logger with three levels: INFO, WARN, ERROR.
5
+ * - Timestamps: [HH:MM:SS]
6
+ * - Error logs written to ~/.peerterm/logs/error.log
7
+ * - --verbose flag enables DEBUG level
8
+ * - Stack traces only go to file, never to terminal
9
+ */
10
+
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import os from 'os';
14
+
15
+ const LOG_DIR = path.join(os.homedir(), '.peerterm', 'logs');
16
+ const ERROR_LOG_PATH = path.join(LOG_DIR, 'error.log');
17
+
18
+ let verboseMode = false;
19
+
20
+ /**
21
+ * Ensure the log directory exists.
22
+ */
23
+ function ensureLogDir() {
24
+ try {
25
+ fs.mkdirSync(LOG_DIR, { recursive: true });
26
+ } catch {
27
+ // Silent — if we can't create the log dir, we just skip file logging
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Format current time as HH:MM:SS.
33
+ */
34
+ function timestamp() {
35
+ const now = new Date();
36
+ const hh = String(now.getHours()).padStart(2, '0');
37
+ const mm = String(now.getMinutes()).padStart(2, '0');
38
+ const ss = String(now.getSeconds()).padStart(2, '0');
39
+ return `${hh}:${mm}:${ss}`;
40
+ }
41
+
42
+ /**
43
+ * Append an error entry to ~/.peerterm/logs/error.log.
44
+ */
45
+ function writeToErrorLog(message, stack) {
46
+ try {
47
+ ensureLogDir();
48
+ const ts = new Date().toISOString();
49
+ const entry = `[${ts}] ${message}\n${stack ? stack + '\n' : ''}\n`;
50
+ fs.appendFileSync(ERROR_LOG_PATH, entry);
51
+ } catch {
52
+ // Silent — don't crash because of logging
53
+ }
54
+ }
55
+
56
+ const logger = {
57
+ /**
58
+ * Enable or disable verbose (DEBUG) mode.
59
+ */
60
+ setVerbose(enabled) {
61
+ verboseMode = enabled;
62
+ },
63
+
64
+ /**
65
+ * INFO — always shown. Key events the user should see.
66
+ */
67
+ info(msg) {
68
+ console.log(` [${timestamp()}] INFO ${msg}`);
69
+ },
70
+
71
+ /**
72
+ * WARN — always shown. Non-fatal issues.
73
+ */
74
+ warn(msg) {
75
+ console.log(` [${timestamp()}] WARN ${msg}`);
76
+ },
77
+
78
+ /**
79
+ * ERROR — always shown in terminal (human-readable message only).
80
+ * Stack trace is written to ~/.peerterm/logs/error.log.
81
+ */
82
+ error(msg, err) {
83
+ console.error(` [${timestamp()}] ERROR ${msg}`);
84
+ if (err && err.stack) {
85
+ writeToErrorLog(msg, err.stack);
86
+ } else {
87
+ writeToErrorLog(msg);
88
+ }
89
+ },
90
+
91
+ /**
92
+ * DEBUG — only shown when --verbose is enabled.
93
+ */
94
+ debug(msg) {
95
+ if (verboseMode) {
96
+ console.log(` [${timestamp()}] DEBUG ${msg}`);
97
+ }
98
+ },
99
+ };
100
+
101
+ export default logger;
102
+ export { writeToErrorLog, ERROR_LOG_PATH };
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PeerTerm Session Viewer
4
+ *
5
+ * Opens in a new terminal window and connects to the host's PTY
6
+ * via a local TCP socket. Gives the host a direct terminal view.
7
+ */
8
+
9
+ import net from 'net';
10
+
11
+ const port = parseInt(process.argv[2], 10);
12
+
13
+ if (!port || isNaN(port) || port < 1 || port > 65535) {
14
+ console.error('Usage: session-viewer.js <port>\nPort must be a number between 1 and 65535.');
15
+ process.exit(1);
16
+ }
17
+
18
+ function cleanup(code, msg) {
19
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
20
+ if (msg) {
21
+ if (code !== 0) console.error(msg);
22
+ else console.log(msg);
23
+ }
24
+ process.exit(code);
25
+ }
26
+
27
+ process.on('SIGINT', () => cleanup(0, '\n Session terminated by signal.'));
28
+ process.on('SIGTERM', () => cleanup(0, '\n Session terminated by signal.'));
29
+
30
+ function formatResizeMessage(cols, rows) {
31
+ return `\x00${JSON.stringify({ type: 'resize', cols, rows })}\n`;
32
+ }
33
+
34
+ const client = net.connect({ port, host: '127.0.0.1' }, () => {
35
+ client.setTimeout(0); // Clear timeout once connected
36
+ // Send initial terminal size
37
+ const cols = process.stdout.isTTY ? (process.stdout.columns || 80) : 80;
38
+ const rows = process.stdout.isTTY ? (process.stdout.rows || 24) : 24;
39
+ client.write(formatResizeMessage(cols, rows));
40
+
41
+ // Enter raw mode
42
+ if (process.stdin.isTTY) {
43
+ process.stdin.setRawMode(true);
44
+ }
45
+ process.stdin.resume();
46
+
47
+ // Host keystrokes → PTY
48
+ process.stdin.on('data', (data) => {
49
+ client.write(data);
50
+ });
51
+
52
+ // PTY output → host terminal
53
+ client.on('data', (data) => {
54
+ process.stdout.write(data);
55
+ });
56
+
57
+ // Forward terminal resize
58
+ if (process.stdout.isTTY) {
59
+ process.stdout.on('resize', () => {
60
+ const cols = process.stdout.columns || 80;
61
+ const rows = process.stdout.rows || 24;
62
+ client.write(formatResizeMessage(cols, rows));
63
+ });
64
+ }
65
+ });
66
+ client.setTimeout(5000);
67
+
68
+ client.on('timeout', () => {
69
+ client.destroy();
70
+ cleanup(1, ' Connection timed out.');
71
+ });
72
+
73
+ client.on('close', () => {
74
+ cleanup(0, '\n Session ended.');
75
+ });
76
+
77
+ client.on('error', (err) => {
78
+ cleanup(1, ` Connection failed: ${err.message}`);
79
+ });
package/src/ui.js ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * PeerTerm — CLI UI Module
3
+ *
4
+ * Handles all terminal output formatting:
5
+ * - ASCII art banner
6
+ * - Session code display box
7
+ * - Help text
8
+ * - Version display
9
+ */
10
+
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import { fileURLToPath } from 'url';
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = path.dirname(__filename);
17
+
18
+ // ─── ASCII Banner ────────────────────────────────────────────────────────────
19
+
20
+ const BANNER = `
21
+ ██████╗ ███████╗███████╗██████╗ ████████╗███████╗██████╗ ███╗ ███╗
22
+ ██╔══██╗██╔════╝██╔════╝██╔══██╗╚══██╔══╝██╔════╝██╔══██╗████╗ ████║
23
+ ██████╔╝█████╗ █████╗ ██████╔╝ ██║ █████╗ ██████╔╝██╔████╔██║
24
+ ██╔═══╝ ██╔══╝ ██╔══╝ ██╔══██╗ ██║ ██╔══╝ ██╔══██╗██║╚██╔╝██║
25
+ ██║ ███████╗███████╗██║ ██║ ██║ ███████╗██║ ██║██║ ╚═╝ ██║
26
+ ╚═╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝
27
+ Terminal sharing. Instant. Encrypted.`;
28
+
29
+ /**
30
+ * Print the startup ASCII art banner.
31
+ */
32
+ export function printBanner() {
33
+ console.log(BANNER);
34
+ console.log('');
35
+ }
36
+
37
+ // ─── Session Code Box ────────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Print the bordered session information box.
41
+ *
42
+ * @param {Object} opts
43
+ * @param {string} opts.code - 6-digit session code
44
+ * @param {string} opts.expiry - Formatted expiry string (e.g. "5 minute(s)")
45
+ * @param {string} opts.mode - "Read-Write" or "Read-Only"
46
+ * @param {string} opts.shell - Shell path (e.g. "/bin/zsh")
47
+ * @param {string} [opts.startPath] - Starting directory for the session
48
+ * @param {string} [opts.shareUrl] - URL to share (e.g. "https://peerterm.dev")
49
+ */
50
+ export function printSessionBox({ code, expiry, mode, shell, startPath, shareUrl }) {
51
+ const spacedCode = code.split('').join(' ');
52
+ const url = shareUrl || 'https://peerterm.dev';
53
+
54
+ // Truncate long shell paths to keep box readable
55
+ const shellDisplay = shell.length > 24 ? '...' + shell.slice(-21) : shell;
56
+
57
+ // Truncate long start paths similarly
58
+ const homeDir = process.env.HOME || process.env.USERPROFILE || '';
59
+ let pathDisplay = startPath || homeDir;
60
+ if (homeDir && pathDisplay.startsWith(homeDir)) {
61
+ pathDisplay = '~' + pathDisplay.slice(homeDir.length);
62
+ }
63
+ if (pathDisplay.length > 30) pathDisplay = '...' + pathDisplay.slice(-27);
64
+
65
+ // Calculate dynamic box width based on longest content
66
+ const infoLines = [
67
+ ` Session Code: ${spacedCode}`,
68
+ ` Expires in: ${expiry}`,
69
+ ` Mode: ${mode}`,
70
+ ` Shell: ${shellDisplay}`,
71
+ ` Path: ${pathDisplay}`,
72
+ ];
73
+ const shareLine = ` Share at: ${url}`;
74
+ const allLines = [...infoLines, shareLine];
75
+ const maxLen = Math.max(...allLines.map(l => l.length), 35);
76
+ const innerWidth = maxLen + 3; // padding on right
77
+
78
+ const hr = '─'.repeat(innerWidth);
79
+ const emptyLine = ' '.repeat(innerWidth);
80
+
81
+ console.log('');
82
+ console.log(` ┌${hr}┐`);
83
+ console.log(` │${emptyLine}│`);
84
+ for (const line of infoLines) {
85
+ console.log(` │${line.padEnd(innerWidth)}│`);
86
+ }
87
+ console.log(` │${emptyLine}│`);
88
+ console.log(` │${shareLine.padEnd(innerWidth)}│`);
89
+ console.log(` │${emptyLine}│`);
90
+ console.log(` └${hr}┘`);
91
+ console.log('');
92
+ }
93
+
94
+ // ─── Help Text ───────────────────────────────────────────────────────────────
95
+
96
+ const HELP_TEXT = `
97
+ Usage: peer-term [options]
98
+
99
+ Options:
100
+ --expiry <time> Session expiry time (e.g. 5m, 30s, 1h) [default: 5m]
101
+ --readonly Share in view-only mode
102
+ --path <dir> Starting directory for the terminal session [default: home]
103
+ --relay <url> Custom relay server URL
104
+ --verbose Enable debug logging
105
+ --help Show this help message
106
+ --version Print version number
107
+
108
+ Examples:
109
+ peer-term Start session with defaults
110
+ peer-term --expiry 10m Custom expiry
111
+ peer-term --readonly View-only session
112
+ peer-term --path ~/projects Start in ~/projects
113
+ peer-term --verbose Debug logs
114
+ peer-term --relay wss://custom Use custom relay server
115
+ `;
116
+
117
+ /**
118
+ * Print CLI help text.
119
+ */
120
+ export function printHelp() {
121
+ console.log(HELP_TEXT);
122
+ }
123
+
124
+ // ─── Version ─────────────────────────────────────────────────────────────────
125
+
126
+ /**
127
+ * Print the version number from package.json.
128
+ */
129
+ export function printVersion() {
130
+ try {
131
+ const pkgPath = path.join(__dirname, '..', 'package.json');
132
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
133
+ console.log(`peer-term v${pkg.version}`);
134
+ } catch {
135
+ console.log('peer-term (version unknown)');
136
+ }
137
+ }
package/src/webrtc.js ADDED
@@ -0,0 +1,309 @@
1
+ /**
2
+ * PeerTerm — Host-side WebRTC Module
3
+ *
4
+ * Manages a WebRTC PeerConnection + DataChannel for direct local P2P
5
+ * terminal data transfer when host and client are on the same LAN.
6
+ *
7
+ * Key design points:
8
+ * - iceServers: [] — no STUN/TURN, only local candidates
9
+ * - ICE candidate parsing detects "typ host" for same-LAN detection
10
+ * - 3-second ICE gathering timeout, 5-second DataChannel open timeout
11
+ * - Emits 'open', 'close', 'message' events for Session integration
12
+ * - Encrypted data flows unchanged — just a different transport
13
+ */
14
+
15
+ import nodeDataChannel from 'node-datachannel';
16
+
17
+ // Private IP ranges (same LAN indicators)
18
+ const LOCAL_IP_REGEX = /^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)/;
19
+
20
+ // Timeouts
21
+ const ICE_TIMEOUT_MS = 3000;
22
+ const DC_OPEN_TIMEOUT_MS = 5000;
23
+
24
+ export class HostWebRTC {
25
+ constructor(logFn) {
26
+ this._log = logFn || (() => {});
27
+ this._pc = null;
28
+ this._dc = null;
29
+ this._sendSignal = null;
30
+ this._sameLAN = false;
31
+ this._peerSameLAN = false;
32
+ this._dcOpen = false;
33
+ this._closed = false;
34
+
35
+ // Event callbacks
36
+ this._onOpenCb = null;
37
+ this._onCloseCb = null;
38
+ this._onMessageCb = null;
39
+
40
+ // Timeout handles
41
+ this._iceTimer = null;
42
+ this._dcTimer = null;
43
+ }
44
+
45
+ /**
46
+ * Start WebRTC negotiation — create offer and begin ICE gathering.
47
+ * @param {Function} sendSignal — sends { type: 'signal', payload } via relay WS
48
+ */
49
+ initiate(sendSignal) {
50
+ this._sendSignal = sendSignal;
51
+
52
+ try {
53
+ this._log('[WebRTC] Initiating peer connection...');
54
+
55
+ this._pc = new nodeDataChannel.PeerConnection('host', {
56
+ iceServers: [],
57
+ // Disable mDNS candidates to get raw local IPs
58
+ enableIceUdpMux: true,
59
+ });
60
+
61
+ // ─── ICE candidate handling ──────────────────────────────────────
62
+ this._pc.onLocalCandidate((candidate, mid) => {
63
+ if (this._closed) return;
64
+
65
+ // Parse candidate type
66
+ const candidateStr = candidate;
67
+ this._checkLocalCandidate(candidateStr);
68
+
69
+ this._sendSignal({
70
+ type: 'signal',
71
+ payload: { kind: 'ice', candidate: candidateStr, mid },
72
+ });
73
+ });
74
+
75
+ this._pc.onStateChange((state) => {
76
+ this._log(`[WebRTC] Connection state: ${state}`);
77
+ if (state === 'disconnected' || state === 'failed' || state === 'closed') {
78
+ this._handleClose();
79
+ }
80
+ });
81
+
82
+ this._pc.onGatheringStateChange((state) => {
83
+ this._log(`[WebRTC] ICE gathering state: ${state}`);
84
+ });
85
+
86
+ // ─── Create DataChannel ──────────────────────────────────────────
87
+ this._dc = this._pc.createDataChannel('terminal');
88
+
89
+ this._dc.onOpen(() => {
90
+ if (this._closed) return;
91
+ this._clearTimers();
92
+ this._dcOpen = true;
93
+ this._log('[WebRTC] DataChannel open — relay bypassed');
94
+ if (this._onOpenCb) this._onOpenCb();
95
+ });
96
+
97
+ this._dc.onClosed(() => {
98
+ if (this._closed) return;
99
+ this._log('[WebRTC] DataChannel closed');
100
+ this._handleClose();
101
+ });
102
+
103
+ this._dc.onError((err) => {
104
+ this._log(`[WebRTC] DataChannel error: ${err}`);
105
+ });
106
+
107
+ this._dc.onMessage((msg) => {
108
+ if (this._closed || !this._onMessageCb) return;
109
+ // msg can be string or Buffer
110
+ const data = typeof msg === 'string' ? msg : msg.toString();
111
+ this._onMessageCb(data);
112
+ });
113
+
114
+ // ─── Create SDP offer ────────────────────────────────────────────
115
+ this._pc.setLocalDescription();
116
+
117
+ // Wait a tick for the description to be set, then send
118
+ setTimeout(() => {
119
+ if (this._closed) return;
120
+ const desc = this._pc.localDescription();
121
+ if (desc) {
122
+ this._sendSignal({
123
+ type: 'signal',
124
+ payload: { kind: 'offer', sdp: desc.sdp, sdpType: desc.type },
125
+ });
126
+ }
127
+ }, 100);
128
+
129
+ // ─── ICE timeout — 3 seconds ────────────────────────────────────
130
+ this._iceTimer = setTimeout(() => {
131
+ if (this._closed || this._dcOpen) return;
132
+ if (!this._sameLAN || !this._peerSameLAN) {
133
+ this._log('[WebRTC] Not on same LAN — using relay');
134
+ this._sendSignal({
135
+ type: 'signal',
136
+ payload: { kind: 'webrtc-abort' },
137
+ });
138
+ this.close();
139
+ } else {
140
+ // Same LAN confirmed — wait for DataChannel to open
141
+ this._log('[WebRTC] Same LAN detected — waiting for DataChannel...');
142
+ this._dcTimer = setTimeout(() => {
143
+ if (this._closed || this._dcOpen) return;
144
+ this._log('[WebRTC] DataChannel open timeout — falling back to relay');
145
+ this._sendSignal({
146
+ type: 'signal',
147
+ payload: { kind: 'webrtc-abort' },
148
+ });
149
+ this.close();
150
+ }, DC_OPEN_TIMEOUT_MS);
151
+ }
152
+ }, ICE_TIMEOUT_MS);
153
+
154
+ } catch (err) {
155
+ this._log(`[WebRTC] Init failed: ${err.message}`);
156
+ this.close();
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Handle incoming signal messages from the peer (via relay).
162
+ */
163
+ handleSignal(payload) {
164
+ if (this._closed || !this._pc) return;
165
+
166
+ try {
167
+ switch (payload.kind) {
168
+ case 'answer':
169
+ this._pc.setRemoteDescription(payload.sdp, payload.sdpType || 'answer');
170
+ break;
171
+
172
+ case 'ice':
173
+ if (payload.candidate) {
174
+ this._checkRemoteCandidate(payload.candidate);
175
+ this._pc.addRemoteCandidate(payload.candidate, payload.mid || '0');
176
+ }
177
+ break;
178
+
179
+ case 'webrtc-abort':
180
+ this._log('[WebRTC] Peer aborted WebRTC — using relay');
181
+ this.close();
182
+ break;
183
+
184
+ default:
185
+ break;
186
+ }
187
+ } catch (err) {
188
+ this._log(`[WebRTC] Signal handling error: ${err.message}`);
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Send data over the DataChannel.
194
+ * @param {string} data — encrypted base64 blob
195
+ */
196
+ send(data) {
197
+ if (!this._dcOpen || !this._dc || this._closed) return false;
198
+ try {
199
+ this._dc.sendMessage(data);
200
+ return true;
201
+ } catch {
202
+ return false;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Returns true if the DataChannel is open and active.
208
+ */
209
+ isActive() {
210
+ return this._dcOpen && !this._closed;
211
+ }
212
+
213
+ // ─── Event registration ──────────────────────────────────────────────────
214
+
215
+ onOpen(cb) { this._onOpenCb = cb; }
216
+ onClose(cb) { this._onCloseCb = cb; }
217
+ onMessage(cb) { this._onMessageCb = cb; }
218
+
219
+ // ─── Cleanup ─────────────────────────────────────────────────────────────
220
+
221
+ close() {
222
+ if (this._closed) return;
223
+ this._closed = true;
224
+ this._dcOpen = false;
225
+ this._clearTimers();
226
+
227
+ try {
228
+ if (this._dc) {
229
+ this._dc.close();
230
+ this._dc = null;
231
+ }
232
+ } catch {}
233
+
234
+ try {
235
+ if (this._pc) {
236
+ this._pc.close();
237
+ this._pc = null;
238
+ }
239
+ } catch {}
240
+ }
241
+
242
+ // ─── Private helpers ─────────────────────────────────────────────────────
243
+
244
+ _clearTimers() {
245
+ if (this._iceTimer) { clearTimeout(this._iceTimer); this._iceTimer = null; }
246
+ if (this._dcTimer) { clearTimeout(this._dcTimer); this._dcTimer = null; }
247
+ }
248
+
249
+ _handleClose() {
250
+ if (this._closed) return;
251
+ const wasOpen = this._dcOpen;
252
+ this._dcOpen = false;
253
+ this._closed = true;
254
+ this._clearTimers();
255
+
256
+ if (wasOpen && this._onCloseCb) {
257
+ this._onCloseCb();
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Check a local ICE candidate for "typ host" (local IP).
263
+ */
264
+ _checkLocalCandidate(candidateStr) {
265
+ if (this._sameLAN) return;
266
+ if (this._isHostCandidate(candidateStr)) {
267
+ this._sameLAN = true;
268
+ this._log('[WebRTC] Local host-type ICE candidate found');
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Check a remote ICE candidate for "typ host" (peer's local IP).
274
+ */
275
+ _checkRemoteCandidate(candidateStr) {
276
+ if (this._peerSameLAN) return;
277
+ if (this._isHostCandidate(candidateStr)) {
278
+ this._peerSameLAN = true;
279
+ this._log('[WebRTC] Remote host-type ICE candidate found');
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Parse ICE candidate string to check if it's a "host" type
285
+ * with a local/private IP address.
286
+ */
287
+ _isHostCandidate(candidateStr) {
288
+ if (!candidateStr) return false;
289
+ // ICE candidate format: candidate:... typ host ...
290
+ const typMatch = candidateStr.match(/typ\s+(\S+)/);
291
+ if (!typMatch) return false;
292
+ const candidateType = typMatch[1];
293
+ if (candidateType !== 'host') return false;
294
+
295
+ // Optionally verify it's a private IP
296
+ const ipMatch = candidateStr.match(/(\d+\.\d+\.\d+\.\d+)/);
297
+ if (ipMatch && LOCAL_IP_REGEX.test(ipMatch[1])) {
298
+ return true;
299
+ }
300
+
301
+ // IPv6 link-local (fe80::) also counts as local
302
+ if (candidateStr.includes('fe80::')) {
303
+ return true;
304
+ }
305
+
306
+ // If it says "typ host" but we can't parse the IP, still treat as host
307
+ return true;
308
+ }
309
+ }