termbeam 0.0.4 → 0.0.5

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
@@ -65,6 +65,12 @@ termbeam --password mysecret
65
65
  termbeam --tunnel --generate-password
66
66
  ```
67
67
 
68
+ > Requires the [Azure Dev Tunnels CLI](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started):
69
+ >
70
+ > - **Windows:** `winget install Microsoft.devtunnel`
71
+ > - **macOS:** `brew install --cask devtunnel`
72
+ > - **Linux:** `curl -sL https://aka.ms/DevTunnelCliInstall | bash`
73
+
68
74
  ## 📖 Usage
69
75
 
70
76
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termbeam",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
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": {
@@ -57,7 +57,7 @@
57
57
  "dependencies": {
58
58
  "cookie-parser": "^1.4.7",
59
59
  "express": "^5.2.1",
60
- "node-pty": "1.0.0",
60
+ "node-pty": "^1.1.0",
61
61
  "qrcode": "^1.5.4",
62
62
  "ws": "^8.19.0"
63
63
  },
package/public/index.html CHANGED
@@ -231,8 +231,18 @@
231
231
  color: #e0e0e0;
232
232
  font-size: 15px;
233
233
  outline: none;
234
+ -webkit-appearance: none;
235
+ appearance: none;
234
236
  }
235
- .modal input:focus {
237
+ .modal select {
238
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
239
+ background-repeat: no-repeat;
240
+ background-position: right 12px center;
241
+ padding-right: 32px;
242
+ cursor: pointer;
243
+ }
244
+ .modal input:focus,
245
+ .modal select:focus {
236
246
  border-color: #533483;
237
247
  }
238
248
  .modal-actions {
@@ -459,8 +469,12 @@
459
469
  <h2>New Session</h2>
460
470
  <label for="sess-name">Name</label>
461
471
  <input type="text" id="sess-name" placeholder="My Session" />
462
- <label for="sess-shell">Shell / Command</label>
463
- <input type="text" id="sess-shell" placeholder="/bin/zsh" />
472
+ <label for="sess-shell">Shell</label>
473
+ <select id="sess-shell">
474
+ <option value="">Loading shells…</option>
475
+ </select>
476
+ <label for="sess-cmd">Initial Command <span style="color:#666;font-weight:normal">(optional)</span></label>
477
+ <input type="text" id="sess-cmd" placeholder="e.g. copilot, htop, vim" />
464
478
  <label for="sess-cwd">Working Directory</label>
465
479
  <div class="cwd-picker">
466
480
  <input type="text" id="sess-cwd" placeholder="/Users/dorlugasigal" />
@@ -568,6 +582,7 @@
568
582
  }
569
583
 
570
584
  document.getElementById('new-session-btn').addEventListener('click', () => {
585
+ loadShells();
571
586
  modal.classList.add('visible');
572
587
  });
573
588
  document.getElementById('modal-cancel').addEventListener('click', () => {
@@ -581,11 +596,13 @@
581
596
  const name = document.getElementById('sess-name').value.trim();
582
597
  const shell = document.getElementById('sess-shell').value.trim();
583
598
  const cwd = document.getElementById('sess-cwd').value.trim();
599
+ const initialCommand = document.getElementById('sess-cmd').value.trim();
584
600
 
585
601
  const body = {};
586
602
  if (name) body.name = name;
587
603
  if (shell) body.shell = shell;
588
604
  if (cwd) body.cwd = cwd;
605
+ if (initialCommand) body.initialCommand = initialCommand;
589
606
 
590
607
  const res = await fetch('/api/sessions', {
591
608
  method: 'POST',
@@ -596,6 +613,30 @@
596
613
  location.href = data.url;
597
614
  });
598
615
 
616
+ // --- Shell detection ---
617
+ let shellsLoaded = false;
618
+ async function loadShells() {
619
+ if (shellsLoaded) return;
620
+ const shellSelect = document.getElementById('sess-shell');
621
+ try {
622
+ const res = await fetch('/api/shells');
623
+ const data = await res.json();
624
+ shellSelect.innerHTML = '';
625
+ for (const s of data.shells) {
626
+ const opt = document.createElement('option');
627
+ opt.value = s.cmd;
628
+ opt.textContent = `${s.name} (${s.cmd})`;
629
+ if (s.cmd === data.default || s.path === data.default) {
630
+ opt.selected = true;
631
+ }
632
+ shellSelect.appendChild(opt);
633
+ }
634
+ shellsLoaded = true;
635
+ } catch {
636
+ shellSelect.innerHTML = '<option value="">Could not detect shells</option>';
637
+ }
638
+ }
639
+
599
640
  // --- Swipe to delete ---
600
641
  async function deleteSession(id, e) {
601
642
  e.stopPropagation();
@@ -65,6 +65,11 @@
65
65
  align-items: center;
66
66
  gap: 8px;
67
67
  }
68
+ #status-bar .right {
69
+ display: flex;
70
+ align-items: center;
71
+ gap: 8px;
72
+ }
68
73
  #back-btn {
69
74
  background: none;
70
75
  border: none;
@@ -76,6 +81,22 @@
76
81
  #back-btn:active {
77
82
  color: #e0e0e0;
78
83
  }
84
+ #stop-btn {
85
+ background: #e74c3c;
86
+ border: none;
87
+ color: white;
88
+ font-size: 11px;
89
+ font-weight: 600;
90
+ cursor: pointer;
91
+ padding: 4px 10px;
92
+ border-radius: 6px;
93
+ display: flex;
94
+ align-items: center;
95
+ gap: 4px;
96
+ }
97
+ #stop-btn:active {
98
+ background: #c0392b;
99
+ }
79
100
  #status-dot {
