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/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
@@ -1,142 +1,142 @@
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, rejoinWindow, 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
- ` Rejoin Window: ${rejoinWindow}`,
70
- ` Mode: ${mode}`,
71
- ` Shell: ${shellDisplay}`,
72
- ` Path: ${pathDisplay}`,
73
- ];
74
- const shareLine = ` Share at: ${url}`;
75
- const allLines = [...infoLines, shareLine];
76
- const maxLen = Math.max(...allLines.map(l => l.length), 35);
77
- const innerWidth = maxLen + 3; // padding on right
78
-
79
- const hr = '─'.repeat(innerWidth);
80
- const emptyLine = ' '.repeat(innerWidth);
81
-
82
- console.log('');
83
- console.log(` ┌${hr}┐`);
84
- console.log(` │${emptyLine}│`);
85
- for (const line of infoLines) {
86
- console.log(` │${line.padEnd(innerWidth)}│`);
87
- }
88
- console.log(` │${emptyLine}│`);
89
- console.log(` │${shareLine.padEnd(innerWidth)}│`);
90
- console.log(` │${emptyLine}│`);
91
- console.log(` └${hr}┘`);
92
- console.log('');
93
- }
94
-
95
- // ─── Help Text ───────────────────────────────────────────────────────────────
96
-
97
- const HELP_TEXT = `
98
- Usage: peer-term [options]
99
-
100
- Options:
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]
103
- --readonly Share in view-only mode
104
- --secure Enable fingerprint verification for MITM protection
105
- --path <dir> Starting directory for the terminal session [default: home]
106
- --relay <url> Custom relay server URL
107
- --verbose Enable debug logging
108
- --help Show this help message
109
- --version Print version number
110
-
111
- Examples:
112
- peer-term Start session with defaults
113
- peer-term --expiry 10m Custom expiry
114
- peer-term --rejoin 30m Custom reconnection window
115
- peer-term --readonly View-only session
116
- peer-term --secure Require fingerprint verification
117
- peer-term --path ~/projects Start in ~/projects
118
- peer-term --verbose Debug logs
119
- peer-term --relay wss://custom Use custom relay server
120
- `;
121
-
122
- /**
123
- * Print CLI help text.
124
- */
125
- export function printHelp() {
126
- console.log(HELP_TEXT);
127
- }
128
-
129
- // ─── Version ─────────────────────────────────────────────────────────────────
130
-
131
- /**
132
- * Print the version number from package.json.
133
- */
134
- export function printVersion() {
135
- try {
136
- const pkgPath = path.join(__dirname, '..', 'package.json');
137
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
138
- console.log(`peer-term v${pkg.version}`);
139
- } catch {
140
- console.log('peer-term (version unknown)');
141
- }
142
- }
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, rejoinWindow, 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
+ ` Rejoin Window: ${rejoinWindow}`,
70
+ ` Mode: ${mode}`,
71
+ ` Shell: ${shellDisplay}`,
72
+ ` Path: ${pathDisplay}`,
73
+ ];
74
+ const shareLine = ` Share at: ${url}`;
75
+ const allLines = [...infoLines, shareLine];
76
+ const maxLen = Math.max(...allLines.map(l => l.length), 35);
77
+ const innerWidth = maxLen + 3; // padding on right
78
+
79
+ const hr = '─'.repeat(innerWidth);
80
+ const emptyLine = ' '.repeat(innerWidth);
81
+
82
+ console.log('');
83
+ console.log(` ┌${hr}┐`);
84
+ console.log(` │${emptyLine}│`);
85
+ for (const line of infoLines) {
86
+ console.log(` │${line.padEnd(innerWidth)}│`);
87
+ }
88
+ console.log(` │${emptyLine}│`);
89
+ console.log(` │${shareLine.padEnd(innerWidth)}│`);
90
+ console.log(` │${emptyLine}│`);
91
+ console.log(` └${hr}┘`);
92
+ console.log('');
93
+ }
94
+
95
+ // ─── Help Text ───────────────────────────────────────────────────────────────
96
+
97
+ const HELP_TEXT = `
98
+ Usage: peer-term [options]
99
+
100
+ Options:
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]
103
+ --readonly Share in view-only mode
104
+ --secure Enable fingerprint verification for MITM protection
105
+ --path <dir> Starting directory for the terminal session [default: home]
106
+ --relay <url> Custom relay server URL
107
+ --verbose Enable debug logging
108
+ --help Show this help message
109
+ --version Print version number
110
+
111
+ Examples:
112
+ peer-term Start session with defaults
113
+ peer-term --expiry 10m Custom expiry
114
+ peer-term --rejoin 30m Custom reconnection window
115
+ peer-term --readonly View-only session
116
+ peer-term --secure Require fingerprint verification
117
+ peer-term --path ~/projects Start in ~/projects
118
+ peer-term --verbose Debug logs
119
+ peer-term --relay wss://custom Use custom relay server
120
+ `;
121
+
122
+ /**
123
+ * Print CLI help text.
124
+ */
125
+ export function printHelp() {
126
+ console.log(HELP_TEXT);
127
+ }
128
+
129
+ // ─── Version ─────────────────────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Print the version number from package.json.
133
+ */
134
+ export function printVersion() {
135
+ try {
136
+ const pkgPath = path.join(__dirname, '..', 'package.json');
137
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
138
+ console.log(`peer-term v${pkg.version}`);
139
+ } catch {
140
+ console.log('peer-term (version unknown)');
141
+ }
142
+ }
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
+ }