termbeam 1.2.3 → 1.2.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
@@ -15,6 +15,16 @@ I built this because I kept needing to run quick commands on my dev machine whil
15
15
 
16
16
  https://github.com/user-attachments/assets/9dd4f3d7-f017-4314-9b3a-f6a5688e3671
17
17
 
18
+ ### Mobile UI
19
+
20
+ <table align="center">
21
+ <tr>
22
+ <td align="center"><img src="docs/assets/screenshots/mobile-session-hub.jpeg" alt="Session hub on mobile" width="250" /></td>
23
+ <td align="center"><img src="docs/assets/screenshots/mobile-session-preview.jpeg" alt="Session preview on mobile" width="250" /></td>
24
+ <td align="center"><img src="docs/assets/screenshots/mobile-terminal.jpeg" alt="Terminal on mobile" width="250" /></td>
25
+ </tr>
26
+ </table>
27
+
18
28
  ## Quick Start
19
29
 
20
30
  ```bash
@@ -102,9 +112,10 @@ termbeam --host 127.0.0.1 # restrict to localhost (default: 0.0.0.0)
102
112
  | `--password <pw>` | Set access password (also accepts `--password=<pw>`) | Auto-generated |
103
113
  | `--no-password` | Disable password | — |
104
114
  | `--generate-password` | Auto-generate a secure password | On |
105
- | `--tunnel` | Create an ephemeral devtunnel URL | On |
115
+ | `--tunnel` | Create an ephemeral devtunnel URL (private) | On |
106
116
  | `--no-tunnel` | Disable tunnel (LAN-only) | — |
107
117
  | `--persisted-tunnel` | Create a reusable devtunnel URL | Off |
118
+ | `--public` | Allow public tunnel access | Off |
108
119
  | `--port <port>` | Server port | `3456` |
109
120
  | `--host <addr>` | Bind address | `0.0.0.0` |
110
121
  | `--log-level <level>` | Log verbosity (error/warn/info/debug) | `info` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termbeam",
3
- "version": "1.2.3",
3
+ "version": "1.2.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": {
@@ -9,8 +9,8 @@
9
9
  "scripts": {
10
10
  "start": "node bin/termbeam.js",
11
11
  "dev": "node bin/termbeam.js --generate-password",
12
- "test": "node -e \"require('child_process').execFileSync(process.execPath,['--test',...require('fs').readdirSync('test').filter(f=>f.endsWith('.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')).map(f=>'test/'+f)],{stdio:'inherit'})\"",
12
+ "test": "node -e \"require('child_process').execFileSync(process.execPath,['--test',...require('fs').readdirSync('test').filter(f=>f.endsWith('.test.js')&&!f.startsWith('e2e-')).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-')).map(f=>'test/'+f)],{stdio:'inherit'})\"",
14
14
  "prepare": "husky",
15
15
  "format": "prettier --write .",
16
16
  "lint": "node --check src/*.js bin/*.js",
@@ -73,6 +73,7 @@
73
73
  "ws": "^8.19.0"
74
74
  },
75
75
  "devDependencies": {
76
+ "@playwright/test": "^1.58.2",
76
77
  "c8": "^11.0.0",
77
78
  "husky": "^9.1.7",
78
79
  "lint-staged": "^16.2.7",
package/public/sw.js CHANGED
@@ -2,17 +2,17 @@ const CACHE_NAME = 'termbeam-v5';
2
2
  const SHELL_URLS = ['/', '/terminal'];
3
3
 
4
4
  self.addEventListener('install', (event) => {
5
- event.waitUntil(
6
- caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_URLS))
7
- );
5
+ event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_URLS)));
8
6
  self.skipWaiting();
9
7
  });
10
8
 
11
9
  self.addEventListener('activate', (event) => {
12
10
  event.waitUntil(
13
- caches.keys().then((keys) =>
14
- Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
15
- )
11
+ caches
12
+ .keys()
13
+ .then((keys) =>
14
+ Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))),
15
+ ),
16
16
  );
17
17
  self.clients.claim();
18
18
  });
@@ -21,11 +21,7 @@ self.addEventListener('fetch', (event) => {
21
21
  const url = new URL(event.request.url);
22
22
 
23
23
  // Don't cache WebSocket upgrades
24
- if (
25
- event.request.mode === 'websocket' ||
26
- url.protocol === 'ws:' ||
27
- url.protocol === 'wss:'
28
- ) {
24
+ if (event.request.mode === 'websocket' || url.protocol === 'ws:' || url.protocol === 'wss:') {
29
25
  return;
30
26
  }
31
27
 
@@ -42,7 +38,7 @@ self.addEventListener('fetch', (event) => {
42
38
  }
43
39
  return response;
44
40
  });
45
- })
41
+ }),
46
42
  );
47
43
  }
48
44
  return;
@@ -50,37 +46,43 @@ self.addEventListener('fetch', (event) => {
50
46
 
51
47
  // Network-first for API calls
52
48
  if (url.pathname.startsWith('/api/')) {
53
- event.respondWith(
54
- fetch(event.request).catch(() => caches.match(event.request))
55
- );
49
+ event.respondWith(fetch(event.request).catch(() => caches.match(event.request)));
56
50
  return;
57
51
  }
58
52
 
59
53
  // Network-first for HTML pages (always get latest code)
60
- if (event.request.mode === 'navigate' || event.request.headers.get('accept')?.includes('text/html')) {
54
+ if (
55
+ event.request.mode === 'navigate' ||
56
+ event.request.headers.get('accept')?.includes('text/html')
57
+ ) {
61
58
  event.respondWith(
62
- fetch(event.request).then((response) => {
63
- if (response.ok) {
64
- const clone = response.clone();
65
- caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
66
- }
67
- return response;
68
- }).catch(() => caches.match(event.request))
59
+ fetch(event.request)
60
+ .then((response) => {
61
+ if (response.ok) {
62
+ const clone = response.clone();
63
+ caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
64
+ }
65
+ return response;
66
+ })
67
+ .catch(() => caches.match(event.request)),
69
68
  );
70
69
  return;
71
70
  }
72
71
 
73
72
  // Cache-first for static assets (JS, CSS, images)
74
73
  event.respondWith(
75
- caches.match(event.request).then((cached) => {
76
- if (cached) return cached;
77
- return fetch(event.request).then((response) => {
78
- if (response.ok) {
79
- const clone = response.clone();
80
- caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
81
- }
82
- return response;
83
- });
84
- }).catch(() => new Response('Offline', { status: 503, statusText: 'Service Unavailable' }))
74
+ caches
75
+ .match(event.request)
76
+ .then((cached) => {
77
+ if (cached) return cached;
78
+ return fetch(event.request).then((response) => {
79
+ if (response.ok) {
80
+ const clone = response.clone();
81
+ caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
82
+ }
83
+ return response;
84
+ });
85
+ })
86
+ .catch(() => new Response('Offline', { status: 503, statusText: 'Service Unavailable' })),
85
87
  );
86
88
  });
@@ -591,6 +591,34 @@
591
591
  touch-action: none;
592
592
  }
593
593
 
594
+ /* ===== Scroll-to-bottom indicator ===== */
595
+ .scroll-bottom-btn {
596
+ display: none;
597
+ position: absolute;
598
+ bottom: 8px;
599
+ right: 16px;
600
+ width: 36px;
601
+ height: 36px;
602
+ border-radius: 50%;
603
+ background: var(--accent);
604
+ color: #fff;
605
+ border: none;
606
+ font-size: 18px;
607
+ line-height: 36px;
608
+ text-align: center;
609
+ cursor: pointer;
610
+ z-index: 50;
611
+ opacity: 0.85;
612
+ transition: opacity 0.15s;
613
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
614
+ }
615
+ .scroll-bottom-btn:hover {
616
+ opacity: 1;
617
+ }
618
+ .scroll-bottom-btn.visible {
619
+ display: block;
620
+ }
621
+
594
622
  /* ===== Toasts & Overlays ===== */
595
623
  #copy-toast {
596
624
  position: fixed;
@@ -2119,6 +2147,7 @@
2119
2147
  document.documentElement.scrollTop = 0;
2120
2148
  document.body.scrollTop = 0;
2121
2149
  }
2150
+ let wasKeyboardOpen = false;
2122
2151
  function onViewportResize() {
2123
2152
  const vv = window.visualViewport;
2124
2153
  const keyboardHeight = window.innerHeight - vv.height;
@@ -2134,11 +2163,22 @@
2134
2163
  keyBar.style.paddingBottom = '';
2135
2164
  terminalsWrapper.style.bottom = '';
2136
2165
  }
2166
+ const keyboardJustClosed = wasKeyboardOpen && !keyboardOpen;
2167
+ wasKeyboardOpen = keyboardOpen;
2137
2168
  resetScroll();
2138
2169
  clearTimeout(vpResizeTimer);
2139
2170
  vpResizeTimer = setTimeout(() => {
2140
2171
  resetScroll();
2141
2172
  doResize();
2173
+ // When keyboard closes, the terminal grows taller — scroll to
2174
+ // bottom so the cursor stays visible instead of appearing offset.
2175
+ if (keyboardJustClosed) {
2176
+ for (const [, ms] of managed) {
2177
+ if (ms.container.classList.contains('visible')) {
2178
+ ms.term.scrollToBottom();
2179
+ }
2180
+ }
2181
+ }
2142
2182
  }, 150);
2143
2183
  }
2144
2184
  function onViewportScroll() {
@@ -2228,6 +2268,7 @@
2228
2268
  theme: getTheme() === 'light' ? lightTermTheme : darkTermTheme,
2229
2269
  allowProposedApi: true,
2230
2270
  scrollback: 10000,
2271
+ scrollOnOutput: false,
2231
2272
  });
2232
2273
 
2233
2274
  const fitAddon = new window.FitAddon.FitAddon();
@@ -2237,9 +2278,44 @@
2237
2278
 
2238
2279
  const container = document.createElement('div');
2239
2280
  container.className = 'terminal-pane';
2281
+ container.style.position = 'relative';
2240
2282
  terminalsWrapper.appendChild(container);
2241
2283
  term.open(container);
2242
2284
 
2285
+ // Scroll-to-bottom button
2286
+ const scrollBtn = document.createElement('button');
2287
+ scrollBtn.className = 'scroll-bottom-btn';
2288
+ scrollBtn.textContent = '↓';
2289
+ scrollBtn.title = 'Scroll to bottom';
2290
+ scrollBtn.addEventListener('click', (e) => {
2291
+ e.stopPropagation();
2292
+ term.scrollToBottom();
2293
+ });
2294
+ container.appendChild(scrollBtn);
2295
+
2296
+ // Write coalescer — batch rapid term.write() calls into one per frame
2297
+ // so ANSI cursor-up/down pairs within a frame are processed atomically
2298
+ let writeBuf = '';
2299
+ let writeRaf = null;
2300
+ function coalescedWrite(data) {
2301
+ writeBuf += data;
2302
+ if (!writeRaf) {
2303
+ writeRaf = requestAnimationFrame(() => {
2304
+ term.write(writeBuf);
2305
+ writeBuf = '';
2306
+ writeRaf = null;
2307
+ });
2308
+ }
2309
+ }
2310
+
2311
+ // Track whether user has scrolled away from bottom
2312
+ let userScrolledUp = false;
2313
+ term.onScroll(() => {
2314
+ const buf = term.buffer.active;
2315
+ userScrolledUp = buf.viewportY < buf.baseY;
2316
+ scrollBtn.classList.toggle('visible', userScrolledUp);
2317
+ });
2318
+
2243
2319
  // Pointer-event scroll handler — uses setPointerCapture so scrolling
2244
2320
  // survives xterm DOM re-renders under the finger (touch on a letter).
2245
2321
  (function () {
@@ -2349,6 +2425,8 @@
2349
2425
  term,
2350
2426
  fitAddon,
2351
2427
  container,
2428
+ coalescedWrite,
2429
+ scrollBtn,
2352
2430
  ws: null,
2353
2431
  exited: false,
2354
2432
  reconnectTimer: null,
@@ -2361,6 +2439,10 @@
2361
2439
  // Terminal input → WebSocket
2362
2440
  term.onData((input) => {
2363
2441
  if (ms.ws && ms.ws.readyState === 1) {
2442
+ // Auto-scroll to bottom on user input
2443
+ if (userScrolledUp) {
2444
+ ms.term.scrollToBottom();
2445
+ }
2364
2446
  let data = input;
2365
2447
  if (ctrlActive && input.length === 1) {
2366
2448
  const code = input.toLowerCase().charCodeAt(0);
@@ -2412,7 +2494,7 @@
2412
2494
  try {
2413
2495
  const msg = JSON.parse(event.data);
2414
2496
  if (msg.type === 'output') {
2415
- ms.term.write(msg.data);
2497
+ ms.coalescedWrite(msg.data);
2416
2498
  ms.lastActivity = Date.now();
2417
2499
  } else if (msg.type === 'attached') {
2418
2500
  if (ms.container.classList.contains('visible')) {
@@ -2448,7 +2530,7 @@
2448
2530
  }
2449
2531
  }
2450
2532
  } catch {
2451
- ms.term.write(event.data);
2533
+ ms.coalescedWrite(event.data);
2452
2534
  }
2453
2535
  };
2454
2536
 
package/src/cli.js CHANGED
@@ -15,9 +15,10 @@ Options:
15
15
  --password <pw> Set access password (or TERMBEAM_PASSWORD env var)
16
16
  --generate-password Auto-generate a secure password (default: auto)
17
17
  --no-password Disable password authentication
18
- --tunnel Create a public devtunnel URL (default: on)
18
+ --tunnel Create a devtunnel URL (default: on, private access)
19
19
  --no-tunnel Disable tunnel (LAN-only mode)
20
20
  --persisted-tunnel Create a reusable devtunnel URL (stable across restarts)
21
+ --public Allow public tunnel access (default: private, owner-only)
21
22
  --port <port> Set port (default: 3456, or PORT env var)
22
23
  --host <addr> Bind address (default: 0.0.0.0)
23
24
  --log-level <level> Set log verbosity: error, warn, info, debug (default: info)
@@ -26,7 +27,9 @@ Options:
26
27
 
27
28
  Defaults:
28
29
  By default, TermBeam enables tunnel + auto-generated password for secure
29
- mobile access (clipboard, HTTPS). Use --no-tunnel for LAN-only mode.
30
+ mobile access (clipboard, HTTPS). Tunnels are private (owner-only via
31
+ Microsoft login). Use --public for public access, or
32
+ --no-tunnel for LAN-only mode.
30
33
 
31
34
  Examples:
32
35
  termbeam Start with tunnel + auto password
@@ -227,6 +230,7 @@ function parseArgs() {
227
230
  let useTunnel = true;
228
231
  let noTunnel = false;
229
232
  let persistedTunnel = false;
233
+ let anonymousTunnel = false;
230
234
  let explicitPassword = !!password;
231
235
 
232
236
  const args = process.argv.slice(2);
@@ -243,6 +247,8 @@ function parseArgs() {
243
247
  } else if (args[i] === '--persisted-tunnel') {
244
248
  useTunnel = true;
245
249
  persistedTunnel = true;
250
+ } else if (args[i] === '--public') {
251
+ anonymousTunnel = true;
246
252
  } else if (args[i].startsWith('--password=')) {
247
253
  password = args[i].split('=')[1];
248
254
  explicitPassword = true;
@@ -278,6 +284,12 @@ function parseArgs() {
278
284
  // --no-tunnel disables the default tunnel
279
285
  if (noTunnel) useTunnel = false;
280
286
 
287
+ // --public requires a tunnel
288
+ if (anonymousTunnel && !useTunnel) {
289
+ console.error('Error: --public requires a tunnel. Remove --no-tunnel or remove --public.');
290
+ process.exit(1);
291
+ }
292
+
281
293
  const shell = filteredArgs[0] || defaultShell;
282
294
  const shellArgs = filteredArgs.slice(1);
283
295
 
@@ -290,6 +302,7 @@ function parseArgs() {
290
302
  password,
291
303
  useTunnel,
292
304
  persistedTunnel,
305
+ anonymousTunnel,
293
306
  shell,
294
307
  shellArgs,
295
308
  cwd,
package/src/logger.js CHANGED
@@ -13,10 +13,11 @@ const log = {
13
13
  if (l !== undefined) currentLevel = l;
14
14
  },
15
15
  getLevel() {
16
- return Object.keys(LEVELS).find(k => LEVELS[k] === currentLevel);
16
+ return Object.keys(LEVELS).find((k) => LEVELS[k] === currentLevel);
17
17
  },
18
18
  error(...args) {
19
- if (currentLevel >= LEVELS.error) console.error(`[${timestamp()}]`, `[${LABELS.error}]`, ...args);
19
+ if (currentLevel >= LEVELS.error)
20
+ console.error(`[${timestamp()}]`, `[${LABELS.error}]`, ...args);
20
21
  },
21
22
  warn(...args) {
22
23
  if (currentLevel >= LEVELS.warn) console.warn(`[${timestamp()}]`, `[${LABELS.warn}]`, ...args);
package/src/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  const os = require('os');
3
3
  const path = require('path');
4
+ const readline = require('readline');
4
5
  const express = require('express');
5
6
  const cookieParser = require('cookie-parser');
6
7
  const http = require('http');
@@ -26,6 +27,16 @@ function getLocalIP() {
26
27
  return '127.0.0.1';
27
28
  }
28
29
 
30
+ function confirmAnonymousTunnel() {
31
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
32
+ return new Promise((resolve) => {
33
+ rl.question(' Do you want to continue with anonymous access? (y/N): ', (answer) => {
34
+ rl.close();
35
+ resolve(answer.trim().toLowerCase() === 'y');
36
+ });
37
+ });
38
+ }
39
+
29
40
  /**
30
41
  * Create a TermBeam server instance without starting it.
31
42
  * @param {object} [overrides] - Optional overrides
@@ -53,7 +64,7 @@ function createTermBeamServer(overrides = {}) {
53
64
  res.setHeader('Cache-Control', 'no-store');
54
65
  res.setHeader(
55
66
  'Content-Security-Policy',
56
- "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self' ws: wss: https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net",
67
+ "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data:; connect-src 'self' ws: wss: https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net",
57
68
  );
58
69
  next();
59
70
  });
@@ -78,7 +89,7 @@ function createTermBeamServer(overrides = {}) {
78
89
  wss.close();
79
90
  }
80
91
 
81
- function start() {
92
+ async function start() {
82
93
  // Fail early if tunnel mode is on but devtunnel CLI is not installed
83
94
  if (config.useTunnel && !findDevtunnel()) {
84
95
  log.error('❌ devtunnel CLI is not installed.');
@@ -102,6 +113,28 @@ function createTermBeamServer(overrides = {}) {
102
113
  process.exit(1);
103
114
  }
104
115
 
116
+ // Warn and require consent for anonymous tunnel access
117
+ if (config.useTunnel && config.anonymousTunnel) {
118
+ const rd = '\x1b[31m';
119
+ const yl = '\x1b[33m';
120
+ const rs = '\x1b[0m';
121
+ const bd = '\x1b[1m';
122
+ console.log('');
123
+ console.log(` ${rd}${bd}⚠️ DANGER: Public tunnel access requested${rs}`);
124
+ console.log('');
125
+ console.log(` ${yl}This will make your terminal accessible to ANYONE with the URL.${rs}`);
126
+ console.log(` ${yl}No Microsoft login will be required to reach the tunnel.${rs}`);
127
+ console.log(` ${yl}Only the TermBeam password will protect your terminal.${rs}`);
128
+ console.log('');
129
+ const confirmed = await confirmAnonymousTunnel();
130
+ if (!confirmed) {
131
+ console.log('');
132
+ console.log(' Aborted. Restart without --public for private access.');
133
+ console.log('');
134
+ process.exit(1);
135
+ }
136
+ }
137
+
105
138
  return new Promise((resolve) => {
106
139
  server.listen(config.port, config.host, async () => {
107
140
  const ip = getLocalIP();
@@ -146,7 +179,10 @@ function createTermBeamServer(overrides = {}) {
146
179
 
147
180
  let publicUrl = null;
148
181
  if (config.useTunnel) {
149
- const tunnel = await startTunnel(config.port, { persisted: config.persistedTunnel });
182
+ const tunnel = await startTunnel(config.port, {
183
+ persisted: config.persistedTunnel,
184
+ anonymous: config.anonymousTunnel,
185
+ });
150
186
  if (tunnel) {
151
187
  publicUrl = tunnel.url;
152
188
  state.shareBaseUrl = publicUrl;
package/src/sessions.js CHANGED
@@ -3,8 +3,14 @@ const pty = require('node-pty');
3
3
  const log = require('./logger');
4
4
 
5
5
  const SESSION_COLORS = [
6
- '#4a9eff', '#4ade80', '#fbbf24', '#c084fc',
7
- '#f87171', '#22d3ee', '#fb923c', '#f472b6',
6
+ '#4a9eff',
7
+ '#4ade80',
8
+ '#fbbf24',
9
+ '#c084fc',
10
+ '#f87171',
11
+ '#22d3ee',
12
+ '#fb923c',
13
+ '#f472b6',
8
14
  ];
9
15
 
10
16
  class SessionManager {
package/src/shells.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const os = require('os');
2
2
  const fs = require('fs');
3
- const { execFileSync } = require('child_process');
3
+ const child_process = require('child_process');
4
4
 
5
5
  const KNOWN_WINDOWS_SHELLS = [
6
6
  { name: 'PowerShell (Core)', cmd: 'pwsh.exe' },
@@ -21,7 +21,7 @@ function detectWindowsShells() {
21
21
  const shells = [];
22
22
  for (const { name, cmd } of KNOWN_WINDOWS_SHELLS) {
23
23
  try {
24
- const result = execFileSync('where', [cmd], {
24
+ const result = child_process.execFileSync('where', [cmd], {
25
25
  stdio: ['pipe', 'pipe', 'ignore'],
26
26
  encoding: 'utf8',
27
27
  timeout: 3000,
package/src/tunnel.js CHANGED
@@ -186,13 +186,25 @@ async function startTunnel(port, options = {}) {
186
186
  { stdio: 'pipe' },
187
187
  );
188
188
  } catch {}
189
- try {
190
- execFileSync(
191
- devtunnelCmd,
192
- ['access', 'create', tunnelId, '-p', String(port), '--anonymous'],
193
- { stdio: 'pipe' },
194
- );
195
- } catch {}
189
+ // Set tunnel access: public (anonymous) or private (owner-only via Microsoft login)
190
+ if (options.anonymous) {
191
+ try {
192
+ execFileSync(
193
+ devtunnelCmd,
194
+ ['access', 'create', tunnelId, '-p', String(port), '--anonymous'],
195
+ { stdio: 'pipe' },
196
+ );
197
+ } catch {}
198
+ log.info('Tunnel access: public (anonymous)');
199
+ } else {
200
+ // Remove any existing anonymous access to ensure the tunnel is private
201
+ try {
202
+ execFileSync(devtunnelCmd, ['access', 'reset', tunnelId], {
203
+ stdio: 'pipe',
204
+ });
205
+ } catch {}
206
+ log.info('Tunnel access: private (owner-only via Microsoft login)');
207
+ }
196
208
 
197
209
  const hostProc = spawn(devtunnelCmd, ['host', tunnelId], {
198
210
  stdio: ['pipe', 'pipe', 'pipe'],