80
101
  width: 8px;
81
102
  height: 8px;
@@ -207,8 +228,11 @@
207
228
  <span id="status-dot"></span>
208
229
  <span id="session-name">…</span>
209
230
  </div>
210
- <span id="status-text">Connecting…</span>
211
- <span id="version-text" style="font-size: 11px; color: #555; margin-left: 8px"></span>
231
+ <div class="right">
232
+ <span id="status-text">Connecting…</span>
233
+ <span id="version-text" style="font-size: 11px; color: #555"></span>
234
+ <button id="stop-btn" title="Stop session">■ Stop</button>
235
+ </div>
212
236
  </div>
213
237
 
214
238
  <div id="terminal-container"></div>
@@ -437,6 +461,15 @@
437
461
  connect();
438
462
  });
439
463
 
464
+ // Stop session
465
+ document.getElementById('stop-btn').addEventListener('click', async () => {
466
+ if (!confirm('Stop this session? The process will be killed.')) return;
467
+ try {
468
+ await fetch(`/api/sessions/${sessionId}`, { method: 'DELETE' });
469
+ } catch {}
470
+ location.href = '/';
471
+ });
472
+
440
473
  // Tap terminal area to toggle keyboard (intentional user action)
441
474
  container.addEventListener('click', () => term.focus());
442
475
 
