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/README.md +27 -2
- package/bin/termbeam.js +26 -1
- package/package.json +3 -3
- package/public/index.html +326 -33
- package/public/terminal.html +770 -37
- package/src/auth.js +135 -22
- package/src/cli.js +10 -1
- package/src/server.js +3 -4
- package/src/service.js +731 -0
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
|
+
};
|