termbeam 1.2.10 → 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/service.js ADDED
@@ -0,0 +1,731 @@
1
+ const { execFileSync, execFile } = require('child_process');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const crypto = require('crypto');
6
+ const readline = require('readline');
7
+
8
+ const TERMBEAM_DIR = path.join(os.homedir(), '.termbeam');
9
+ const ECOSYSTEM_FILE = path.join(TERMBEAM_DIR, 'ecosystem.config.js');
10
+ const DEFAULT_SERVICE_NAME = 'termbeam';
11
+
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
+ // ── PM2 Detection ────────────────────────────────────────────────────────────
132
+
133
+ function findPm2() {
134
+ try {
135
+ const cmd = os.platform() === 'win32' ? 'where' : 'which';
136
+ const result = execFileSync(cmd, ['pm2'], {
137
+ encoding: 'utf8',
138
+ stdio: ['pipe', 'pipe', 'ignore'],
139
+ timeout: 5000,
140
+ });
141
+ return result.trim().split('\n')[0].trim();
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ function installPm2Global() {
148
+ console.log(yellow('\nInstalling PM2 globally...'));
149
+ try {
150
+ execFileSync('npm', ['install', '-g', 'pm2'], {
151
+ stdio: 'inherit',
152
+ timeout: 120000,
153
+ });
154
+ console.log(green('✓ PM2 installed successfully.\n'));
155
+ return true;
156
+ } catch (err) {
157
+ console.error(red(`✗ Failed to install PM2: ${err.message}`));
158
+ console.error(dim(' Try running: sudo npm install -g pm2'));
159
+ return false;
160
+ }
161
+ }
162
+
163
+ // ── Ecosystem Config ─────────────────────────────────────────────────────────
164
+
165
+ function buildArgs(config) {
166
+ const args = [];
167
+ if (config.password === false) {
168
+ args.push('--no-password');
169
+ } else if (config.password) {
170
+ args.push('--password', config.password);
171
+ }
172
+ if (config.port && config.port !== 3456) {
173
+ args.push('--port', String(config.port));
174
+ }
175
+ if (config.host && config.host !== '127.0.0.1') {
176
+ args.push('--host', config.host);
177
+ }
178
+ if (config.lan) {
179
+ args.push('--lan');
180
+ }
181
+ if (config.noTunnel) {
182
+ args.push('--no-tunnel');
183
+ }
184
+ if (config.persistedTunnel) {
185
+ args.push('--persisted-tunnel');
186
+ }
187
+ if (config.publicTunnel) {
188
+ args.push('--public');
189
+ }
190
+ if (config.logLevel && config.logLevel !== 'info') {
191
+ args.push('--log-level', config.logLevel);
192
+ }
193
+ if (config.shell) {
194
+ args.push(config.shell);
195
+ }
196
+ return args;
197
+ }
198
+
199
+ function generateEcosystem(config) {
200
+ const entry = require.resolve('../bin/termbeam.js');
201
+ const args = buildArgs(config);
202
+ const env = {};
203
+ if (config.cwd) env.TERMBEAM_CWD = config.cwd;
204
+
205
+ const ecosystem = {
206
+ apps: [
207
+ {
208
+ name: config.name || DEFAULT_SERVICE_NAME,
209
+ script: entry,
210
+ args: args,
211
+ cwd: config.cwd || os.homedir(),
212
+ env,
213
+ autorestart: true,
214
+ max_restarts: 10,
215
+ restart_delay: 1000,
216
+ },
217
+ ],
218
+ };
219
+
220
+ return `module.exports = ${JSON.stringify(ecosystem, null, 2)};\n`;
221
+ }
222
+
223
+ function writeEcosystem(content) {
224
+ fs.mkdirSync(TERMBEAM_DIR, { recursive: true });
225
+ fs.writeFileSync(ECOSYSTEM_FILE, content, 'utf8');
226
+ }
227
+
228
+ // ── PM2 Commands ─────────────────────────────────────────────────────────────
229
+
230
+ function pm2Exec(args, opts = {}) {
231
+ try {
232
+ return execFileSync('pm2', args, {
233
+ encoding: 'utf8',
234
+ stdio: opts.inherit ? 'inherit' : ['pipe', 'pipe', 'pipe'],
235
+ timeout: 30000,
236
+ ...opts,
237
+ });
238
+ } catch (err) {
239
+ if (opts.silent) return null;
240
+ console.error(red(`✗ PM2 command failed: pm2 ${args.join(' ')}`));
241
+ if (err.stderr) console.error(dim(err.stderr.trim()));
242
+ return null;
243
+ }
244
+ }
245
+
246
+ // ── Actions ──────────────────────────────────────────────────────────────────
247
+
248
+ async function actionInstall() {
249
+ console.log(dim('\nChecking PM2...\n'));
250
+
251
+ // Step 1: Check PM2
252
+ let pm2Path = findPm2();
253
+ if (!pm2Path) {
254
+ console.log(yellow('⚠ PM2 is not installed.'));
255
+ console.log(dim(' PM2 is a process manager for Node.js that keeps TermBeam running'));
256
+ console.log(dim(' in the background and can auto-restart it on boot.\n'));
257
+
258
+ const rl = createRL();
259
+ const shouldInstall = await confirm(rl, 'Install PM2 globally now?', true);
260
+ rl.close();
261
+
262
+ if (!shouldInstall) {
263
+ console.log(dim('\nYou can install PM2 manually: npm install -g pm2'));
264
+ console.log(dim('Then run: termbeam service install\n'));
265
+ process.exit(1);
266
+ }
267
+ if (!installPm2Global()) process.exit(1);
268
+ pm2Path = findPm2();
269
+ if (!pm2Path) {
270
+ console.error(red('✗ PM2 still not found after installation.'));
271
+ process.exit(1);
272
+ }
273
+ } else {
274
+ console.log(green(`✓ PM2 found: ${pm2Path}`));
275
+ }
276
+
277
+ // Enter alternate screen buffer for a clean wizard (like vim/htop)
278
+ process.stdout.write('\x1b[?1049h');
279
+ // Ensure we exit alternate screen on any exit
280
+ const exitAltScreen = () => process.stdout.write('\x1b[?1049l');
281
+ process.on('exit', exitAltScreen);
282
+
283
+ // Step 2: Interactive config
284
+ const rl = createRL();
285
+ const config = {};
286
+
287
+ const steps = [
288
+ 'Service name',
289
+ 'Password',
290
+ 'Port',
291
+ 'Access',
292
+ 'Directory',
293
+ 'Log level',
294
+ 'Boot',
295
+ 'Confirm',
296
+ ];
297
+
298
+ const decisions = [];
299
+
300
+ function showProgress(stepIndex) {
301
+ // Clear alternate screen and move to top
302
+ process.stdout.write('\x1b[2J\x1b[H');
303
+
304
+ console.log(bold('🚀 TermBeam Service Setup'));
305
+ console.log('');
306
+ const total = steps.length;
307
+ const filled = stepIndex + 1;
308
+ const bar = steps
309
+ .map((s, i) => {
310
+ if (i < stepIndex) return green('●');
311
+ if (i === stepIndex) return cyan('●');
312
+ return dim('○');
313
+ })
314
+ .join(dim(' ─ '));
315
+ console.log(`${dim(`Step ${filled}/${total}`)} ${bar} ${cyan(steps[stepIndex])}`);
316
+
317
+ // Show decisions so far
318
+ if (decisions.length > 0) {
319
+ console.log('');
320
+ for (const { label, value } of decisions) {
321
+ console.log(` ${dim(label + ':')} ${value}`);
322
+ }
323
+ }
324
+ }
325
+
326
+ // Service name
327
+ showProgress(0);
328
+ config.name = await ask(rl, 'Service name:', DEFAULT_SERVICE_NAME);
329
+ decisions.push({ label: 'Service name', value: config.name });
330
+
331
+ // Password
332
+ showProgress(1);
333
+ const pwChoice = await choose(rl, 'Password authentication:', [
334
+ {
335
+ label: 'Auto-generate a secure password',
336
+ hint: 'A random password will be created and displayed for you',
337
+ },
338
+ { label: 'Enter a custom password', hint: 'You choose the password for accessing TermBeam' },
339
+ {
340
+ label: 'No password',
341
+ hint: '⚠ Not recommended — anyone on the network can access your terminal',
342
+ warn: true,
343
+ },
344
+ ]);
345
+ if (pwChoice.index === 0) {
346
+ config.password = crypto.randomBytes(16).toString('base64url');
347
+ console.log(dim(` Generated password: ${config.password}`));
348
+ } else if (pwChoice.index === 1) {
349
+ config.password = await ask(rl, 'Enter password:');
350
+ while (!config.password) {
351
+ console.log(red(' Password cannot be empty.'));
352
+ config.password = await ask(rl, 'Enter password:');
353
+ }
354
+ } else {
355
+ config.password = false;
356
+ }
357
+ decisions.push({
358
+ label: 'Password',
359
+ value: config.password === false ? yellow('disabled') : '••••••••',
360
+ });
361
+
362
+ // Port
363
+ showProgress(2);
364
+ const portStr = await ask(rl, 'Port:', '3456');
365
+ config.port = parseInt(portStr, 10) || 3456;
366
+ decisions.push({ label: 'Port', value: String(config.port) });
367
+
368
+ // Access mode (combines host binding + tunnel into one clear question)
369
+ showProgress(3);
370
+ const accessChoice = await choose(rl, 'How will you connect to TermBeam?', [
371
+ {
372
+ label: 'From anywhere (DevTunnel)',
373
+ hint: 'Creates a secure tunnel URL — access from phone, other networks, anywhere',
374
+ },
375
+ {
376
+ label: 'Local network (LAN)',
377
+ hint: 'Accessible from devices on the same Wi-Fi/network (e.g. phone on same Wi-Fi)',
378
+ },
379
+ {
380
+ label: 'This machine only',
381
+ hint: 'Localhost only — most secure, no external access',
382
+ },
383
+ ]);
384
+
385
+ if (accessChoice.index === 0) {
386
+ // DevTunnel mode: localhost binding, tunnel enabled, persisted by default for services
387
+ config.host = '127.0.0.1';
388
+ config.noTunnel = false;
389
+ config.persistedTunnel = true;
390
+ // Re-render step to clear the previous menu before showing sub-question
391
+ showProgress(3);
392
+ const publicChoice = await choose(rl, 'Tunnel access:', [
393
+ {
394
+ label: 'Private (requires Microsoft login)',
395
+ hint: 'Only you can access the tunnel — secured via your Microsoft account',
396
+ },
397
+ {
398
+ label: 'Public (anyone with the link)',
399
+ hint: '🚨 Anyone with the URL can reach your terminal — password is the only protection',
400
+ danger: true,
401
+ },
402
+ ]);
403
+ config.publicTunnel = publicChoice.index === 1;
404
+ if (config.publicTunnel && config.password === false) {
405
+ console.log(yellow(' ⚠ Public tunnels require password authentication.'));
406
+ config.password = crypto.randomBytes(16).toString('base64url');
407
+ console.log(dim(` Auto-generated password: ${config.password}`));
408
+ }
409
+ } else if (accessChoice.index === 1) {
410
+ // LAN mode: bind to all interfaces, no tunnel
411
+ config.lan = true;
412
+ config.noTunnel = true;
413
+ } else {
414
+ // Localhost only: no tunnel
415
+ config.host = '127.0.0.1';
416
+ config.noTunnel = true;
417
+ }
418
+ const accessLabel = config.noTunnel
419
+ ? config.lan
420
+ ? 'LAN (0.0.0.0)'
421
+ : 'Localhost only'
422
+ : config.publicTunnel
423
+ ? 'DevTunnel (public)'
424
+ : 'DevTunnel (private)';
425
+ decisions.push({ label: 'Access', value: accessLabel });
426
+
427
+ // Working directory
428
+ showProgress(4);
429
+ config.cwd = await ask(rl, 'Working directory:', process.cwd());
430
+ decisions.push({ label: 'Directory', value: config.cwd });
431
+
432
+ // Shell — use current shell automatically
433
+ config.shell = process.env.SHELL || (os.platform() === 'win32' ? process.env.COMSPEC : '/bin/sh');
434
+ decisions.push({ label: 'Shell', value: config.shell });
435
+
436
+ // Log level
437
+ showProgress(5);
438
+ const logChoice = await choose(
439
+ rl,
440
+ 'Log level:',
441
+ [
442
+ { label: 'info', hint: 'Standard logging — startup, connections, errors (recommended)' },
443
+ { label: 'debug', hint: 'Verbose output — useful for troubleshooting issues' },
444
+ { label: 'warn', hint: 'Only warnings and errors' },
445
+ { label: 'error', hint: 'Only critical errors — minimal output' },
446
+ ],
447
+ 0,
448
+ );
449
+ config.logLevel = logChoice.value;
450
+ decisions.push({ label: 'Log level', value: config.logLevel });
451
+
452
+ // Boot
453
+ showProgress(6);
454
+ config.startup = await confirm(rl, 'Auto-start TermBeam on system boot?', true);
455
+ decisions.push({ label: 'Boot', value: config.startup ? 'yes' : 'no' });
456
+
457
+ // Confirm
458
+ showProgress(7);
459
+ console.log(bold('\n── Configuration Summary ──────────────────'));
460
+ console.log(` Service name: ${cyan(config.name)}`);
461
+ console.log(
462
+ ` Password: ${config.password === false ? yellow('disabled') : cyan(config.password)}`,
463
+ );
464
+ console.log(` Port: ${cyan(String(config.port))}`);
465
+ console.log(
466
+ ` Host: ${cyan(config.lan ? '0.0.0.0 (LAN)' : config.host || '127.0.0.1')}`,
467
+ );
468
+ console.log(` Tunnel: ${config.noTunnel ? yellow('disabled') : cyan('enabled')}`);
469
+ if (!config.noTunnel) {
470
+ console.log(` Persisted: ${config.persistedTunnel ? cyan('yes') : dim('no')}`);
471
+ console.log(` Public: ${config.publicTunnel ? yellow('yes') : dim('no')}`);
472
+ }
473
+ console.log(` Directory: ${cyan(config.cwd)}`);
474
+ console.log(` Shell: ${cyan(config.shell || 'default')}`);
475
+ console.log(` Log level: ${cyan(config.logLevel)}`);
476
+ console.log(` Boot: ${config.startup ? cyan('yes') : dim('no')}`);
477
+ console.log(dim('─'.repeat(44)));
478
+
479
+ const proceed = await confirm(rl, '\nProceed with installation?', true);
480
+ rl.close();
481
+
482
+ // Exit alternate screen — return to normal terminal
483
+ exitAltScreen();
484
+ process.removeListener('exit', exitAltScreen);
485
+
486
+ if (!proceed) {
487
+ console.log(dim('Cancelled.'));
488
+ process.exit(0);
489
+ }
490
+
491
+ // Step 3: Create working directory if needed, write ecosystem & start
492
+ if (!fs.existsSync(config.cwd)) {
493
+ fs.mkdirSync(config.cwd, { recursive: true });
494
+ console.log(green(`✓ Created directory ${config.cwd}`));
495
+ }
496
+ const ecosystemContent = generateEcosystem(config);
497
+ writeEcosystem(ecosystemContent);
498
+ console.log(green(`\n✓ Config written to ${ECOSYSTEM_FILE}`));
499
+
500
+ // Stop existing instance if running
501
+ pm2Exec(['delete', config.name], { silent: true });
502
+
503
+ // Truncate old log files for a clean start
504
+ const outLog = path.join(os.homedir(), '.pm2', 'logs', `${config.name}-out.log`);
505
+ const errLog = path.join(os.homedir(), '.pm2', 'logs', `${config.name}-error.log`);
506
+ try {
507
+ fs.writeFileSync(outLog, '', 'utf8');
508
+ } catch {}
509
+ try {
510
+ fs.writeFileSync(errLog, '', 'utf8');
511
+ } catch {}
512
+
513
+ // Start
514
+ const started = pm2Exec(['start', ECOSYSTEM_FILE], { inherit: true });
515
+ if (started === null && !fs.existsSync(ECOSYSTEM_FILE)) {
516
+ console.error(red('✗ Failed to start TermBeam service.'));
517
+ process.exit(1);
518
+ }
519
+
520
+ pm2Exec(['save'], { inherit: true });
521
+ console.log(green('\n✓ TermBeam is now running as a PM2 service!'));
522
+
523
+ // Run pm2 startup if chosen during wizard
524
+ if (config.startup) {
525
+ console.log('');
526
+ // pm2 startup outputs a sudo command to copy/paste — capture it and run it
527
+ const startupOutput = pm2Exec(['startup'], { silent: true }) || '';
528
+ const sudoMatch = startupOutput.match(/^(sudo .+)$/m);
529
+ if (sudoMatch) {
530
+ console.log(dim('Setting up boot persistence (may ask for your password)...\n'));
531
+ const { spawn } = require('child_process');
532
+ const child = spawn('sh', ['-c', sudoMatch[1]], { stdio: 'inherit' });
533
+ await new Promise((resolve) => child.on('close', resolve));
534
+ pm2Exec(['save'], { inherit: true });
535
+ console.log(green('✓ TermBeam will start automatically on boot.'));
536
+ } else {
537
+ // Fallback: just show what pm2 said
538
+ console.log(startupOutput);
539
+ }
540
+ }
541
+
542
+ // Wait for server to start and show connection info
543
+ console.log(dim('\nWaiting for TermBeam to start...'));
544
+ const maxWait = 15;
545
+ let logContent = '';
546
+ for (let i = 0; i < maxWait; i++) {
547
+ await new Promise((r) => setTimeout(r, 1000));
548
+ try {
549
+ logContent = fs.readFileSync(outLog, 'utf8');
550
+ } catch {
551
+ continue;
552
+ }
553
+ if (logContent.includes('Scan the QR code') || logContent.includes('Local:')) break;
554
+ }
555
+ if (logContent) {
556
+ // Extract from last "Shell:" to last "Scan the QR code" line
557
+ const lines = logContent.split('\n');
558
+ const startIdx = lines.findLastIndex((l) => l.includes('Shell:'));
559
+ const endIdx = lines.findLastIndex((l) => l.includes('Scan the QR code'));
560
+ if (startIdx >= 0 && endIdx >= startIdx) {
561
+ console.log('');
562
+ for (let i = startIdx; i <= endIdx; i++) {
563
+ console.log(lines[i]);
564
+ }
565
+ console.log('');
566
+ }
567
+ }
568
+
569
+ console.log(dim('\nUseful commands:'));
570
+ console.log(` ${cyan('termbeam service status')} — Check service status`);
571
+ console.log(` ${cyan('termbeam service logs')} — View logs`);
572
+ console.log(` ${cyan('termbeam service restart')} — Restart service`);
573
+ console.log(` ${cyan('termbeam service uninstall')} — Remove service\n`);
574
+ }
575
+
576
+ async function actionUninstall() {
577
+ const pm2Path = findPm2();
578
+ if (!pm2Path) {
579
+ console.error(red('✗ PM2 is not installed.'));
580
+ process.exit(1);
581
+ }
582
+
583
+ // Find running termbeam services
584
+ const list = pm2Exec(['jlist'], { silent: true });
585
+ let services = [];
586
+ if (list) {
587
+ try {
588
+ services = JSON.parse(list).filter(
589
+ (p) => p.name === DEFAULT_SERVICE_NAME || p.name.startsWith('termbeam'),
590
+ );
591
+ } catch {
592
+ // ignore parse errors
593
+ }
594
+ }
595
+
596
+ const name = services.length > 0 ? services[0].name : DEFAULT_SERVICE_NAME;
597
+
598
+ const rl = createRL();
599
+ const sure = await confirm(rl, `Remove TermBeam service "${name}" from PM2?`, true);
600
+ rl.close();
601
+
602
+ if (!sure) {
603
+ console.log(dim('Cancelled.'));
604
+ process.exit(0);
605
+ }
606
+
607
+ pm2Exec(['stop', name], { inherit: true });
608
+ pm2Exec(['delete', name], { inherit: true });
609
+ pm2Exec(['save'], { inherit: true });
610
+
611
+ // Clean up ecosystem file
612
+ if (fs.existsSync(ECOSYSTEM_FILE)) {
613
+ fs.unlinkSync(ECOSYSTEM_FILE);
614
+ console.log(dim(`Removed ${ECOSYSTEM_FILE}`));
615
+ }
616
+
617
+ console.log(green(`\n✓ TermBeam service "${name}" removed.\n`));
618
+ }
619
+
620
+ function actionStatus() {
621
+ const pm2Path = findPm2();
622
+ if (!pm2Path) {
623
+ console.error(red('✗ PM2 is not installed. Run: npm install -g pm2'));
624
+ process.exit(1);
625
+ }
626
+ pm2Exec(['describe', DEFAULT_SERVICE_NAME], { inherit: true });
627
+ }
628
+
629
+ function actionLogs() {
630
+ const pm2Path = findPm2();
631
+ if (!pm2Path) {
632
+ console.error(red('✗ PM2 is not installed. Run: npm install -g pm2'));
633
+ process.exit(1);
634
+ }
635
+ const { spawn } = require('child_process');
636
+ const child = spawn('pm2', ['logs', DEFAULT_SERVICE_NAME, '--lines', '200'], {
637
+ stdio: 'inherit',
638
+ });
639
+ child.on('error', (err) => {
640
+ console.error(red(`✗ Failed to stream logs: ${err.message}`));
641
+ });
642
+ }
643
+
644
+ function actionRestart() {
645
+ const pm2Path = findPm2();
646
+ if (!pm2Path) {
647
+ console.error(red('✗ PM2 is not installed. Run: npm install -g pm2'));
648
+ process.exit(1);
649
+ }
650
+ pm2Exec(['restart', DEFAULT_SERVICE_NAME], { inherit: true });
651
+ console.log(green('\n✓ TermBeam service restarted.\n'));
652
+ }
653
+
654
+ // ── readline factory ─────────────────────────────────────────────────────────
655
+
656
+ function createRL() {
657
+ return readline.createInterface({
658
+ input: process.stdin,
659
+ output: process.stdout,
660
+ });
661
+ }
662
+
663
+ // ── Entrypoint ───────────────────────────────────────────────────────────────
664
+
665
+ function printServiceHelp() {
666
+ console.log(`
667
+ ${bold('termbeam service')} — Manage TermBeam as a background service (PM2)
668
+
669
+ ${bold('Usage:')}
670
+ termbeam service install Interactive setup & start
671
+ termbeam service uninstall Stop & remove from PM2
672
+ termbeam service status Show service status
673
+ termbeam service logs Tail service logs
674
+ termbeam service restart Restart the service
675
+
676
+ ${dim('PM2 will be installed globally if not already present.')}
677
+ `);
678
+ }
679
+
680
+ async function run(args) {
681
+ const action = (args[0] || '').toLowerCase();
682
+
683
+ switch (action) {
684
+ case 'install':
685
+ await actionInstall();
686
+ break;
687
+ case 'uninstall':
688
+ case 'remove':
689
+ await actionUninstall();
690
+ break;
691
+ case 'status':
692
+ actionStatus();
693
+ break;
694
+ case 'logs':
695
+ case 'log':
696
+ actionLogs();
697
+ break;
698
+ case 'restart':
699
+ actionRestart();
700
+ break;
701
+ default:
702
+ printServiceHelp();
703
+ break;
704
+ }
705
+ }
706
+
707
+ module.exports = {
708
+ run,
709
+ findPm2,
710
+ buildArgs,
711
+ generateEcosystem,
712
+ writeEcosystem,
713
+ pm2Exec,
714
+ actionStatus,
715
+ actionRestart,
716
+ actionLogs,
717
+ printServiceHelp,
718
+ color,
719
+ green,
720
+ yellow,
721
+ red,
722
+ cyan,
723
+ bold,
724
+ dim,
725
+ ask,
726
+ choose,
727
+ confirm,
728
+ TERMBEAM_DIR,
729
+ ECOSYSTEM_FILE,
730
+ DEFAULT_SERVICE_NAME,
731
+ };