package/src/cli.js CHANGED
@@ -32,10 +32,61 @@ Environment:
32
32
  `);
33
33
  }
34
34
 
35
+ function getDefaultShell() {
36
+ const { execFileSync } = require('child_process');
37
+ const ppid = process.ppid;
38
+ console.log(`[termbeam] Detecting shell (parent PID: ${ppid}, platform: ${os.platform()})`);
39
+
40
+ if (os.platform() === 'win32') {
41
+ // Detect parent process on Windows via WMIC
42
+ try {
43
+ const result = execFileSync(
44
+ 'wmic',
45
+ ['process', 'where', `ProcessId=${ppid}`, 'get', 'Name', '/value'],
46
+ { stdio: ['pipe', 'pipe', 'ignore'], encoding: 'utf8', timeout: 3000 },
47
+ );
48
+ const match = result.match(/Name=(.+)/);
49
+ if (match) {
50
+ const name = match[1].trim().toLowerCase();
51
+ console.log(`[termbeam] Detected parent process: ${name}`);
52
+ if (name === 'pwsh.exe') return 'pwsh.exe';
53
+ if (name === 'powershell.exe') return 'powershell.exe';
54
+ }
55
+ } catch (err) {
56
+ console.log(`[termbeam] Could not detect parent process: ${err.message}`);
57
+ }
58
+ const fallback = process.env.COMSPEC || 'cmd.exe';
59
+ console.log(`[termbeam] Falling back to: ${fallback}`);
60
+ return fallback;
61
+ }
62
+
63
+ // Unix: detect parent shell via ps
64
+ try {
65
+ const result = execFileSync('ps', ['-o', 'comm=', '-p', String(ppid)], {
66
+ stdio: ['pipe', 'pipe', 'ignore'],
67
+ encoding: 'utf8',
68
+ timeout: 3000,
69
+ });
70
+ const comm = result.trim();
71
+ if (comm) {
72
+ const shell = comm.startsWith('-') ? comm.slice(1) : comm;
73
+ console.log(`[termbeam] Detected parent shell: ${shell}`);
74
+ return shell;
75
+ }
76
+ } catch (err) {
77
+ console.log(`[termbeam] Could not detect parent shell: ${err.message}`);
78
+ }
79
+
80
+ // Fallback to SHELL env or /bin/sh
81
+ const fallback = process.env.SHELL || '/bin/sh';
82
+ console.log(`[termbeam] Falling back to: ${fallback}`);
83
+ return fallback;
84
+ }
85
+
35
86
  function parseArgs() {
36
87
  let port = parseInt(process.env.PORT || '3456', 10);
37
88
  let host = '0.0.0.0';
38
- const defaultShell = process.env.SHELL || '/bin/zsh';
89
+ const defaultShell = getDefaultShell();
39
90
  const cwd = process.env.TERMBEAM_CWD || process.env.PTY_CWD || process.cwd();
40
91
  let password = process.env.TERMBEAM_PASSWORD || process.env.PTY_PASSWORD || null;
41
92
  let useTunnel = false;
package/src/routes.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const path = require('path');
2
2
  const os = require('os');
3
3
  const fs = require('fs');
4
+ const { detectShells } = require('./shells');
4
5
 
5
6
  const PUBLIC_DIR = path.join(__dirname, '..', 'public');
6
7
 
@@ -46,16 +47,23 @@ function setupRoutes(app, { auth, sessions, config }) {
46
47
  });
47
48
 
48
49
  app.post('/api/sessions', auth.middleware, (req, res) => {
49
- const { name, shell, args: shellArgs, cwd } = req.body || {};
50
+ const { name, shell, args: shellArgs, cwd, initialCommand } = req.body || {};
50
51
  const id = sessions.create({
51
52
  name: name || `Session ${sessions.sessions.size + 1}`,
52
53
  shell: shell || config.defaultShell,
53
54
  args: shellArgs || [],
54
55
  cwd: cwd || config.cwd,
56
+ initialCommand: initialCommand || null,
55
57
  });
56
58
  res.json({ id, url: `/terminal?id=${id}` });
57
59
  });
58
60
 
61
+ // Available shells
62
+ app.get('/api/shells', auth.middleware, (_req, res) => {
63
+ const shells = detectShells();
64
+ res.json({ shells, default: config.defaultShell });
65
+ });
66
+
59
67
  app.delete('/api/sessions/:id', auth.middleware, (req, res) => {
60
68
  if (sessions.delete(req.params.id)) {
61
69
  res.json({ ok: true });
package/src/sessions.js CHANGED
@@ -6,7 +6,7 @@ class SessionManager {
6
6
  this.sessions = new Map();
7
7
  }
8
8
 
9
- create({ name, shell, args = [], cwd }) {
9
+ create({ name, shell, args = [], cwd, initialCommand = null }) {
10
10
  const id = crypto.randomBytes(4).toString('hex');
11
11
  const ptyProcess = pty.spawn(shell, args, {
12
12
  name: 'xterm-256color',
@@ -16,6 +16,11 @@ class SessionManager {
16
16
  env: { ...process.env, TERM: 'xterm-256color' },
17
17
  });
18
18
 
19
+ // Send initial command once the shell is ready
20
+ if (initialCommand) {
21
+ setTimeout(() => ptyProcess.write(initialCommand + '\r'), 300);
22
+ }
23
+
19
24
  const session = {
20
25
  pty: ptyProcess,
21
26
  name,
package/src/shells.js ADDED
@@ -0,0 +1,77 @@
1
+ const os = require('os');
2
+ const fs = require('fs');
3
+ const { execFileSync } = require('child_process');
4
+
5
+ const KNOWN_WINDOWS_SHELLS = [
6
+ { name: 'PowerShell (Core)', cmd: 'pwsh.exe' },
7
+ { name: 'Windows PowerShell', cmd: 'powershell.exe' },
8
+ { name: 'Command Prompt', cmd: 'cmd.exe' },
9
+ { name: 'Git Bash', cmd: 'bash.exe' },
10
+ { name: 'WSL', cmd: 'wsl.exe' },
11
+ ];
12
+
13
+ function detectShells() {
14
+ if (os.platform() === 'win32') {
15
+ return detectWindowsShells();
16
+ }
17
+ return detectUnixShells();
18
+ }
19
+
20
+ function detectWindowsShells() {
21
+ const shells = [];
22
+ for (const { name, cmd } of KNOWN_WINDOWS_SHELLS) {
23
+ try {
24
+ const result = execFileSync('where', [cmd], {
25
+ stdio: ['pipe', 'pipe', 'ignore'],
26
+ encoding: 'utf8',
27
+ timeout: 3000,
28
+ });
29
+ const fullPath = result.trim().split('\n')[0].trim();
30
+ if (fullPath) {
31
+ shells.push({ name, path: fullPath, cmd });
32
+ }
33
+ } catch {
34
+ // not installed
35
+ }
36
+ }
37
+ return shells;
38
+ }
39
+
40
+ function detectUnixShells() {
41
+ const shells = [];
42
+ const seen = new Set();
43
+
44
+ // Read /etc/shells
45
+ try {
46
+ const content = fs.readFileSync('/etc/shells', 'utf8');
47
+ for (const line of content.split('\n')) {
48
+ const trimmed = line.trim();
49
+ if (trimmed && !trimmed.startsWith('#')) {
50
+ const name = trimmed.split('/').pop();
51
+ if (!seen.has(name)) {
52
+ seen.add(name);
53
+ shells.push({ name, path: trimmed, cmd: trimmed });
54
+ }
55
+ }
56
+ }
57
+ } catch {
58
+ // /etc/shells not available, try common paths
59
+ const common = ['/bin/bash', '/bin/zsh', '/bin/sh', '/usr/bin/fish', '/bin/fish'];
60
+ for (const p of common) {
61
+ try {
62
+ fs.accessSync(p, fs.constants.X_OK);
63
+ const name = p.split('/').pop();
64
+ if (!seen.has(name)) {
65
+ seen.add(name);
66
+ shells.push({ name, path: p, cmd: p });
67
+ }
68
+ } catch {
69
+ // not installed
70
+ }
71
+ }
72
+ }
73
+
74
+ return shells;
75
+ }
76
+
77
+ module.exports = { detectShells };
package/src/tunnel.js CHANGED
@@ -1,26 +1,79 @@
1
1
  const { execSync, spawn } = require('child_process');
2
+ const path = require('path');
3
+ const fs = require('fs');
2
4
 
3
5
  let tunnelId = null;
4
6
  let tunnelProc = null;
7
+ let devtunnelCmd = 'devtunnel';
8
+
9
+ function findDevtunnel() {
10
+ // Try devtunnel directly
11
+ try {
12
+ execSync('devtunnel --version', { stdio: 'pipe' });
13
+ return 'devtunnel';
14
+ } catch {}
15
+
16
+ // On Windows, check common install locations
17
+ if (process.platform === 'win32') {
18
+ const candidates = [
19
+ path.join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WindowsApps', 'devtunnel.exe'),
20
+ path.join(process.env.PROGRAMFILES || '', 'Microsoft', 'devtunnel', 'devtunnel.exe'),
21
+ ];
22
+ for (const p of candidates) {
23
+ if (fs.existsSync(p)) {
24
+ return p;
25
+ }
26
+ }
27
+ }
28
+
29
+ return null;
30
+ }
5
31
 
6
32
  async function startTunnel(port) {
33
+ // Check if devtunnel CLI is installed
34
+ const found = findDevtunnel();
35
+ if (!found) {
36
+ console.error('[termbeam] ❌ devtunnel CLI is not installed.');
37
+ console.error('');
38
+ console.error(' The --tunnel flag requires the Azure Dev Tunnels CLI.');
39
+ console.error('');
40
+ console.error(' Install it:');
41
+ console.error(' Windows: winget install Microsoft.devtunnel');
42
+ console.error(' or: Invoke-WebRequest -Uri https://aka.ms/TunnelsCliDownload/win-x64 -OutFile devtunnel.exe');
43
+ console.error(' macOS: brew install --cask devtunnel');
44
+ console.error(' Linux: curl -sL https://aka.ms/DevTunnelCliInstall | bash');
45
+ console.error('');
46
+ console.error(' Then restart your terminal and try again.');
47
+ console.error(' Docs: https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started');
48
+ console.error('');
49
+ return null;
50
+ }
51
+ devtunnelCmd = found;
52
+
7
53
  console.log('[termbeam] Starting devtunnel...');
8
54
  try {
55
+ // Ensure user is logged in
56
+ let loggedIn = false;
9
57
  try {
10
- execSync('devtunnel user show', { stdio: 'pipe' });
11
- } catch {
58
+ const userOut = execSync(`"${devtunnelCmd}" user show`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
59
+ // user show can succeed but show "not logged in" status
60
+ loggedIn = userOut && !userOut.toLowerCase().includes('not logged in');
61
+ } catch {}
62
+
63
+ if (!loggedIn) {
12
64
  console.log('[termbeam] devtunnel not logged in, launching login...');
13
- execSync('devtunnel user login -g', { stdio: 'inherit' });
65
+ console.log('[termbeam] A browser window will open for authentication.');
66
+ execSync(`"${devtunnelCmd}" user login`, { stdio: 'inherit' });
14
67
  }
15
68
 
16
- const createOut = execSync('devtunnel create --expiration 1d --json', { encoding: 'utf-8' });
69
+ const createOut = execSync(`"${devtunnelCmd}" create --expiration 1d --json`, { encoding: 'utf-8' });
17
70
  const tunnelData = JSON.parse(createOut);
18
71
  tunnelId = tunnelData.tunnel.tunnelId;
19
72
 
20
- execSync(`devtunnel port create ${tunnelId} -p ${port} --protocol http`, { stdio: 'pipe' });
21
- execSync(`devtunnel access create ${tunnelId} -p ${port} --anonymous`, { stdio: 'pipe' });
73
+ execSync(`"${devtunnelCmd}" port create ${tunnelId} -p ${port} --protocol http`, { stdio: 'pipe' });
74
+ execSync(`"${devtunnelCmd}" access create ${tunnelId} -p ${port} --anonymous`, { stdio: 'pipe' });
22
75
 
23
- const hostProc = spawn('devtunnel', ['host', tunnelId], {
76
+ const hostProc = spawn(devtunnelCmd, ['host', tunnelId], {
24
77
  stdio: ['pipe', 'pipe', 'pipe'],
25
78
  detached: true,
26
79
  });
@@ -56,7 +109,7 @@ function cleanupTunnel() {
56
109
  if (tunnelId) {
57
110
  try {
58
111
  if (tunnelProc) tunnelProc.kill();
59
- execSync(`devtunnel delete ${tunnelId} -f`, { stdio: 'pipe' });
112
+ execSync(`"${devtunnelCmd}" delete ${tunnelId} -f`, { stdio: 'pipe' });
60
113
  console.log('[termbeam] Tunnel cleaned up');
61
114
  } catch {
62
115
  /* best effort */