termbeam 1.2.10 → 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 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,7 +143,7 @@ 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. The QR code on startup embeds a share token for password-free login — the token is reusable within its 5-minute validity window, which handles tunnel proxy retries and link preview services. API clients that can't use cookies can authenticate with an `Authorization: Bearer <password>` header.
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.
132
147
 
133
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/).
134
149
 
@@ -136,6 +151,10 @@ For the full threat model, safe usage guidance, and a quick safety checklist, se
136
151
 
137
152
  Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
138
153
 
154
+ ## Changelog
155
+
156
+ See [CHANGELOG.md](CHANGELOG.md) for version history.
157
+
139
158
  ## License
140
159
 
141
160
  [MIT](LICENSE)
package/bin/termbeam.js CHANGED
@@ -1,2 +1,27 @@
1
1
  #!/usr/bin/env node
2
- require('../src/server.js');
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.2.10",
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",
@@ -2050,7 +2050,17 @@
2050
2050
  // ===== Zoom =====
2051
2051
  const MIN_FONT = 2,
2052
2052
  MAX_FONT = 28;
2053
- let fontSize = parseInt(localStorage.getItem('termbeam-fontsize') || '8', 10);
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="theme-color" content="#1a1a2e" />
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
- * { margin: 0; padding: 0; box-sizing: border-box; }
13
- html, body { height: 100%; background: #1a1a2e; color: #e0e0e0;
14
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
15
- display: flex; align-items: center; justify-content: center; }
16
- .card { background: #16213e; border: 1px solid #0f3460; border-radius: 16px;
17
- padding: 32px 24px; width: 320px; text-align: center; }
18
- h1 { font-size: 20px; margin-bottom: 8px; }
19
- h1 span { color: #533483; }
20
- p { font-size: 13px; color: #888; margin-bottom: 24px; }
21
- input { width: 100%; padding: 12px; background: #1a1a2e; border: 1px solid #0f3460;
22
- border-radius: 8px; color: #e0e0e0; font-size: 16px; outline: none;
23
- text-align: center; letter-spacing: 2px; }
24
- input:focus { border-color: #533483; }
25
- button { width: 100%; padding: 12px; margin-top: 16px; background: #533483;
26
- color: white; border: none; border-radius: 8px; font-size: 16px;
27
- font-weight: 600; cursor: pointer; }
28
- button:active { background: #6a42a8; }
29
- .error { color: #e74c3c; font-size: 13px; margin-top: 12px; display: none; }
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/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 (CLI entry point)
237
- const _entryBase = path.basename(process.argv[1] || '');
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
+ };