termbeam 1.2.9 → 1.3.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 +22 -1
- package/bin/termbeam.js +26 -1
- package/package.json +2 -2
- package/public/terminal.html +11 -1
- package/src/auth.js +51 -22
- package/src/cli.js +10 -1
- package/src/routes.js +28 -0
- package/src/server.js +3 -4
- package/src/service.js +731 -0
package/README.md
CHANGED
|
@@ -106,6 +106,11 @@ termbeam [shell] [args...] # start with a specific shell (default: auto-d
|
|
|
106
106
|
termbeam --port 8080 # custom port (default: 3456)
|
|
107
107
|
termbeam --host 0.0.0.0 # allow LAN access (default: 127.0.0.1)
|
|
108
108
|
termbeam --lan # shortcut for --host 0.0.0.0
|
|
109
|
+
termbeam service install # interactive PM2 service setup wizard
|
|
110
|
+
termbeam service uninstall # stop & remove PM2 service
|
|
111
|
+
termbeam service status # show PM2 service status
|
|
112
|
+
termbeam service logs # tail PM2 service logs
|
|
113
|
+
termbeam service restart # restart PM2 service
|
|
109
114
|
```
|
|
110
115
|
|
|
111
116
|
| Flag | Description | Default |
|
|
@@ -121,6 +126,16 @@ termbeam --lan # shortcut for --host 0.0.0.0
|
|
|
121
126
|
| `--host <addr>` | Bind address | `127.0.0.1` |
|
|
122
127
|
| `--lan` | Bind to all interfaces (LAN access) | Off |
|
|
123
128
|
| `--log-level <level>` | Log verbosity (error/warn/info/debug) | `info` |
|
|
129
|
+
| `-h, --help` | Show help | — |
|
|
130
|
+
| `-v, --version` | Show version | — |
|
|
131
|
+
|
|
132
|
+
| Subcommand | Description |
|
|
133
|
+
| ------------------- | ----------------------------- |
|
|
134
|
+
| `service install` | Interactive PM2 service setup |
|
|
135
|
+
| `service uninstall` | Stop & remove from PM2 |
|
|
136
|
+
| `service status` | Show PM2 service status |
|
|
137
|
+
| `service logs` | Tail PM2 service logs |
|
|
138
|
+
| `service restart` | Restart PM2 service |
|
|
124
139
|
|
|
125
140
|
Environment variables: `PORT`, `TERMBEAM_PASSWORD`, `TERMBEAM_CWD`, `TERMBEAM_LOG_LEVEL`, `SHELL` (Unix fallback), `COMSPEC` (Windows fallback). See [Configuration docs](https://dorlugasigal.github.io/TermBeam/configuration/).
|
|
126
141
|
|
|
@@ -128,12 +143,18 @@ Environment variables: `PORT`, `TERMBEAM_PASSWORD`, `TERMBEAM_CWD`, `TERMBEAM_LO
|
|
|
128
143
|
|
|
129
144
|
TermBeam auto-generates a password and creates a tunnel by default, so your terminal is protected out of the box. By default, the server binds to `127.0.0.1` (localhost only). Use `--lan` or `--host 0.0.0.0` to allow LAN access, or `--no-tunnel` to disable the tunnel.
|
|
130
145
|
|
|
131
|
-
Auth uses secure httpOnly cookies with 24-hour expiry, login is rate-limited to 5 attempts per minute, and security headers (X-Frame-Options, X-Content-Type-Options, etc.) are set on all responses.
|
|
146
|
+
Auth uses secure httpOnly cookies with 24-hour expiry, login is rate-limited to 5 attempts per minute, and security headers (X-Frame-Options, X-Content-Type-Options, etc.) are set on all responses. Each QR code contains a single-use share token (5-minute expiry) for password-free login. API clients that can't use cookies can authenticate with an `Authorization: Bearer <password>` header.
|
|
147
|
+
|
|
148
|
+
For the full threat model, safe usage guidance, and a quick safety checklist, see [SECURITY.md](SECURITY.md). For detailed security feature documentation, see the [Security Guide](https://dorlugasigal.github.io/TermBeam/security/).
|
|
132
149
|
|
|
133
150
|
## Contributing
|
|
134
151
|
|
|
135
152
|
Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
136
153
|
|
|
154
|
+
## Changelog
|
|
155
|
+
|
|
156
|
+
See [CHANGELOG.md](CHANGELOG.md) for version history.
|
|
157
|
+
|
|
137
158
|
## License
|
|
138
159
|
|
|
139
160
|
[MIT](LICENSE)
|
package/bin/termbeam.js
CHANGED
|
@@ -1,2 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
// Dispatch subcommands before loading the server
|
|
4
|
+
const subcommand = (process.argv[2] || '').toLowerCase();
|
|
5
|
+
if (subcommand === 'service') {
|
|
6
|
+
const { run } = require('../src/service');
|
|
7
|
+
run(process.argv.slice(3)).catch((err) => {
|
|
8
|
+
console.error(err.message);
|
|
9
|
+
process.exit(1);
|
|
10
|
+
});
|
|
11
|
+
} else {
|
|
12
|
+
const { createTermBeamServer } = require('../src/server.js');
|
|
13
|
+
const instance = createTermBeamServer();
|
|
14
|
+
|
|
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
|
+
});
|
|
25
|
+
|
|
26
|
+
instance.start();
|
|
27
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "termbeam",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"start": "node bin/termbeam.js",
|
|
11
11
|
"dev": "node bin/termbeam.js --generate-password",
|
|
12
12
|
"test": "node -e \"require('child_process').execFileSync(process.execPath,['--test',...require('fs').readdirSync('test').filter(f=>f.endsWith('.test.js')&&!f.startsWith('e2e-')&&f!=='devtunnel-install.test.js').map(f=>'test/'+f)],{stdio:'inherit'})\"",
|
|
13
|
-
"test:coverage": "c8 --exclude=src/tunnel.js --reporter=text --reporter=lcov --reporter=json-summary --reporter=json node -e \"require('child_process').execFileSync(process.execPath,['--test','--test-reporter=spec','--test-reporter-destination=stdout',...require('fs').readdirSync('test').filter(f=>f.endsWith('.test.js')&&!f.startsWith('e2e-')&&f!=='devtunnel-install.test.js').map(f=>'test/'+f)],{stdio:'inherit'})\"",
|
|
13
|
+
"test:coverage": "c8 --exclude=src/tunnel.js --exclude=src/devtunnel-install.js --exclude=test --reporter=text --reporter=lcov --reporter=json-summary --reporter=json node -e \"require('child_process').execFileSync(process.execPath,['--test','--test-reporter=spec','--test-reporter-destination=stdout',...require('fs').readdirSync('test').filter(f=>f.endsWith('.test.js')&&!f.startsWith('e2e-')&&f!=='devtunnel-install.test.js').map(f=>'test/'+f)],{stdio:'inherit'})\"",
|
|
14
14
|
"prepare": "husky",
|
|
15
15
|
"format": "prettier --write .",
|
|
16
16
|
"lint": "node --check src/*.js bin/*.js",
|
package/public/terminal.html
CHANGED
|
@@ -2050,7 +2050,17 @@
|
|
|
2050
2050
|
// ===== Zoom =====
|
|
2051
2051
|
const MIN_FONT = 2,
|
|
2052
2052
|
MAX_FONT = 28;
|
|
2053
|
-
|
|
2053
|
+
function defaultFontSize() {
|
|
2054
|
+
const w = window.innerWidth;
|
|
2055
|
+
if (w <= 480) return 12;
|
|
2056
|
+
if (w <= 768) return 13;
|
|
2057
|
+
if (w <= 1280) return 14;
|
|
2058
|
+
return 15;
|
|
2059
|
+
}
|
|
2060
|
+
let fontSize = parseInt(
|
|
2061
|
+
localStorage.getItem('termbeam-fontsize') || String(defaultFontSize()),
|
|
2062
|
+
10,
|
|
2063
|
+
);
|
|
2054
2064
|
|
|
2055
2065
|
function applyZoom(size) {
|
|
2056
2066
|
fontSize = Math.max(MIN_FONT, Math.min(MAX_FONT, size));
|
package/src/auth.js
CHANGED
|
@@ -5,41 +5,70 @@ const LOGIN_HTML = `<!DOCTYPE html>
|
|
|
5
5
|
<html lang="en">
|
|
6
6
|
<head>
|
|
7
7
|
<meta charset="UTF-8" />
|
|
8
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
|
9
|
-
<meta name="
|
|
8
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
|
9
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
10
|
+
<meta name="mobile-web-app-capable" content="yes" />
|
|
11
|
+
<meta name="theme-color" content="#1e1e1e" />
|
|
10
12
|
<title>TermBeam — Login</title>
|
|
11
13
|
<style>
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
14
|
+
:root { --bg:#1e1e1e; --surface:#252526; --border:#3c3c3c; --border-subtle:#474747;
|
|
15
|
+
--text:#d4d4d4; --text-secondary:#858585; --text-dim:#6e6e6e;
|
|
16
|
+
--accent:#0078d4; --accent-hover:#1a8ae8; --accent-active:#005a9e;
|
|
17
|
+
--danger:#f14c4c; --shadow:rgba(0,0,0,0.15); }
|
|
18
|
+
[data-theme='light'] { --bg:#ffffff; --surface:#f3f3f3; --border:#e0e0e0;
|
|
19
|
+
--border-subtle:#d0d0d0; --text:#1e1e1e; --text-secondary:#616161;
|
|
20
|
+
--text-dim:#767676; --accent:#0078d4; --accent-hover:#106ebe;
|
|
21
|
+
--accent-active:#005a9e; --danger:#e51400; --shadow:rgba(0,0,0,0.06); }
|
|
22
|
+
* { margin:0; padding:0; box-sizing:border-box; }
|
|
23
|
+
html, body { height:100%; background:var(--bg); color:var(--text);
|
|
24
|
+
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
25
|
+
display:flex; flex-direction:column; align-items:center; justify-content:center;
|
|
26
|
+
transition:background 0.3s,color 0.3s;
|
|
27
|
+
padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }
|
|
28
|
+
.theme-toggle { position:fixed; top:16px; right:16px; background:none;
|
|
29
|
+
border:1px solid var(--border); color:var(--text-dim); width:32px; height:32px;
|
|
30
|
+
border-radius:8px; cursor:pointer; display:flex; align-items:center;
|
|
31
|
+
justify-content:center; font-size:16px; transition:color 0.15s,border-color 0.15s,background 0.15s;
|
|
32
|
+
-webkit-tap-highlight-color:transparent; z-index:10; }
|
|
33
|
+
.theme-toggle:hover { color:var(--text); border-color:var(--border-subtle); background:var(--border); }
|
|
34
|
+
.card { background:var(--surface); border:1px solid var(--border); border-radius:12px;
|
|
35
|
+
padding:32px 24px; width:320px; max-width:calc(100vw - 32px); text-align:center;
|
|
36
|
+
box-shadow:0 2px 8px var(--shadow); transition:background 0.3s,border-color 0.3s,box-shadow 0.3s; }
|
|
37
|
+
h1 { font-size:22px; font-weight:700; margin-bottom:4px; }
|
|
38
|
+
h1 span { color:var(--accent); }
|
|
39
|
+
.subtitle { font-size:13px; color:var(--text-secondary); margin-bottom:24px; }
|
|
40
|
+
input { width:100%; padding:12px; background:var(--bg); border:1px solid var(--border);
|
|
41
|
+
border-radius:8px; color:var(--text); font-size:16px; outline:none;
|
|
42
|
+
text-align:center; letter-spacing:2px; transition:border-color 0.15s,background 0.3s,color 0.3s; }
|
|
43
|
+
input:focus { border-color:var(--accent); }
|
|
44
|
+
.btn { width:100%; padding:12px; margin-top:16px; background:var(--accent);
|
|
45
|
+
color:#fff; border:none; border-radius:8px; font-size:16px;
|
|
46
|
+
font-weight:600; cursor:pointer; transition:background 0.15s; }
|
|
47
|
+
.btn:hover { background:var(--accent-hover); }
|
|
48
|
+
.btn:active { background:var(--accent-active); }
|
|
49
|
+
.error { color:var(--danger); font-size:13px; margin-top:12px; display:none; transition:color 0.3s; }
|
|
50
|
+
.tagline { margin-top:24px; font-size:12px; color:var(--text-dim); transition:color 0.3s; }
|
|
30
51
|
</style>
|
|
31
52
|
</head>
|
|
32
53
|
<body>
|
|
54
|
+
<button class="theme-toggle" id="themeBtn" aria-label="Toggle theme">🌙</button>
|
|
33
55
|
<div class="card">
|
|
34
56
|
<h1>📡 Term<span>Beam</span></h1>
|
|
35
|
-
<p>Enter the access password</p>
|
|
57
|
+
<p class="subtitle">Enter the access password</p>
|
|
36
58
|
<form id="form">
|
|
37
59
|
<input type="password" id="pw" placeholder="Password" autocomplete="off" autofocus />
|
|
38
|
-
<button type="submit">Unlock</button>
|
|
60
|
+
<button type="submit" class="btn">Unlock</button>
|
|
39
61
|
</form>
|
|
40
62
|
<div class="error" id="err">Incorrect password</div>
|
|
41
63
|
</div>
|
|
64
|
+
<p class="tagline">Beam your terminal to any device</p>
|
|
42
65
|
<script>
|
|
66
|
+
const t=document.getElementById('themeBtn'), h=document.documentElement;
|
|
67
|
+
function applyTheme(light){h.setAttribute('data-theme',light?'light':'');t.textContent=light?'☀️':'🌙';
|
|
68
|
+
document.querySelector('meta[name=theme-color]').content=light?'#ffffff':'#1e1e1e';}
|
|
69
|
+
applyTheme(localStorage.getItem('theme')==='light');
|
|
70
|
+
t.addEventListener('click',()=>{const light=h.getAttribute('data-theme')!=='light';
|
|
71
|
+
localStorage.setItem('theme',light?'light':'dark');applyTheme(light);});
|
|
43
72
|
document.getElementById('form').addEventListener('submit', async (e) => {
|
|
44
73
|
e.preventDefault();
|
|
45
74
|
const pw = document.getElementById('pw').value;
|
package/src/cli.js
CHANGED
|
@@ -10,6 +10,14 @@ termbeam — Beam your terminal to any device
|
|
|
10
10
|
|
|
11
11
|
Usage:
|
|
12
12
|
termbeam [options] [shell] [args...]
|
|
13
|
+
termbeam service <action> Manage as a background service (PM2)
|
|
14
|
+
|
|
15
|
+
Actions (service):
|
|
16
|
+
install Interactive setup & start as PM2 service
|
|
17
|
+
uninstall Stop & remove from PM2
|
|
18
|
+
status Show service status
|
|
19
|
+
logs Tail service logs
|
|
20
|
+
restart Restart the service
|
|
13
21
|
|
|
14
22
|
Options:
|
|
15
23
|
--password <pw> Set access password (or TERMBEAM_PASSWORD env var)
|
|
@@ -39,6 +47,7 @@ Examples:
|
|
|
39
47
|
termbeam --password secret Start with specific password
|
|
40
48
|
termbeam --persisted-tunnel Stable tunnel URL across restarts
|
|
41
49
|
termbeam /bin/bash Use bash instead of default shell
|
|
50
|
+
termbeam service install Set up as background service (PM2)
|
|
42
51
|
|
|
43
52
|
Environment:
|
|
44
53
|
PORT Server port (default: 3456)
|
|
@@ -329,4 +338,4 @@ function parseArgs() {
|
|
|
329
338
|
};
|
|
330
339
|
}
|
|
331
340
|
|
|
332
|
-
module.exports = { parseArgs, printHelp, isKnownShell };
|
|
341
|
+
module.exports = { parseArgs, printHelp, isKnownShell, getWindowsAncestors };
|
package/src/routes.js
CHANGED
|
@@ -9,6 +9,30 @@ const log = require('./logger');
|
|
|
9
9
|
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
|
|
10
10
|
const uploadedFiles = new Map(); // id -> filepath
|
|
11
11
|
|
|
12
|
+
const IMAGE_SIGNATURES = [
|
|
13
|
+
{ type: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
|
|
14
|
+
{ type: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] },
|
|
15
|
+
{ type: 'image/gif', bytes: [0x47, 0x49, 0x46, 0x38] },
|
|
16
|
+
{ type: 'image/webp', offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] },
|
|
17
|
+
{ type: 'image/bmp', bytes: [0x42, 0x4d] },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function validateMagicBytes(buffer, contentType) {
|
|
21
|
+
const sig = IMAGE_SIGNATURES.find((s) => s.type === contentType);
|
|
22
|
+
if (!sig) return true; // unknown type, skip validation
|
|
23
|
+
const offset = sig.offset || 0;
|
|
24
|
+
if (buffer.length < offset + sig.bytes.length) return false;
|
|
25
|
+
const match = sig.bytes.every((b, i) => buffer[offset + i] === b);
|
|
26
|
+
if (!match) return false;
|
|
27
|
+
// WebP requires RIFF header at offset 0
|
|
28
|
+
if (contentType === 'image/webp') {
|
|
29
|
+
const riff = [0x52, 0x49, 0x46, 0x46];
|
|
30
|
+
if (buffer.length < 4) return false;
|
|
31
|
+
return riff.every((b, i) => buffer[i] === b);
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
12
36
|
function setupRoutes(app, { auth, sessions, config, state }) {
|
|
13
37
|
// Serve static files (manifest.json, sw.js, icons, etc.)
|
|
14
38
|
app.use(express.static(PUBLIC_DIR, { index: false }));
|
|
@@ -204,6 +228,10 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
204
228
|
if (!buffer.length) {
|
|
205
229
|
return res.status(400).json({ error: 'No image data' });
|
|
206
230
|
}
|
|
231
|
+
if (!validateMagicBytes(buffer, contentType)) {
|
|
232
|
+
log.warn(`Upload rejected: content-type "${contentType}" does not match file signature`);
|
|
233
|
+
return res.status(400).json({ error: 'File content does not match declared image type' });
|
|
234
|
+
}
|
|
207
235
|
const ext =
|
|
208
236
|
{
|
|
209
237
|
'image/png': '.png',
|
package/src/server.js
CHANGED
|
@@ -231,11 +231,10 @@ function createTermBeamServer(overrides = {}) {
|
|
|
231
231
|
return { app, server, wss, sessions, config, auth, start, shutdown };
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
-
module.exports = { createTermBeamServer };
|
|
234
|
+
module.exports = { createTermBeamServer, getLocalIP };
|
|
235
235
|
|
|
236
|
-
// Auto-start when run directly (
|
|
237
|
-
|
|
238
|
-
if (require.main === module || _entryBase === 'termbeam' || _entryBase === 'termbeam.js') {
|
|
236
|
+
// Auto-start when run directly (e.g. `node src/server.js`)
|
|
237
|
+
if (require.main === module) {
|
|
239
238
|
const instance = createTermBeamServer();
|
|
240
239
|
|
|
241
240
|
process.on('SIGINT', () => {
|
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
|
+
};
|