peer-term 1.1.8 → 1.2.2

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +211 -26
  3. package/src/ui.js +4 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peer-term",
3
- "version": "1.1.8",
3
+ "version": "1.2.2",
4
4
  "license": "MIT",
5
5
  "description": "Share your terminal instantly using a 6-digit code. Encrypted. No config.",
6
6
  "keywords": [
package/src/index.js CHANGED
@@ -10,6 +10,7 @@
10
10
  *
11
11
  * CLI flags:
12
12
  * --expiry <value> Session code expiry (e.g. 5m, 30s, 1h). Default: 5m
13
+ * --rejoin <value> Reconnection window (e.g. 5m, 1h, 6h). Default: 2m
13
14
  * --readonly Prevent client keystrokes from reaching the PTY
14
15
  * --path <dir> Starting directory for the terminal session
15
16
  * --relay <url> Custom relay server URL
@@ -48,15 +49,26 @@ const __dirname = path.dirname(__filename);
48
49
  // Load .env from host/ directory (parent of src/)
49
50
  dotenv.config({ path: path.join(__dirname, '..', '.env') });
50
51
 
52
+ // Load package.json for version information
53
+ let version = 'unknown';
54
+ try {
55
+ const pkgPath = path.join(__dirname, '..', 'package.json');
56
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
57
+ version = pkg.version;
58
+ } catch {}
59
+
51
60
  // ─── CLI Argument Parsing ────────────────────────────────────────────────────
52
61
 
53
62
  const argv = minimist(process.argv.slice(2), {
54
63
  boolean: ['readonly', 'verbose', 'help', 'version'],
55
- string: ['expiry', 'relay', 'path'],
64
+ string: ['expiry', 'rejoin', 'relay', 'path'],
56
65
  alias: { h: 'help', v: 'version', V: 'verbose' },
57
- default: { expiry: '5m' },
58
66
  });
59
67
 
68
+ // Detect if --expiry or --rejoin were explicitly passed on the command line
69
+ const expiryFlagPassed = process.argv.some(a => a === '--expiry' || a.startsWith('--expiry='));
70
+ const rejoinFlagPassed = process.argv.some(a => a === '--rejoin' || a.startsWith('--rejoin='));
71
+
60
72
  // Handle --help
61
73
  if (argv.help) {
62
74
  printHelp();
@@ -143,6 +155,130 @@ function getExpiry() {
143
155
  return parsed;
144
156
  }
145
157
 
158
+ // ─── Interactive Prompts ─────────────────────────────────────────────────────
159
+
160
+ /**
161
+ * Prompt the user for a duration value in the terminal.
162
+ * Re-prompts on invalid input until a valid value is entered or Enter is pressed for default.
163
+ *
164
+ * @param {Object} opts
165
+ * @param {string} opts.label - Main prompt question
166
+ * @param {string} opts.description - Additional context shown below the question
167
+ * @param {number} opts.defaultMs - Default value in milliseconds
168
+ * @param {string} opts.defaultLabel - Human-readable default (e.g. "5 minutes")
169
+ * @param {number} opts.minMs - Minimum allowed value in milliseconds
170
+ * @param {number} opts.maxMs - Maximum allowed value in milliseconds
171
+ * @param {string} opts.minLabel - Human-readable minimum (e.g. "2m")
172
+ * @param {string} opts.maxLabel - Human-readable maximum (e.g. "24h")
173
+ * @returns {Promise<number>} Resolved duration in milliseconds
174
+ */
175
+ function promptDuration({ label, description, defaultMs, defaultLabel, minMs, maxMs, minLabel, maxLabel }) {
176
+ return new Promise((resolve) => {
177
+ const rl = readline.createInterface({
178
+ input: process.stdin,
179
+ output: process.stdout,
180
+ });
181
+
182
+ const ask = () => {
183
+ console.log('');
184
+ console.log(` ${label}`);
185
+ if (description) console.log(` ${description}`);
186
+ console.log(` Enter a value between ${minLabel} and ${maxLabel} (e.g. ${minLabel}, 30m, 2h)`);
187
+ console.log(` Default is ${defaultLabel}. Press Enter to use default.`);
188
+ rl.question(' > ', (answer) => {
189
+ const input = answer.trim();
190
+
191
+ // Empty input — use default
192
+ if (!input) {
193
+ rl.close();
194
+ resolve(defaultMs);
195
+ return;
196
+ }
197
+
198
+ const parsed = parseDuration(input);
199
+ if (!parsed || parsed < minMs || parsed > maxMs) {
200
+ console.log(` Invalid value. Please enter a time between ${minLabel} and ${maxLabel} (e.g. 5m, 1h).`);
201
+ ask();
202
+ return;
203
+ }
204
+
205
+ rl.close();
206
+ resolve(parsed);
207
+ });
208
+ };
209
+
210
+ ask();
211
+ });
212
+ }
213
+
214
+ /**
215
+ * Get the session code expiry — from flag, interactive prompt, or silent default.
216
+ */
217
+ async function getExpiryInteractive() {
218
+ const DEFAULT_EXPIRY = 5 * 60 * 1000; // 5 minutes
219
+ const MIN_EXPIRY = 2 * 60 * 1000; // 2 minutes
220
+ const MAX_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours
221
+
222
+ // If --expiry flag was explicitly passed, use it (validate and exit on error)
223
+ if (expiryFlagPassed) {
224
+ const parsed = parseDuration(argv.expiry);
225
+ if (!parsed || parsed < MIN_EXPIRY || parsed > MAX_EXPIRY) {
226
+ logger.error(`Invalid expiry: "${argv.expiry}". Must be between 2m and 24h.`);
227
+ process.exit(1);
228
+ }
229
+ return parsed;
230
+ }
231
+
232
+ // Non-interactive — use default silently
233
+ if (!process.stdin.isTTY) return DEFAULT_EXPIRY;
234
+
235
+ // Interactive prompt
236
+ return promptDuration({
237
+ label: 'How long should the session code be valid?',
238
+ description: null,
239
+ defaultMs: DEFAULT_EXPIRY,
240
+ defaultLabel: '5 minutes',
241
+ minMs: MIN_EXPIRY,
242
+ maxMs: MAX_EXPIRY,
243
+ minLabel: '2m',
244
+ maxLabel: '24h',
245
+ });
246
+ }
247
+
248
+ /**
249
+ * Get the reconnection window — from flag, interactive prompt, or silent default.
250
+ */
251
+ async function getRejoinInteractive() {
252
+ const DEFAULT_REJOIN = 5 * 60 * 1000; // 5 minutes
253
+ const MIN_REJOIN = 5 * 60 * 1000; // 5 minutes
254
+ const MAX_REJOIN = 6 * 60 * 60 * 1000; // 6 hours
255
+
256
+ // If --rejoin flag was explicitly passed, use it
257
+ if (rejoinFlagPassed) {
258
+ const parsed = parseDuration(argv.rejoin);
259
+ if (!parsed || parsed < MIN_REJOIN || parsed > MAX_REJOIN) {
260
+ logger.error(`Invalid rejoin window: "${argv.rejoin}". Must be between 5m and 6h.`);
261
+ process.exit(1);
262
+ }
263
+ return parsed;
264
+ }
265
+
266
+ // Non-interactive — use default silently
267
+ if (!process.stdin.isTTY) return DEFAULT_REJOIN;
268
+
269
+ // Interactive prompt
270
+ return promptDuration({
271
+ label: 'How long should the reconnection window be?',
272
+ description: 'This is how long a disconnected client or host has to rejoin before the session ends.',
273
+ defaultMs: DEFAULT_REJOIN,
274
+ defaultLabel: '2 minutes',
275
+ minMs: MIN_REJOIN,
276
+ maxMs: MAX_REJOIN,
277
+ minLabel: '5m',
278
+ maxLabel: '6h',
279
+ });
280
+ }
281
+
146
282
  // ─── Shell Detection ─────────────────────────────────────────────────────────
147
283
 
148
284
  function detectShell() {
@@ -164,9 +300,10 @@ function detectShell() {
164
300
  // ─── Session Class ───────────────────────────────────────────────────────────
165
301
 
166
302
  class Session {
167
- constructor(shell, expiryMs, readOnly, startPath, onDestroy) {
303
+ constructor(shell, expiryMs, rejoinMs, readOnly, startPath, onDestroy) {
168
304
  this.shell = shell;
169
305
  this.expiryMs = expiryMs;
306
+ this.rejoinMs = rejoinMs;
170
307
  this.readOnly = readOnly;
171
308
  this.startPath = startPath;
172
309
  this.onDestroy = onDestroy;
@@ -212,7 +349,7 @@ class Session {
212
349
  const config = JSON.stringify({
213
350
  type: 'session-config',
214
351
  readonly: this.readOnly,
215
- version: '1.1.8',
352
+ version: version,
216
353
  shell: this.shell,
217
354
  startPath: this.startPath,
218
355
  });
@@ -252,6 +389,7 @@ class Session {
252
389
  this.ws.send(JSON.stringify({
253
390
  type: 'host-register',
254
391
  expiry: this.expiryMs,
392
+ rejoinWindow: this.rejoinMs,
255
393
  readonly: this.readOnly,
256
394
  }));
257
395
  });
@@ -272,6 +410,7 @@ class Session {
272
410
  printSessionBox({
273
411
  code: this.code,
274
412
  expiry: formatDuration(this.expiryMs),
413
+ rejoinWindow: formatDuration(this.rejoinMs),
275
414
  mode: this.readOnly ? 'Read-Only' : 'Read-Write',
276
415
  shell: this.shell,
277
416
  startPath: this.startPath,
@@ -362,7 +501,14 @@ class Session {
362
501
  }
363
502
 
364
503
  case 'peer-disconnected': {
365
- this.log('Client disconnected. Rejoin window: 2 minutes.');
504
+ if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
505
+ this.log('Client relay connection lost; direct DataChannel still active.');
506
+ this.missedPings = 0;
507
+ this.startHeartbeat();
508
+ break;
509
+ }
510
+
511
+ this.log(`Client disconnected. Rejoin window: ${formatDuration(this.rejoinMs)}.`);
366
512
  this.isClientConnected = false;
367
513
  this.awaitingRejoin = true;
368
514
  this.sharedKey = null;
@@ -381,10 +527,7 @@ class Session {
381
527
 
382
528
  case 'rejoined': {
383
529
  this.log(`\u2705 Reconnected. Session restored. (code: ${msg.code})`);
384
- // Restart heartbeat if client is connected
385
- if (this.isClientConnected) {
386
- this.startHeartbeat();
387
- }
530
+ await this._restartPeerSessionAfterHostRejoin();
388
531
  break;
389
532
  }
390
533
 
@@ -445,6 +588,12 @@ class Session {
445
588
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
446
589
  this.ws.send(JSON.stringify({ type: 'heartbeat' }));
447
590
  }
591
+
592
+ if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
593
+ this.missedPings = 0;
594
+ return;
595
+ }
596
+
448
597
  this.missedPings++;
449
598
  if (this.missedPings >= MAX_MISSED_PINGS + 1) {
450
599
  this.log('Client heartbeat lost. Waiting for reconnect...');
@@ -637,7 +786,7 @@ class Session {
637
786
  newWs.send(JSON.stringify({ type: 'host-rejoin', code: this.code, hostToken: this.hostToken }));
638
787
  });
639
788
 
640
- newWs.on('message', (raw) => {
789
+ newWs.on('message', async (raw) => {
641
790
  let msg;
642
791
  try {
643
792
  msg = JSON.parse(raw.toString());
@@ -655,10 +804,7 @@ class Session {
655
804
  // Re-attach the full message handler by wiring up events
656
805
  this._attachWsHandlers(newWs);
657
806
 
658
- // Restart heartbeat if client is connected
659
- if (this.isClientConnected) {
660
- this.startHeartbeat();
661
- }
807
+ await this._restartPeerSessionAfterHostRejoin();
662
808
  } else if (msg.type === 'error') {
663
809
  this.log(`Rejoin failed: ${msg.msg}`);
664
810
  this._pendingReconnectWs = null;
@@ -720,6 +866,12 @@ class Session {
720
866
  this.isClientConnected = true;
721
867
  this.missedPings = 0;
722
868
 
869
+ if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
870
+ // Keep existing keys if direct session is healthy
871
+ this.startHeartbeat();
872
+ break;
873
+ }
874
+
723
875
  this.keyPair = await generateKeyPair();
724
876
  const pubKeyBase64 = await exportPublicKey(this.keyPair.publicKey);
725
877
  this.ws.send(JSON.stringify({ type: 'key-exchange', publicKey: pubKeyBase64 }));
@@ -787,7 +939,14 @@ class Session {
787
939
  }
788
940
 
789
941
  case 'peer-disconnected': {
790
- this.log('Client disconnected. Rejoin window: 2 minutes.');
942
+ if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
943
+ this.log('Client relay connection lost; direct DataChannel still active.');
944
+ this.missedPings = 0;
945
+ this.startHeartbeat();
946
+ break;
947
+ }
948
+
949
+ this.log(`Client disconnected. Rejoin window: ${formatDuration(this.rejoinMs)}.`);
791
950
  this.isClientConnected = false;
792
951
  this.awaitingRejoin = true;
793
952
  this.sharedKey = null;
@@ -805,9 +964,7 @@ class Session {
805
964
 
806
965
  case 'rejoined': {
807
966
  this.log(`Reconnected. Session restored. (code: ${msg.code})`);
808
- if (this.isClientConnected) {
809
- this.startHeartbeat();
810
- }
967
+ await this._restartPeerSessionAfterHostRejoin();
811
968
  break;
812
969
  }
813
970
 
@@ -930,6 +1087,26 @@ class Session {
930
1087
  });
931
1088
  }
932
1089
 
1090
+ async _restartPeerSessionAfterHostRejoin() {
1091
+ this.missedPings = 0;
1092
+
1093
+ if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
1094
+ this.startHeartbeat();
1095
+ return;
1096
+ }
1097
+
1098
+ this.sharedKey = null;
1099
+ this._cleanupWebRTC();
1100
+
1101
+ this.keyPair = await generateKeyPair();
1102
+ const pubKeyBase64 = await exportPublicKey(this.keyPair.publicKey);
1103
+
1104
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
1105
+ this.ws.send(JSON.stringify({ type: 'key-exchange', publicKey: pubKeyBase64 }));
1106
+ this.startHeartbeat();
1107
+ }
1108
+ }
1109
+
933
1110
  _cleanupWebRTC() {
934
1111
  this.useDataChannel = false;
935
1112
  if (this.webrtc) {
@@ -958,16 +1135,17 @@ class Session {
958
1135
  // ─── Session Manager ─────────────────────────────────────────────────────────
959
1136
 
960
1137
  class SessionManager {
961
- constructor(shell, expiryMs, readOnly, startPath) {
1138
+ constructor(shell, expiryMs, rejoinMs, readOnly, startPath) {
962
1139
  this.shell = shell;
963
1140
  this.expiryMs = expiryMs;
1141
+ this.rejoinMs = rejoinMs;
964
1142
  this.readOnly = readOnly;
965
1143
  this.startPath = startPath;
966
1144
  this.sessions = new Map(); // code → Session
967
1145
  }
968
1146
 
969
1147
  async createSession() {
970
- const session = new Session(this.shell, this.expiryMs, this.readOnly, this.startPath, (code) => {
1148
+ const session = new Session(this.shell, this.expiryMs, this.rejoinMs, this.readOnly, this.startPath, (code) => {
971
1149
  this.sessions.delete(code);
972
1150
  });
973
1151
 
@@ -1016,20 +1194,27 @@ class SessionManager {
1016
1194
 
1017
1195
  async function main() {
1018
1196
  const shell = detectShell();
1019
- const expiryMs = getExpiry();
1020
1197
  const readOnly = argv.readonly;
1021
1198
  const startPath = resolveStartPath(argv.path);
1022
1199
 
1023
1200
  // Print startup banner
1024
1201
  printBanner();
1025
- logger.info(`Shell: ${shell}`);
1026
- logger.info(`Relays: ${RELAY_URLS.join(', ')}`);
1027
- logger.info(`Path: ${startPath}`);
1028
- if (readOnly) logger.info('Mode: READ-ONLY');
1202
+
1203
+ // Interactive prompts — always ask unless flags are passed or non-interactive
1204
+ const expiryMs = await getExpiryInteractive();
1205
+ const rejoinMs = await getRejoinInteractive();
1206
+ console.log('');
1207
+
1208
+ logger.info(`Shell: ${shell}`);
1209
+ logger.info(`Relays: ${RELAY_URLS.join(', ')}`);
1210
+ logger.info(`Path: ${startPath}`);
1211
+ logger.info(`Expiry: ${formatDuration(expiryMs)}`);
1212
+ logger.info(`Rejoin Window: ${formatDuration(rejoinMs)}`);
1213
+ if (readOnly) logger.info('Mode: READ-ONLY');
1029
1214
  if (argv.verbose) logger.info('Verbose logging enabled');
1030
1215
  console.log('');
1031
1216
 
1032
- const manager = new SessionManager(shell, expiryMs, readOnly, startPath);
1217
+ const manager = new SessionManager(shell, expiryMs, rejoinMs, readOnly, startPath);
1033
1218
 
1034
1219
  // Create first session automatically
1035
1220
  const firstCode = await manager.createSession();
package/src/ui.js CHANGED
@@ -47,7 +47,7 @@ export function printBanner() {
47
47
  * @param {string} [opts.startPath] - Starting directory for the session
48
48
  * @param {string} [opts.shareUrl] - URL to share (e.g. "https://peerterm.dev")
49
49
  */
50
- export function printSessionBox({ code, expiry, mode, shell, startPath, shareUrl }) {
50
+ export function printSessionBox({ code, expiry, rejoinWindow, mode, shell, startPath, shareUrl }) {
51
51
  const spacedCode = code.split('').join(' ');
52
52
  const url = shareUrl || 'https://peerterm.dev';
53
53
 
@@ -66,6 +66,7 @@ export function printSessionBox({ code, expiry, mode, shell, startPath, shareUrl
66
66
  const infoLines = [
67
67
  ` Session Code: ${spacedCode}`,
68
68
  ` Expires in: ${expiry}`,
69
+ ` Rejoin Window: ${rejoinWindow}`,
69
70
  ` Mode: ${mode}`,
70
71
  ` Shell: ${shellDisplay}`,
71
72
  ` Path: ${pathDisplay}`,
@@ -98,6 +99,7 @@ Usage: peer-term [options]
98
99
 
99
100
  Options:
100
101
  --expiry <time> Session expiry time (e.g. 5m, 30s, 1h) [default: 5m]
102
+ --rejoin <time> Reconnection window (e.g. 5m, 1h, 6h) [default: 2m]
101
103
  --readonly Share in view-only mode
102
104
  --path <dir> Starting directory for the terminal session [default: home]
103
105
  --relay <url> Custom relay server URL
@@ -108,6 +110,7 @@ Options:
108
110
  Examples:
109
111
  peer-term Start session with defaults
110
112
  peer-term --expiry 10m Custom expiry
113
+ peer-term --rejoin 30m Custom reconnection window
111
114
  peer-term --readonly View-only session
112
115
  peer-term --path ~/projects Start in ~/projects
113
116
  peer-term --verbose Debug logs