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.
- package/package.json +1 -1
- package/src/index.js +211 -26
- package/src/ui.js +4 -1
package/package.json
CHANGED
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:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|