Skip to content

Commit 51edf88

Browse files
committed
feat: implement shell detection and initial command execution in sessions
1 parent a0e7ac2 commit 51edf88

11 files changed

Lines changed: 375 additions & 29 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ termbeam --password mysecret
6565
termbeam --tunnel --generate-password
6666
```
6767

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+
6874
## 📖 Usage
6975

7076
```bash

package-lock.json

Lines changed: 10 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
"dependencies": {
5858
"cookie-parser": "^1.4.7",
5959
"express": "^5.2.1",
60-
"node-pty": "1.0.0",
60+
"node-pty": "^1.1.0",
6161
"qrcode": "^1.5.4",
6262
"ws": "^8.19.0"
6363
},

public/index.html

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,8 +231,18 @@
231231
color: #e0e0e0;
232232
font-size: 15px;
233233
outline: none;
234+
-webkit-appearance: none;
235+
appearance: none;
234236
}
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 {
236246
border-color: #533483;
237247
}
238248
.modal-actions {
@@ -459,8 +469,12 @@ <h1>📡 Term<span>Cast</span></h1>
459469
<h2>New Session</h2>
460470
<label for="sess-name">Name</label>
461471
<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" />
464478
<label for="sess-cwd">Working Directory</label>
465479
<div class="cwd-picker">
466480
<input type="text" id="sess-cwd" placeholder="/Users/dorlugasigal" />
@@ -568,6 +582,7 @@ <h3>
568582
}
569583

570584
document.getElementById('new-session-btn').addEventListener('click', () => {
585+
loadShells();
571586
modal.classList.add('visible');
572587
});
573588
document.getElementById('modal-cancel').addEventListener('click', () => {
@@ -581,11 +596,13 @@ <h3>
581596
const name = document.getElementById('sess-name').value.trim();
582597
const shell = document.getElementById('sess-shell').value.trim();
583598
const cwd = document.getElementById('sess-cwd').value.trim();
599+
const initialCommand = document.getElementById('sess-cmd').value.trim();
584600

585601
const body = {};
586602
if (name) body.name = name;
587603
if (shell) body.shell = shell;
588604
if (cwd) body.cwd = cwd;
605+
if (initialCommand) body.initialCommand = initialCommand;
589606

590607
const res = await fetch('/api/sessions', {
591608
method: 'POST',
@@ -596,6 +613,30 @@ <h3>
596613
location.href = data.url;
597614
});
598615

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+
599640
// --- Swipe to delete ---
600641
async function deleteSession(id, e) {
601642
e.stopPropagation();

public/terminal.html

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@
6565
align-items: center;
6666
gap: 8px;
6767
}
68+
#status-bar .right {
69+
display: flex;
70+
align-items: center;
71+
gap: 8px;
72+
}
6873
#back-btn {
6974
background: none;
7075
border: none;
@@ -76,6 +81,22 @@
7681
#back-btn:active {
7782
color: #e0e0e0;
7883
}
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+
}
79100
#status-dot {
80101
width: 8px;
81102
height: 8px;
@@ -207,8 +228,11 @@
207228
<span id="status-dot"></span>
208229
<span id="session-name"></span>
209230
</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>
212236
</div>
213237

214238
<div id="terminal-container"></div>
@@ -437,6 +461,15 @@
437461
connect();
438462
});
439463

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+
440473
// Tap terminal area to toggle keyboard (intentional user action)
441474
container.addEventListener('click', () => term.focus());
442475

src/cli.js

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,61 @@ Environment:
3232
`);
3333
}
3434

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+
3586
function parseArgs() {
3687
let port = parseInt(process.env.PORT || '3456', 10);
3788
let host = '0.0.0.0';
38-
const defaultShell = process.env.SHELL || '/bin/zsh';
89+
const defaultShell = getDefaultShell();
3990
const cwd = process.env.TERMBEAM_CWD || process.env.PTY_CWD || process.cwd();
4091
let password = process.env.TERMBEAM_PASSWORD || process.env.PTY_PASSWORD || null;
4192
let useTunnel = false;

src/routes.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const path = require('path');
22
const os = require('os');
33
const fs = require('fs');
4+
const { detectShells } = require('./shells');
45

56
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
67

@@ -46,16 +47,23 @@ function setupRoutes(app, { auth, sessions, config }) {
4647
});
4748

4849
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 || {};
5051
const id = sessions.create({
5152
name: name || `Session ${sessions.sessions.size + 1}`,
5253
shell: shell || config.defaultShell,
5354
args: shellArgs || [],
5455
cwd: cwd || config.cwd,
56+
initialCommand: initialCommand || null,
5557
});
5658
res.json({ id, url: `/terminal?id=${id}` });
5759
});
5860

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+
5967
app.delete('/api/sessions/:id', auth.middleware, (req, res) => {
6068
if (sessions.delete(req.params.id)) {
6169
res.json({ ok: true });

src/sessions.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ class SessionManager {
66
this.sessions = new Map();
77
}
88

9-
create({ name, shell, args = [], cwd }) {
9+
create({ name, shell, args = [], cwd, initialCommand = null }) {
1010
const id = crypto.randomBytes(4).toString('hex');
1111
const ptyProcess = pty.spawn(shell, args, {
1212
name: 'xterm-256color',
@@ -16,6 +16,11 @@ class SessionManager {
1616
env: { ...process.env, TERM: 'xterm-256color' },
1717
});
1818

19+
// Send initial command once the shell is ready
20+
if (initialCommand) {
21+
setTimeout(() => ptyProcess.write(initialCommand + '\r'), 300);
22+
}
23+
1924
const session = {
2025
pty: ptyProcess,
2126
name,

0 commit comments

Comments
 (0)