termbeam 1.7.0 → 1.8.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/README.md CHANGED
@@ -46,6 +46,8 @@ termbeam
46
46
 
47
47
  Scan the QR code printed in your terminal, or open the URL on any device.
48
48
 
49
+ > **First time?** Run `termbeam -i` for a guided setup wizard that walks you through password, port, and access mode.
50
+
49
51
  ### Secure by default
50
52
 
51
53
  TermBeam starts with a tunnel and auto-generated password out of the box — just run `termbeam` and scan the QR code.
@@ -55,6 +57,7 @@ termbeam # tunnel + auto-password (default)
55
57
  termbeam --password mysecret # use a specific password
56
58
  termbeam --no-tunnel # LAN-only (no tunnel)
57
59
  termbeam --no-password # disable password protection
60
+ termbeam -i # interactive setup wizard
58
61
  ```
59
62
 
60
63
  ## Remote Access
@@ -85,6 +88,7 @@ termbeam [shell] [args...] # start with a specific shell (default: auto-d
85
88
  termbeam --port 8080 # custom port (default: 3456)
86
89
  termbeam --host 0.0.0.0 # allow LAN access (default: 127.0.0.1)
87
90
  termbeam --lan # shortcut for --host 0.0.0.0
91
+ termbeam -i # interactive setup wizard
88
92
  termbeam service install # interactive PM2 service setup wizard
89
93
  termbeam service uninstall # stop & remove PM2 service
90
94
  termbeam service status # show PM2 service status
@@ -105,6 +109,7 @@ termbeam service restart # restart PM2 service
105
109
  | `--host <addr>` | Bind address | `127.0.0.1` |
106
110
  | `--lan` | Bind to all interfaces (LAN access) | Off |
107
111
  | `--log-level <level>` | Log verbosity (error/warn/info/debug) | `info` |
112
+ | `-i, --interactive` | Interactive setup wizard (guided configuration) | Off |
108
113
  | `-h, --help` | Show help | — |
109
114
  | `-v, --version` | Show version | — |
110
115
 
package/bin/termbeam.js CHANGED
@@ -10,18 +10,33 @@ if (subcommand === 'service') {
10
10
  });
11
11
  } else {
12
12
  const { createTermBeamServer } = require('../src/server.js');
13
- const instance = createTermBeamServer();
13
+ const { parseArgs } = require('../src/cli');
14
+ const { runInteractiveSetup } = require('../src/interactive');
14
15
 
15
- process.on('SIGINT', () => {
16
- console.log('\n[termbeam] Shutting down...');
17
- instance.shutdown();
18
- setTimeout(() => process.exit(0), 500).unref();
19
- });
20
- process.on('SIGTERM', () => {
21
- console.log('\n[termbeam] Shutting down...');
22
- instance.shutdown();
23
- setTimeout(() => process.exit(0), 500).unref();
24
- });
16
+ async function main() {
17
+ const baseConfig = parseArgs();
18
+ let config;
19
+ if (baseConfig.interactive) {
20
+ config = await runInteractiveSetup(baseConfig);
21
+ }
22
+ const instance = createTermBeamServer(config ? { config } : undefined);
23
+
24
+ process.on('SIGINT', () => {
25
+ console.log('\n[termbeam] Shutting down...');
26
+ instance.shutdown();
27
+ setTimeout(() => process.exit(0), 500).unref();
28
+ });
29
+ process.on('SIGTERM', () => {
30
+ console.log('\n[termbeam] Shutting down...');
31
+ instance.shutdown();
32
+ setTimeout(() => process.exit(0), 500).unref();
33
+ });
34
+
35
+ instance.start();
36
+ }
25
37
 
26
- instance.start();
38
+ main().catch((err) => {
39
+ console.error(err.message);
40
+ process.exit(1);
41
+ });
27
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termbeam",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Beam your terminal to any device — mobile-optimized web terminal with multi-session support",
5
5
  "main": "src/server.js",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -31,6 +31,7 @@ Options:
31
31
  --host <addr> Bind address (default: 127.0.0.1)
32
32
  --lan Bind to 0.0.0.0 (allow LAN access, default: localhost only)
33
33
  --log-level <level> Set log verbosity: error, warn, info, debug (default: info)
34
+ -i, --interactive Interactive setup wizard (guided configuration)
34
35
  -h, --help Show this help
35
36
  -v, --version Show version
36
37
 
@@ -47,6 +48,7 @@ Examples:
47
48
  termbeam --password secret Start with specific password
48
49
  termbeam --persisted-tunnel Stable tunnel URL across restarts
49
50
  termbeam /bin/bash Use bash instead of default shell
51
+ termbeam --interactive Guided setup wizard
50
52
  termbeam service install Set up as background service (PM2)
51
53
 
52
54
  Environment:
@@ -241,6 +243,7 @@ function parseArgs() {
241
243
  let noTunnel = false;
242
244
  let persistedTunnel = false;
243
245
  let publicTunnel = false;
246
+ let interactive = false;
244
247
  let explicitPassword = !!password;
245
248
 
246
249
  const args = process.argv.slice(2);
@@ -281,6 +284,8 @@ function parseArgs() {
281
284
  host = '0.0.0.0';
282
285
  } else if (args[i] === '--host' && args[i + 1]) {
283
286
  host = args[++i];
287
+ } else if (args[i] === '--interactive' || (args[i] === '-i' && filteredArgs.length === 0)) {
288
+ interactive = true;
284
289
  } else if (args[i] === '--log-level' && args[i + 1]) {
285
290
  logLevel = args[++i];
286
291
  } else {
@@ -335,6 +340,7 @@ function parseArgs() {
335
340
  defaultShell,
336
341
  version,
337
342
  logLevel,
343
+ interactive,
338
344
  };
339
345
  }
340
346
 
@@ -0,0 +1,269 @@
1
+ const crypto = require('crypto');
2
+ const {
3
+ green,
4
+ yellow,
5
+ red,
6
+ cyan,
7
+ bold,
8
+ dim,
9
+ ask,
10
+ choose,
11
+ confirm,
12
+ createRL,
13
+ } = require('./prompts');
14
+
15
+ // ── Interactive Setup Wizard ─────────────────────────────────────────────────
16
+
17
+ async function runInteractiveSetup(baseConfig) {
18
+ // Enter alternate screen buffer for a clean wizard (like vim/htop)
19
+ process.stdout.write('\x1b[?1049h');
20
+ const exitAltScreen = () => process.stdout.write('\x1b[?1049l');
21
+ process.on('exit', exitAltScreen);
22
+
23
+ const rl = createRL();
24
+
25
+ const steps = ['Password', 'Port', 'Access', 'Log level', 'Confirm'];
26
+ const decisions = [];
27
+
28
+ function showProgress(stepIndex) {
29
+ process.stdout.write('\x1b[2J\x1b[H');
30
+
31
+ console.log(bold('🚀 TermBeam Interactive Setup'));
32
+ console.log('');
33
+ const total = steps.length;
34
+ const filled = stepIndex + 1;
35
+ const bar = steps
36
+ .map((s, i) => {
37
+ if (i < stepIndex) return green('●');
38
+ if (i === stepIndex) return cyan('●');
39
+ return dim('○');
40
+ })
41
+ .join(dim(' ─ '));
42
+ console.log(`${dim(`Step ${filled}/${total}`)} ${bar} ${cyan(steps[stepIndex])}`);
43
+
44
+ if (decisions.length > 0) {
45
+ console.log('');
46
+ for (const { label, value } of decisions) {
47
+ console.log(` ${dim(label + ':')} ${value}`);
48
+ }
49
+ }
50
+ }
51
+
52
+ // Build config from base
53
+ const config = {
54
+ port: baseConfig.port,
55
+ host: baseConfig.host,
56
+ password: baseConfig.password,
57
+ useTunnel: baseConfig.useTunnel,
58
+ persistedTunnel: baseConfig.persistedTunnel,
59
+ publicTunnel: baseConfig.publicTunnel,
60
+ shell: baseConfig.shell,
61
+ shellArgs: baseConfig.shellArgs,
62
+ cwd: baseConfig.cwd,
63
+ defaultShell: baseConfig.defaultShell,
64
+ version: baseConfig.version,
65
+ logLevel: baseConfig.logLevel,
66
+ };
67
+
68
+ // Step 1: Password
69
+ showProgress(0);
70
+ const pwChoice = await choose(rl, 'Password authentication:', [
71
+ {
72
+ label: 'Auto-generate',
73
+ hint: 'Random password, shown on screen and embedded in the QR code',
74
+ },
75
+ {
76
+ label: 'Custom password',
77
+ hint: 'You type a password to use for this session',
78
+ },
79
+ {
80
+ label: 'No password',
81
+ hint: '⚠ No authentication — anyone who can reach the server gets shell access',
82
+ warn: true,
83
+ },
84
+ ]);
85
+ let passwordMode = 'auto';
86
+ if (pwChoice.index === 0) {
87
+ config.password = crypto.randomBytes(16).toString('base64url');
88
+ console.log(dim(` Generated password: ${config.password}`));
89
+ } else if (pwChoice.index === 1) {
90
+ passwordMode = 'custom';
91
+ config.password = await ask(rl, 'Enter password:');
92
+ while (!config.password) {
93
+ console.log(red(' Password cannot be empty.'));
94
+ config.password = await ask(rl, 'Enter password:');
95
+ }
96
+ } else {
97
+ passwordMode = 'none';
98
+ config.password = null;
99
+ }
100
+ decisions.push({
101
+ label: 'Password',
102
+ value: config.password == null ? yellow('disabled') : '••••••••',
103
+ });
104
+
105
+ // Step 2: Port
106
+ showProgress(1);
107
+ const portStr = await ask(rl, 'Port:', String(config.port));
108
+ const portNum = parseInt(portStr, 10);
109
+ config.port = portNum >= 1 && portNum <= 65535 ? portNum : 3456;
110
+ decisions.push({ label: 'Port', value: String(config.port) });
111
+
112
+ // Step 3: Access mode
113
+ showProgress(2);
114
+ const accessChoice = await choose(rl, 'How will you connect to TermBeam?', [
115
+ {
116
+ label: 'DevTunnel (internet)',
117
+ hint: 'HTTPS tunnel — accessible from any network, secured with your Microsoft account',
118
+ },
119
+ {
120
+ label: 'LAN',
121
+ hint: 'Binds to 0.0.0.0 — accessible from devices on the same network',
122
+ },
123
+ {
124
+ label: 'Localhost only',
125
+ hint: 'Binds to 127.0.0.1 — only this machine can connect',
126
+ },
127
+ ]);
128
+
129
+ if (accessChoice.index === 0) {
130
+ // DevTunnel mode
131
+ config.host = '127.0.0.1';
132
+ config.useTunnel = true;
133
+
134
+ // Sub-question: tunnel persistence
135
+ showProgress(2);
136
+ const persistChoice = await choose(rl, 'Tunnel persistence:', [
137
+ {
138
+ label: 'Ephemeral',
139
+ hint: 'New URL each run, automatically deleted when TermBeam exits',
140
+ },
141
+ {
142
+ label: 'Persisted',
143
+ hint: 'Stable URL that survives restarts (expires after 30 days idle)',
144
+ },
145
+ ]);
146
+ config.persistedTunnel = persistChoice.index === 1;
147
+
148
+ // Sub-question: access level
149
+ showProgress(2);
150
+ const publicChoice = await choose(rl, 'Tunnel access:', [
151
+ {
152
+ label: 'Private (owner-only)',
153
+ hint: 'Only the Microsoft account that created the tunnel can access it',
154
+ },
155
+ {
156
+ label: 'Public',
157
+ hint: '🚨 No Microsoft login — anyone with the URL can reach your terminal',
158
+ danger: true,
159
+ },
160
+ ]);
161
+ config.publicTunnel = publicChoice.index === 1;
162
+
163
+ // Auto-generate password if public tunnel with no password
164
+ if (config.publicTunnel && !config.password) {
165
+ console.log(yellow(' ⚠ Public tunnels require password authentication.'));
166
+ config.password = crypto.randomBytes(16).toString('base64url');
167
+ console.log(dim(` Auto-generated password: ${config.password}`));
168
+ passwordMode = 'auto';
169
+ // Update the password decision
170
+ decisions[0] = { label: 'Password', value: '••••••••' };
171
+ }
172
+ } else if (accessChoice.index === 1) {
173
+ // LAN mode
174
+ config.host = '0.0.0.0';
175
+ config.useTunnel = false;
176
+ config.persistedTunnel = false;
177
+ config.publicTunnel = false;
178
+ } else {
179
+ // Localhost only
180
+ config.host = '127.0.0.1';
181
+ config.useTunnel = false;
182
+ config.persistedTunnel = false;
183
+ config.publicTunnel = false;
184
+ }
185
+
186
+ const accessLabel = !config.useTunnel
187
+ ? config.host === '0.0.0.0'
188
+ ? 'LAN (0.0.0.0)'
189
+ : 'Localhost only'
190
+ : config.publicTunnel
191
+ ? 'DevTunnel (public)'
192
+ : 'DevTunnel (private)';
193
+ decisions.push({ label: 'Access', value: accessLabel });
194
+
195
+ // Step 4: Log level
196
+ showProgress(3);
197
+ const logChoice = await choose(
198
+ rl,
199
+ 'Log level:',
200
+ [
201
+ { label: 'info', hint: 'Logs startup, connections, sessions, and errors (default)' },
202
+ { label: 'debug', hint: 'Includes all info logs plus WebSocket frames and internal state' },
203
+ { label: 'warn', hint: 'Only logs warnings and errors' },
204
+ { label: 'error', hint: 'Only logs critical errors' },
205
+ ],
206
+ 0,
207
+ );
208
+ config.logLevel = logChoice.value;
209
+ decisions.push({ label: 'Log level', value: config.logLevel });
210
+
211
+ // Step 5: Confirmation
212
+ showProgress(4);
213
+ console.log(bold('\n── Configuration Summary ──────────────────'));
214
+ console.log(
215
+ ` Password: ${config.password == null ? yellow('disabled') : cyan('••••••••')}`,
216
+ );
217
+ console.log(` Port: ${cyan(String(config.port))}`);
218
+ console.log(
219
+ ` Host: ${cyan(config.host === '0.0.0.0' ? '0.0.0.0 (LAN)' : config.host)}`,
220
+ );
221
+ console.log(` Tunnel: ${config.useTunnel ? cyan('enabled') : yellow('disabled')}`);
222
+ if (config.useTunnel) {
223
+ console.log(` Persisted: ${config.persistedTunnel ? cyan('yes') : dim('no')}`);
224
+ console.log(` Public: ${config.publicTunnel ? yellow('yes') : dim('no')}`);
225
+ }
226
+ console.log(` Shell: ${cyan(config.shell || 'default')}`);
227
+ console.log(` Directory: ${cyan(config.cwd)}`);
228
+ console.log(` Log level: ${cyan(config.logLevel)}`);
229
+ console.log(dim('─'.repeat(44)));
230
+
231
+ // Build the equivalent CLI command
232
+ const cmdParts = ['termbeam'];
233
+ if (passwordMode === 'none') {
234
+ cmdParts.push('--no-password');
235
+ } else if (passwordMode === 'custom') {
236
+ cmdParts.push('--password', '"<your-password>"');
237
+ }
238
+ // auto-generate is the default — no flag needed
239
+ if (config.port !== 3456) cmdParts.push('--port', String(config.port));
240
+ if (!config.useTunnel) {
241
+ cmdParts.push('--no-tunnel');
242
+ if (config.host === '0.0.0.0') cmdParts.push('--lan');
243
+ } else {
244
+ if (config.persistedTunnel) cmdParts.push('--persisted-tunnel');
245
+ if (config.publicTunnel) cmdParts.push('--public');
246
+ }
247
+ if (config.logLevel !== 'info') cmdParts.push('--log-level', config.logLevel);
248
+ const cliCommand = cmdParts.join(' ');
249
+
250
+ console.log('');
251
+ console.log(dim(' To reuse this configuration without the wizard:'));
252
+ console.log(` ${cyan(cliCommand)}`);
253
+
254
+ const proceed = await confirm(rl, '\nStart TermBeam with this configuration?', true);
255
+ rl.close();
256
+
257
+ // Exit alternate screen — return to normal terminal
258
+ exitAltScreen();
259
+ process.removeListener('exit', exitAltScreen);
260
+
261
+ if (!proceed) {
262
+ console.log(dim('Cancelled.'));
263
+ process.exit(0);
264
+ }
265
+
266
+ return config;
267
+ }
268
+
269
+ module.exports = { runInteractiveSetup };
package/src/prompts.js ADDED
@@ -0,0 +1,146 @@
1
+ const readline = require('readline');
2
+
3
+ // ── Color helpers ────────────────────────────────────────────────────────────
4
+
5
+ function color(code, text) {
6
+ return `\x1b[${code}m${text}\x1b[0m`;
7
+ }
8
+ const green = (t) => color('32', t);
9
+ const yellow = (t) => color('33', t);
10
+ const red = (t) => color('31', t);
11
+ const cyan = (t) => color('36', t);
12
+ const bold = (t) => color('1', t);
13
+ const dim = (t) => color('2', t);
14
+
15
+ // ── Interactive prompts ──────────────────────────────────────────────────────
16
+
17
+ /**
18
+ * Prompt the user with a question. Returns the trimmed answer.
19
+ * If `defaultValue` is provided, it's shown in brackets and used when the user presses Enter.
20
+ */
21
+ function ask(rl, question, defaultValue) {
22
+ const suffix = defaultValue != null ? ` ${dim(`[${defaultValue}]`)} ` : ' ';
23
+ return new Promise((resolve) => {
24
+ rl.question(`${question}${suffix}`, (answer) => {
25
+ const trimmed = answer.trim();
26
+ resolve(trimmed || (defaultValue != null ? String(defaultValue) : ''));
27
+ });
28
+ });
29
+ }
30
+
31
+ /**
32
+ * Prompt the user with a list of choices using arrow keys.
33
+ * Each choice can be a string or { label, hint } object.
34
+ * Up/Down to move, Enter to select. Returns the chosen value.
35
+ */
36
+ function choose(rl, question, choices, defaultIndex = 0) {
37
+ // Normalize choices to { label, hint } objects
38
+ const items = choices.map((c) => (typeof c === 'string' ? { label: c, hint: '' } : c));
39
+
40
+ return new Promise((resolve) => {
41
+ let selected = defaultIndex;
42
+
43
+ function lineCount() {
44
+ return items.reduce((n, item) => n + 1 + (item.hint ? 1 : 0), 0);
45
+ }
46
+
47
+ function render(clear) {
48
+ if (clear) {
49
+ process.stdout.write(`\x1b[${lineCount()}A\r\x1b[J`);
50
+ }
51
+ items.forEach((item, i) => {
52
+ const marker = i === selected ? cyan('→') : ' ';
53
+ const label = i === selected ? bold(item.label) : item.label;
54
+ process.stdout.write(` ${marker} ${label}\n`);
55
+ if (item.hint) {
56
+ const hintText = item.danger
57
+ ? red(item.hint)
58
+ : item.warn
59
+ ? yellow(item.hint)
60
+ : dim(item.hint);
61
+ process.stdout.write(` ${hintText}\n`);
62
+ }
63
+ });
64
+ process.stdout.write(dim(' ↑/↓ to move, Enter to select'));
65
+ }
66
+
67
+ rl.pause();
68
+ console.log(`\n${question}`);
69
+ render(false);
70
+
71
+ const wasRaw = process.stdin.isRaw;
72
+ if (process.stdin.isTTY) {
73
+ process.stdin.setRawMode(true);
74
+ }
75
+ process.stdin.resume();
76
+
77
+ function onKey(buf) {
78
+ const key = buf.toString();
79
+
80
+ if (key === '\x1b[A' || key === 'k') {
81
+ selected = (selected - 1 + items.length) % items.length;
82
+ render(true);
83
+ } else if (key === '\x1b[B' || key === 'j') {
84
+ selected = (selected + 1) % items.length;
85
+ render(true);
86
+ } else if (key === '\r' || key === '\n') {
87
+ cleanup();
88
+ process.stdout.write('\r\x1b[K\n');
89
+ console.log(dim(` Selected: ${items[selected].label}`));
90
+ resolve({ index: selected, value: items[selected].label });
91
+ } else if (key === '\x03') {
92
+ cleanup();
93
+ process.stdout.write('\x1b[?1049l');
94
+ process.exit(0);
95
+ }
96
+ }
97
+
98
+ function cleanup() {
99
+ process.stdin.removeListener('data', onKey);
100
+ if (process.stdin.isTTY) {
101
+ process.stdin.setRawMode(wasRaw || false);
102
+ }
103
+ process.stdin.pause();
104
+ rl.resume();
105
+ }
106
+
107
+ process.stdin.on('data', onKey);
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Ask a yes/no question. Returns boolean.
113
+ */
114
+ function confirm(rl, question, defaultYes = true) {
115
+ const hint = defaultYes ? 'Y/n' : 'y/N';
116
+ return new Promise((resolve) => {
117
+ rl.question(`${question} ${dim(`[${hint}]`)} `, (answer) => {
118
+ const a = answer.trim().toLowerCase();
119
+ if (a === '') resolve(defaultYes);
120
+ else resolve(a === 'y' || a === 'yes');
121
+ });
122
+ });
123
+ }
124
+
125
+ // ── readline factory ─────────────────────────────────────────────────────────
126
+
127
+ function createRL() {
128
+ return readline.createInterface({
129
+ input: process.stdin,
130
+ output: process.stdout,
131
+ });
132
+ }
133
+
134
+ module.exports = {
135
+ color,
136
+ green,
137
+ yellow,
138
+ red,
139
+ cyan,
140
+ bold,
141
+ dim,
142
+ ask,
143
+ choose,
144
+ confirm,
145
+ createRL,
146
+ };
package/src/service.js CHANGED
@@ -3,131 +3,24 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
  const os = require('os');
5
5
  const crypto = require('crypto');
6
- const readline = require('readline');
6
+ const {
7
+ color,
8
+ green,
9
+ yellow,
10
+ red,
11
+ cyan,
12
+ bold,
13
+ dim,
14
+ ask,
15
+ choose,
16
+ confirm,
17
+ createRL,
18
+ } = require('./prompts');
7
19
 
8
20
  const TERMBEAM_DIR = path.join(os.homedir(), '.termbeam');
9
21
  const ECOSYSTEM_FILE = path.join(TERMBEAM_DIR, 'ecosystem.config.js');
10
22
  const DEFAULT_SERVICE_NAME = 'termbeam';
11
23
 
12
- // ── Helpers ──────────────────────────────────────────────────────────────────
13
-
14
- function color(code, text) {
15
- return `\x1b[${code}m${text}\x1b[0m`;
16
- }
17
- const green = (t) => color('32', t);
18
- const yellow = (t) => color('33', t);
19
- const red = (t) => color('31', t);
20
- const cyan = (t) => color('36', t);
21
- const bold = (t) => color('1', t);
22
- const dim = (t) => color('2', t);
23
-
24
- /**
25
- * Prompt the user with a question. Returns the trimmed answer.
26
- * If `defaultValue` is provided, it's shown in brackets and used when the user presses Enter.
27
- */
28
- function ask(rl, question, defaultValue) {
29
- const suffix = defaultValue != null ? ` ${dim(`[${defaultValue}]`)} ` : ' ';
30
- return new Promise((resolve) => {
31
- rl.question(`${question}${suffix}`, (answer) => {
32
- const trimmed = answer.trim();
33
- resolve(trimmed || (defaultValue != null ? String(defaultValue) : ''));
34
- });
35
- });
36
- }
37
-
38
- /**
39
- * Prompt the user with a list of choices using arrow keys.
40
- * Each choice can be a string or { label, hint } object.
41
- * Up/Down to move, Enter to select. Returns the chosen value.
42
- */
43
- function choose(rl, question, choices, defaultIndex = 0) {
44
- // Normalize choices to { label, hint } objects
45
- const items = choices.map((c) => (typeof c === 'string' ? { label: c, hint: '' } : c));
46
-
47
- return new Promise((resolve) => {
48
- let selected = defaultIndex;
49
-
50
- function lineCount() {
51
- return items.reduce((n, item) => n + 1 + (item.hint ? 1 : 0), 0);
52
- }
53
-
54
- function render(clear) {
55
- if (clear) {
56
- process.stdout.write(`\x1b[${lineCount()}A\r\x1b[J`);
57
- }
58
- items.forEach((item, i) => {
59
- const marker = i === selected ? cyan('→') : ' ';
60
- const label = i === selected ? bold(item.label) : item.label;
61
- process.stdout.write(` ${marker} ${label}\n`);
62
- if (item.hint) {
63
- const hintText = item.danger
64
- ? red(item.hint)
65
- : item.warn
66
- ? yellow(item.hint)
67
- : dim(item.hint);
68
- process.stdout.write(` ${hintText}\n`);
69
- }
70
- });
71
- process.stdout.write(dim(' ↑/↓ to move, Enter to select'));
72
- }
73
-
74
- rl.pause();
75
- console.log(`\n${question}`);
76
- render(false);
77
-
78
- const wasRaw = process.stdin.isRaw;
79
- if (process.stdin.isTTY) {
80
- process.stdin.setRawMode(true);
81
- }
82
- process.stdin.resume();
83
-
84
- function onKey(buf) {
85
- const key = buf.toString();
86
-
87
- if (key === '\x1b[A' || key === 'k') {
88
- selected = (selected - 1 + items.length) % items.length;
89
- render(true);
90
- } else if (key === '\x1b[B' || key === 'j') {
91
- selected = (selected + 1) % items.length;
92
- render(true);
93
- } else if (key === '\r' || key === '\n') {
94
- cleanup();
95
- process.stdout.write('\r\x1b[K\n');
96
- console.log(dim(` Selected: ${items[selected].label}`));
97
- resolve({ index: selected, value: items[selected].label });
98
- } else if (key === '\x03') {
99
- cleanup();
100
- process.exit(0);
101
- }
102
- }
103
-
104
- function cleanup() {
105
- process.stdin.removeListener('data', onKey);
106
- if (process.stdin.isTTY) {
107
- process.stdin.setRawMode(wasRaw || false);
108
- }
109
- process.stdin.pause();
110
- rl.resume();
111
- }
112
-
113
- process.stdin.on('data', onKey);
114
- });
115
- }
116
-
117
- /**
118
- * Ask a yes/no question. Returns boolean.
119
- */
120
- function confirm(rl, question, defaultYes = true) {
121
- const hint = defaultYes ? 'Y/n' : 'y/N';
122
- return new Promise((resolve) => {
123
- rl.question(`${question} ${dim(`[${hint}]`)} `, (answer) => {
124
- const a = answer.trim().toLowerCase();
125
- if (a === '') resolve(defaultYes);
126
- else resolve(a === 'y' || a === 'yes');
127
- });
128
- });
129
- }
130
-
131
24
  // ── PM2 Detection ────────────────────────────────────────────────────────────
132
25
 
133
26
  function findPm2() {
@@ -651,15 +544,6 @@ function actionRestart() {
651
544
  console.log(green('\n✓ TermBeam service restarted.\n'));
652
545
  }
653
546
 
654
- // ── readline factory ─────────────────────────────────────────────────────────
655
-
656
- function createRL() {
657
- return readline.createInterface({
658
- input: process.stdin,
659
- output: process.stdout,
660
- });
661
- }
662
-
663
547
  // ── Entrypoint ───────────────────────────────────────────────────────────────
664
548
 
665
549
  function printServiceHelp() {