webtun 1.4.1 → 1.4.3

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,7 +106,17 @@ npx webtun --tunnel # With Cloudflare Tunnel
106
106
  npx webtun -p 4000 -t # Port 4000 + tunnel
107
107
  ```
108
108
 
109
- **Note:** `node-pty` requires build tools on first install:
109
+ **Note:** npm 12+ blocks install scripts by default. Allow them for `node-pty` (native module):
110
+ ```bash
111
+ # Allow scripts once during install
112
+ npm install -g --allow-scripts=webtun,node-pty webtun
113
+
114
+ # Or allow globally (one-time setup)
115
+ npm config set allow-scripts=webtun,node-pty --location=user
116
+ npm install -g webtun
117
+ ```
118
+
119
+ Build tools are also required (for compiling native modules):
110
120
  ```bash
111
121
  # Debian/Ubuntu
112
122
  sudo apt-get install -y python3 make g++
package/bin/webtun.js CHANGED
@@ -23,7 +23,7 @@ function printHelp() {
23
23
  PORT Server port (default: 3000)
24
24
  HOST Bind address (default: 0.0.0.0)
25
25
  PIN Authentication PIN (empty = no auth)
26
- SHELL Shell to use (default: /bin/bash or system shell)
26
+ SHELL Shell to use (default: PowerShell on Windows, bash/sh elsewhere)
27
27
  WORKSPACE_ROOT Root directory for file operations (default: ~)
28
28
 
29
29
  Examples:
@@ -70,25 +70,20 @@ function parseArgs(argv) {
70
70
  }
71
71
 
72
72
  function startTunnel(port) {
73
- const { execSync, spawn } = require('child_process');
74
- const os = require('os');
73
+ const { spawn } = require('child_process');
74
+ const { findCloudflared } = require('../server');
75
75
 
76
- // Check if cloudflared is installed
77
- try {
78
- if (os.platform() === 'win32') {
79
- execSync('where cloudflared', { stdio: 'ignore' });
80
- } else {
81
- execSync('command -v cloudflared', { stdio: 'ignore' });
82
- }
83
- } catch {
76
+ const bin = findCloudflared();
77
+ if (!bin) {
84
78
  console.error('\n Error: cloudflared is not installed.');
85
79
  console.error(' Install it from: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/');
80
+ console.error(' Or re-run: npm install webtun (postinstall downloads it)');
86
81
  return;
87
82
  }
88
83
 
89
- console.log('\n Starting Cloudflare Tunnel...');
84
+ console.log(' Starting Cloudflare Tunnel...');
90
85
 
91
- const proc = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
86
+ const proc = spawn(bin, ['tunnel', '--url', `http://localhost:${port}`], {
92
87
  stdio: ['ignore', 'pipe', 'pipe']
93
88
  });
94
89
 
@@ -121,23 +116,22 @@ function startTunnel(port) {
121
116
  }
122
117
  });
123
118
 
124
- // Cleanup on exit
125
- process.on('SIGINT', () => {
126
- try { proc.kill('SIGTERM'); } catch {}
127
- process.exit(0);
128
- });
129
- process.on('SIGTERM', () => {
119
+ const stop = () => {
130
120
  try { proc.kill('SIGTERM'); } catch {}
131
121
  process.exit(0);
132
- });
122
+ };
123
+ process.on('SIGINT', stop);
124
+ process.on('SIGTERM', stop);
133
125
  }
134
126
 
135
127
  const opts = parseArgs(args);
136
- const { startServer } = require('../server');
128
+ const { startServer, PORT } = require('../server');
129
+
130
+ const listenPort = opts.port || PORT;
137
131
 
138
132
  startServer(opts).then(() => {
139
133
  if (opts.tunnel) {
140
- startTunnel(opts.port || 3000);
134
+ startTunnel(listenPort);
141
135
  }
142
136
  }).catch(err => {
143
137
  console.error('Failed to start server:', err.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webtun",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "Self-hosted web terminal with Cloudflare Tunnel, file explorer, and PWA support",
5
5
  "author": {
6
6
  "name": "Gaurang Patel",
@@ -93,10 +93,12 @@
93
93
  }
94
94
  },
95
95
  "dependencies": {
96
+ "archiver": "^8.0.0",
96
97
  "express": "^4.18.2",
97
98
  "multer": "^2.0.0",
98
99
  "node-pty": "^1.0.0",
99
- "ws": "^8.21.0"
100
+ "ws": "^8.21.0",
101
+ "yauzl": "^3.4.0"
100
102
  },
101
103
  "devDependencies": {
102
104
  "electron": "^42.2.0",
@@ -105,5 +107,8 @@
105
107
  },
106
108
  "overrides": {
107
109
  "form-data": "4.0.6"
110
+ },
111
+ "allowScripts": {
112
+ "node-pty@1.1.0": true
108
113
  }
109
114
  }
package/postinstall.js CHANGED
@@ -2,6 +2,8 @@ const { execSync, spawnSync } = require('child_process');
2
2
  const fs = require('fs');
3
3
  const os = require('os');
4
4
  const path = require('path');
5
+ const https = require('https');
6
+ const http = require('http');
5
7
 
6
8
  // ── Rebuild node-pty if native module is missing ─────────────────────
7
9
  function rebuildNodePty() {
@@ -18,16 +20,20 @@ function rebuildNodePty() {
18
20
  spawnSync('npm', ['rebuild', 'node-pty'], {
19
21
  cwd: __dirname,
20
22
  stdio: 'inherit',
21
- timeout: 120000
23
+ timeout: 120000,
24
+ shell: os.platform() === 'win32'
22
25
  });
23
26
  // Verify it worked
27
+ delete require.cache[require.resolve('node-pty')];
24
28
  require('node-pty');
25
29
  console.log(' node-pty rebuilt successfully');
26
30
  } catch (e) {
27
31
  console.log(' node-pty rebuild failed: ' + e.message);
28
- console.log(' Terminal requires build tools: python3, make, g++');
29
- console.log(' Linux: sudo apt-get install -y python3 make g++');
30
- console.log(' macOS: xcode-select --install');
32
+ console.log(' Terminal requires native build tools:');
33
+ console.log(' Linux: sudo apt-get install -y python3 make g++');
34
+ console.log(' macOS: xcode-select --install');
35
+ console.log(' Windows: install Visual Studio Build Tools with "Desktop development with C++"');
36
+ console.log(' https://visualstudio.microsoft.com/visual-cpp-build-tools/');
31
37
  }
32
38
  }
33
39
 
@@ -36,47 +42,101 @@ rebuildNodePty();
36
42
  // ── Install cloudflared ──────────────────────────────────────────────
37
43
  const CF_RELEASES = 'https://github.com/cloudflare/cloudflared/releases/latest/download';
38
44
 
39
- try {
40
- if (os.platform() === 'win32') {
41
- execSync('where cloudflared', { stdio: 'ignore' });
45
+ function cloudflaredExists() {
46
+ try {
47
+ if (os.platform() === 'win32') {
48
+ execSync('where cloudflared', { stdio: 'ignore' });
49
+ } else {
50
+ execSync('command -v cloudflared', { stdio: 'ignore' });
51
+ }
52
+ return true;
53
+ } catch {}
54
+
55
+ const isWin = os.platform() === 'win32';
56
+ const name = isWin ? 'cloudflared.exe' : 'cloudflared';
57
+ const candidates = [
58
+ path.join(__dirname, name),
59
+ path.join(process.cwd(), name),
60
+ ];
61
+ if (isWin) {
62
+ const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
63
+ candidates.push(path.join(localApp, 'cloudflared', name));
64
+ const pf = process.env.ProgramW6432 || process.env.ProgramFiles;
65
+ if (pf) candidates.push(path.join(pf, 'cloudflared', name));
42
66
  } else {
43
- execSync('command -v cloudflared', { stdio: 'ignore' });
67
+ candidates.push(path.join(os.homedir(), '.local', 'bin', name));
68
+ candidates.push('/usr/local/bin/' + name);
44
69
  }
45
- } catch {
46
- // cloudflared not found install it
47
- const platform = os.platform();
48
- const arch = os.arch();
70
+ return candidates.some(c => {
71
+ try { return fs.existsSync(c) && fs.statSync(c).isFile(); } catch { return false; }
72
+ });
73
+ }
49
74
 
50
- const files = {
51
- linux: { x64: 'cloudflared-linux-amd64', arm64: 'cloudflared-linux-arm64', arm: 'cloudflared-linux-arm' },
52
- darwin: { x64: 'cloudflared-darwin-amd64.tgz', arm64: 'cloudflared-darwin-arm64.tgz' },
53
- win32: { x64: 'cloudflared-windows-amd64.exe' }
54
- };
75
+ if (cloudflaredExists()) process.exit(0);
55
76
 
56
- const file = (files[platform] || {})[arch];
57
- if (!file) process.exit(0);
77
+ const platform = os.platform();
78
+ const arch = os.arch();
58
79
 
59
- console.log(' installing cloudflared...');
80
+ const files = {
81
+ linux: { x64: 'cloudflared-linux-amd64', arm64: 'cloudflared-linux-arm64', arm: 'cloudflared-linux-arm' },
82
+ darwin: { x64: 'cloudflared-darwin-amd64.tgz', arm64: 'cloudflared-darwin-arm64.tgz' },
83
+ win32: { x64: 'cloudflared-windows-amd64.exe', arm64: 'cloudflared-windows-amd64.exe' }
84
+ };
60
85
 
61
- const url = CF_RELEASES + '/' + file;
62
- const tmpDir = os.tmpdir();
63
- const tmp = path.join(tmpDir, 'cloudflared-' + process.pid + (platform === 'win32' ? '.exe' : ''));
86
+ const file = (files[platform] || {})[arch];
87
+ if (!file) process.exit(0);
64
88
 
65
- function cleanup() {
66
- try { fs.unlinkSync(tmp); } catch {}
67
- }
89
+ console.log(' installing cloudflared...');
68
90
 
69
- function download() {
70
- const r = spawnSync('curl', ['-#fL', url, '-o', tmp], { stdio: 'inherit', timeout: 60000 });
71
- if (r.status === 0) return true;
72
- const r2 = spawnSync('wget', ['-q', url, '-O', tmp], { stdio: 'inherit', timeout: 60000 });
73
- return r2.status === 0;
74
- }
91
+ const url = CF_RELEASES + '/' + file;
92
+ const tmpDir = os.tmpdir();
93
+ const tmp = path.join(tmpDir, 'cloudflared-' + process.pid + (platform === 'win32' ? '.exe' : ''));
75
94
 
76
- if (!download()) {
77
- console.log(' cloudflared install failed (download error) skipping');
78
- cleanup();
79
- process.exit(0);
95
+ function cleanup() {
96
+ try { fs.unlinkSync(tmp); } catch {}
97
+ }
98
+
99
+ function downloadFile(downloadUrl, dest) {
100
+ return new Promise((resolve, reject) => {
101
+ const mod = downloadUrl.startsWith('https') ? https : http;
102
+ const req = mod.get(downloadUrl, { headers: { 'User-Agent': 'webtun-postinstall' } }, res => {
103
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
104
+ res.resume();
105
+ return downloadFile(res.headers.location, dest).then(resolve, reject);
106
+ }
107
+ if (res.statusCode !== 200) {
108
+ res.resume();
109
+ return reject(new Error('HTTP ' + res.statusCode));
110
+ }
111
+ const out = fs.createWriteStream(dest);
112
+ res.pipe(out);
113
+ out.on('finish', () => out.close(err => err ? reject(err) : resolve()));
114
+ out.on('error', reject);
115
+ });
116
+ req.on('error', reject);
117
+ req.setTimeout(60000, () => {
118
+ req.destroy();
119
+ reject(new Error('download timeout'));
120
+ });
121
+ });
122
+ }
123
+
124
+ function downloadWithCurlOrWget() {
125
+ const r = spawnSync('curl', ['-#fL', url, '-o', tmp], { stdio: 'inherit', timeout: 60000 });
126
+ if (r.status === 0) return true;
127
+ const r2 = spawnSync('wget', ['-q', url, '-O', tmp], { stdio: 'inherit', timeout: 60000 });
128
+ return r2.status === 0;
129
+ }
130
+
131
+ async function main() {
132
+ try {
133
+ await downloadFile(url, tmp);
134
+ } catch (e) {
135
+ if (!downloadWithCurlOrWget()) {
136
+ console.log(' cloudflared install failed (download error) — skipping');
137
+ cleanup();
138
+ process.exit(0);
139
+ }
80
140
  }
81
141
 
82
142
  // Validate downloaded file is not HTML (e.g. 404 page)
@@ -98,6 +158,7 @@ try {
98
158
  }
99
159
 
100
160
  function installBin(src, dest) {
161
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
101
162
  try {
102
163
  fs.renameSync(src, dest);
103
164
  } catch (e) {
@@ -111,35 +172,61 @@ try {
111
172
  try { fs.chmodSync(dest, 0o755); } catch {}
112
173
  }
113
174
 
175
+ function preferredDest() {
176
+ const name = platform === 'win32' ? 'cloudflared.exe' : 'cloudflared';
177
+ if (platform === 'win32') {
178
+ const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
179
+ return path.join(localApp, 'cloudflared', name);
180
+ }
181
+ return path.join(os.homedir(), '.local', 'bin', name);
182
+ }
183
+
184
+ function extractDarwinIfNeeded() {
185
+ if (platform !== 'darwin') return tmp;
186
+ const result = spawnSync('tar', ['xzf', tmp, '-C', tmpDir], { stdio: 'inherit' });
187
+ if (result.status !== 0) throw new Error('tar extraction failed');
188
+ try { fs.unlinkSync(tmp); } catch {}
189
+ return path.join(tmpDir, 'cloudflared');
190
+ }
191
+
114
192
  try {
115
- if (platform === 'darwin') {
116
- const result = spawnSync('tar', ['xzf', tmp, '-C', tmpDir], { stdio: 'inherit' });
117
- if (result.status !== 0) throw new Error('tar extraction failed');
118
- installBin(path.join(tmpDir, 'cloudflared'), '/usr/local/bin/cloudflared');
119
- try { fs.unlinkSync(tmp); } catch {}
120
- } else if (platform === 'win32') {
121
- const progFiles = process.env.ProgramW6432 || process.env.ProgramFiles || (process.env.SystemRoot + '\\Program Files');
122
- const dest = path.join(progFiles, 'cloudflared', 'cloudflared.exe');
123
- fs.mkdirSync(path.dirname(dest), { recursive: true });
124
- installBin(tmp, dest);
125
- } else {
126
- installBin(tmp, '/usr/local/bin/cloudflared');
193
+ const src = extractDarwinIfNeeded();
194
+ // Prefer user-local path (no admin), then package dir, then system
195
+ const dests = [preferredDest(), path.join(__dirname, platform === 'win32' ? 'cloudflared.exe' : 'cloudflared')];
196
+ if (platform !== 'win32') dests.push('/usr/local/bin/cloudflared');
197
+ else {
198
+ const pf = process.env.ProgramW6432 || process.env.ProgramFiles;
199
+ if (pf) dests.push(path.join(pf, 'cloudflared', 'cloudflared.exe'));
127
200
  }
128
- console.log(' cloudflared installed');
129
- cleanup();
130
- } catch (e) {
131
- if (e.code === 'EACCES' || e.code === 'EPERM') {
132
- const localDest = path.join(process.cwd(), 'cloudflared' + (platform === 'win32' ? '.exe' : ''));
201
+
202
+ let installed = null;
203
+ let lastErr = null;
204
+ for (const dest of dests) {
133
205
  try {
134
- installBin(tmp, localDest);
135
- console.log(' cloudflared installed to ' + localDest);
136
- console.log(' Move it to your PATH: sudo mv ' + localDest + ' /usr/local/bin/');
137
- } catch (e2) {
138
- console.log(' cloudflared install failed: ' + e2.message + ' — skipping');
206
+ installBin(src, dest);
207
+ installed = dest;
208
+ break;
209
+ } catch (e) {
210
+ lastErr = e;
211
+ }
212
+ }
213
+
214
+ if (installed) {
215
+ console.log(' cloudflared installed → ' + installed);
216
+ if (platform === 'win32' && installed.includes('Local')) {
217
+ console.log(' (not on PATH; WebTun will find it automatically)');
139
218
  }
140
219
  } else {
141
- console.log(' cloudflared install failed: ' + e.message + ' — skipping');
220
+ console.log(' cloudflared install failed: ' + (lastErr && lastErr.message) + ' — skipping');
142
221
  }
143
222
  cleanup();
223
+ } catch (e) {
224
+ console.log(' cloudflared install failed: ' + e.message + ' — skipping');
225
+ cleanup();
144
226
  }
145
227
  }
228
+
229
+ main().catch(e => {
230
+ console.log(' cloudflared install failed: ' + e.message + ' — skipping');
231
+ cleanup();
232
+ });
package/public/index.html CHANGED
@@ -1152,6 +1152,7 @@ svg { display: block; }
1152
1152
  <div style="display:flex;align-items:center;gap:8px">
1153
1153
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/></svg>
1154
1154
  <span>WebTun</span>
1155
+ <span id="about-version" style="color:var(--fg3);font-size:11px"></span>
1155
1156
  </div>
1156
1157
  <div style="display:flex;align-items:center;gap:8px">
1157
1158
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--fg2)" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
@@ -2437,27 +2438,74 @@ function formatSize(bytes) {
2437
2438
  return (bytes / 1073741824).toFixed(1) + ' GB';
2438
2439
  }
2439
2440
 
2440
- function renderBreadcrumb(path) {
2441
+ function renderBreadcrumb(fullPath) {
2441
2442
  const el = document.getElementById('file-breadcrumb');
2442
2443
  if (!el) return;
2443
- const parts = path.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
2444
+ const isWin = /\\/.test(fullPath) || /^[A-Za-z]:/.test(fullPath);
2445
+ const sep = isWin ? '\\' : '/';
2444
2446
  let html = '';
2445
- let accumulated = '';
2446
- for (const part of parts) {
2447
- accumulated += '/' + part;
2448
- const isLast = part === parts[parts.length - 1];
2449
- if (isLast) {
2450
- html += `<span style="color:var(--fg);font-weight:500">${escHtml(part)}</span>`;
2447
+
2448
+ if (isWin) {
2449
+ // e.g. C:\Users\name or \\server\share\path
2450
+ const normalized = fullPath.replace(/\//g, '\\');
2451
+ const unc = normalized.startsWith('\\\\');
2452
+ let rest = normalized;
2453
+ let accumulated = '';
2454
+ const segments = [];
2455
+
2456
+ if (unc) {
2457
+ const m = normalized.match(/^\\\\[^\\]+\\[^\\]+/);
2458
+ if (m) {
2459
+ segments.push({ label: m[0], path: m[0] });
2460
+ rest = normalized.slice(m[0].length).replace(/^\\+/, '');
2461
+ accumulated = m[0];
2462
+ }
2451
2463
  } else {
2452
- html += `<a href="#" data-path="${escHtml(accumulated)}" style="color:var(--accent);text-decoration:none;padding:4px 2px;display:inline-block">${escHtml(part)}</a>`;
2453
- html += `<span style="color:var(--fg2);margin:0 2px">/</span>`;
2464
+ const driveMatch = normalized.match(/^([A-Za-z]:)(.*)$/);
2465
+ if (driveMatch) {
2466
+ const root = driveMatch[1] + '\\';
2467
+ segments.push({ label: driveMatch[1], path: root });
2468
+ rest = (driveMatch[2] || '').replace(/^\\+/, '');
2469
+ accumulated = root;
2470
+ }
2471
+ }
2472
+
2473
+ const parts = rest.split('\\').filter(Boolean);
2474
+ for (let i = 0; i < parts.length; i++) {
2475
+ accumulated = accumulated.endsWith('\\') ? accumulated + parts[i] : accumulated + '\\' + parts[i];
2476
+ segments.push({ label: parts[i], path: accumulated });
2477
+ }
2478
+
2479
+ if (!segments.length) {
2480
+ html = `<span style="color:var(--fg2)">\\</span><span style="color:var(--fg2);font-size:11px;margin-left:6px">(root)</span>`;
2481
+ } else {
2482
+ html = segments.map((seg, i) => {
2483
+ const isLast = i === segments.length - 1;
2484
+ if (isLast) return `<span style="color:var(--fg);font-weight:500">${escHtml(seg.label)}</span>`;
2485
+ return `<a href="#" data-path="${escHtml(seg.path)}" style="color:var(--accent);text-decoration:none;padding:4px 2px;display:inline-block">${escHtml(seg.label)}</a>` +
2486
+ `<span style="color:var(--fg2);margin:0 2px">\\</span>`;
2487
+ }).join('');
2488
+ }
2489
+ } else {
2490
+ const parts = fullPath.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
2491
+ let accumulated = '';
2492
+ for (const part of parts) {
2493
+ accumulated += '/' + part;
2494
+ const isLast = part === parts[parts.length - 1];
2495
+ if (isLast) {
2496
+ html += `<span style="color:var(--fg);font-weight:500">${escHtml(part)}</span>`;
2497
+ } else {
2498
+ html += `<a href="#" data-path="${escHtml(accumulated)}" style="color:var(--accent);text-decoration:none;padding:4px 2px;display:inline-block">${escHtml(part)}</a>`;
2499
+ html += `<span style="color:var(--fg2);margin:0 2px">/</span>`;
2500
+ }
2501
+ }
2502
+ if (!parts.length) {
2503
+ html = `<span style="color:var(--fg2)">/</span><span style="color:var(--fg2);font-size:11px;margin-left:6px">(root)</span>`;
2454
2504
  }
2455
2505
  }
2456
- if (!parts.length) {
2457
- html = `<span style="color:var(--fg2)">/</span><span style="color:var(--fg2);font-size:11px;margin-left:6px">(root)</span>`;
2458
- }
2506
+
2459
2507
  el.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--fg2);margin-right:4px;vertical-align:middle"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>' + html;
2460
-
2508
+
2461
2509
  if (!el.dataset.delegated) {
2462
2510
  el.addEventListener('click', e => {
2463
2511
  const a = e.target.closest('a[data-path]');
@@ -4947,6 +4995,12 @@ function registerSW() {
4947
4995
  // ═══════════════════════════════════════════════════════
4948
4996
  init().catch(e => console.error('Init failed:', e));
4949
4997
 
4998
+ // Set version in About section
4999
+ fetch('/api/version').then(r => r.json()).then(d => {
5000
+ const el = document.getElementById('about-version');
5001
+ if (el) el.textContent = 'v' + (d.version || '');
5002
+ }).catch(() => {});
5003
+
4950
5004
  // Warn before closing if there are unsaved changes or active terminals
4951
5005
  window.addEventListener('beforeunload', e => {
4952
5006
  const editorOpen = document.getElementById('editor-view').classList.contains('open');
package/server.js CHANGED
@@ -14,17 +14,29 @@ try {
14
14
  } catch {}
15
15
 
16
16
  const express = require('express');
17
- const http = require('http');
18
17
  const WebSocket = require('ws');
19
18
  let pty;
20
19
  try {
21
20
  pty = require('node-pty');
22
21
  } catch (e) {
23
- console.error('\n Error: node-pty failed to load. Terminal functionality requires native build tools.');
24
- console.error(' Install build tools and reinstall:');
25
- console.error(' Linux: sudo apt-get install -y python3 make g++');
26
- console.error(' macOS: xcode-select --install');
27
- console.error(' Then reinstall: npm install webtun\n');
22
+ console.error('');
23
+ console.error(' Error: node-pty native module not found.');
24
+ console.error('');
25
+ console.error(' npm 12+ blocks install scripts by default. To fix:');
26
+ console.error('');
27
+ console.error(' Option 1 — Allow scripts once:');
28
+ console.error(' npm install -g --allow-scripts=webtun,node-pty webtun');
29
+ console.error('');
30
+ console.error(' Option 2 — Allow scripts globally (one-time):');
31
+ console.error(' npm config set allow-scripts=webtun,node-pty --location=user');
32
+ console.error(' npm install -g webtun');
33
+ console.error('');
34
+ console.error(' Option 3 — If building from source, install build tools first:');
35
+ console.error(' Linux: sudo apt-get install -y python3 make g++');
36
+ console.error(' macOS: xcode-select --install');
37
+ console.error(' Windows: install "Desktop development with C++" (Visual Studio Build Tools)');
38
+ console.error(' https://visualstudio.microsoft.com/visual-cpp-build-tools/');
39
+ console.error('');
28
40
  process.exit(1);
29
41
  }
30
42
  const multer = require('multer');
@@ -33,6 +45,10 @@ const path = require('path');
33
45
  const os = require('os');
34
46
  const crypto = require('crypto');
35
47
  const { execSync, execFileSync, spawn } = require('child_process');
48
+ const archiver = require('archiver');
49
+ const yauzl = require('yauzl');
50
+ const https = require('https');
51
+ const http = require('http');
36
52
 
37
53
  // MIME type lookup without mime-types dependency
38
54
  const MIME_MAP = {
@@ -136,6 +152,10 @@ app.get('/api/auth/required', (req, res) => {
136
152
  res.json({ required: !!PIN });
137
153
  });
138
154
 
155
+ app.get('/api/version', (req, res) => {
156
+ res.json({ version: require('./package.json').version });
157
+ });
158
+
139
159
  app.post('/api/auth', authRateLimiter, (req, res) => {
140
160
  const { pin } = req.body;
141
161
  if (!PIN || (pin && constantTimeEqual(pin, PIN))) {
@@ -166,6 +186,188 @@ function realPath(targetPath) {
166
186
  try { return fs.realpathSync(resolved); } catch { return resolved; }
167
187
  }
168
188
 
189
+ // Case-aware path containment (Windows paths are case-insensitive).
190
+ function pathContained(parent, child) {
191
+ let p = path.resolve(parent);
192
+ let c = path.resolve(child);
193
+ if (os.platform() === 'win32') {
194
+ p = p.replace(/\\/g, '/').toLowerCase();
195
+ c = c.replace(/\\/g, '/').toLowerCase();
196
+ if (!p.endsWith('/')) p += '/';
197
+ return c === p.slice(0, -1) || c.startsWith(p);
198
+ }
199
+ return c === p || c.startsWith(p + path.sep);
200
+ }
201
+
202
+ async function renameWithFallback(src, dst) {
203
+ try {
204
+ await fsPromises.rename(src, dst);
205
+ } catch (e) {
206
+ if (e.code === 'EXDEV') {
207
+ await fsPromises.cp(src, dst, { recursive: true, force: true });
208
+ await fsPromises.rm(src, { recursive: true, force: true });
209
+ } else {
210
+ throw e;
211
+ }
212
+ }
213
+ }
214
+
215
+ async function dirSize(dir) {
216
+ let total = 0;
217
+ async function walk(d) {
218
+ let entries;
219
+ try { entries = await fsPromises.readdir(d, { withFileTypes: true }); } catch { return; }
220
+ await Promise.all(entries.map(async e => {
221
+ const full = path.join(d, e.name);
222
+ try {
223
+ if (e.isDirectory()) await walk(full);
224
+ else if (e.isFile()) {
225
+ const st = await fsPromises.stat(full);
226
+ total += st.size;
227
+ }
228
+ } catch {}
229
+ }));
230
+ }
231
+ await walk(dir);
232
+ return total;
233
+ }
234
+
235
+ function createZipArchive(entries, zipPath) {
236
+ return new Promise((resolve, reject) => {
237
+ const output = fs.createWriteStream(zipPath);
238
+ const archive = archiver('zip', { zlib: { level: 6 } });
239
+ output.on('close', () => resolve());
240
+ output.on('error', reject);
241
+ archive.on('error', reject);
242
+ archive.pipe(output);
243
+ for (const entry of entries) {
244
+ try {
245
+ const st = fs.statSync(entry.fullPath);
246
+ if (st.isDirectory()) archive.directory(entry.fullPath, entry.nameInZip);
247
+ else archive.file(entry.fullPath, { name: entry.nameInZip });
248
+ } catch (e) {
249
+ return reject(e);
250
+ }
251
+ }
252
+ archive.finalize();
253
+ });
254
+ }
255
+
256
+ function streamZipDirectory(dirPath, res) {
257
+ const archive = archiver('zip', { zlib: { level: 6 } });
258
+ archive.on('error', err => {
259
+ if (!res.headersSent) res.status(500).json({ error: err.message });
260
+ else res.end();
261
+ });
262
+ archive.pipe(res);
263
+ archive.directory(dirPath, path.basename(dirPath));
264
+ archive.finalize();
265
+ }
266
+
267
+ function extractZip(zipPath, destDir) {
268
+ return new Promise((resolve, reject) => {
269
+ yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
270
+ if (err) return reject(err);
271
+ zipfile.readEntry();
272
+ zipfile.on('entry', entry => {
273
+ const entryName = entry.fileName.replace(/\\/g, '/');
274
+ const entryPath = path.normalize(entryName);
275
+ if (entryPath.startsWith('..') || path.isAbsolute(entryPath) || entryName.includes('\0')) {
276
+ return reject(new Error('Invalid zip entry: ' + entry.fileName));
277
+ }
278
+ const target = path.join(destDir, entryPath);
279
+ if (!pathContained(destDir, target)) {
280
+ return reject(new Error('Zip entry escapes destination directory'));
281
+ }
282
+ if (/\/$/.test(entryName)) {
283
+ fs.mkdirSync(target, { recursive: true });
284
+ zipfile.readEntry();
285
+ return;
286
+ }
287
+ fs.mkdirSync(path.dirname(target), { recursive: true });
288
+ zipfile.openReadStream(entry, (err2, readStream) => {
289
+ if (err2) return reject(err2);
290
+ const writeStream = fs.createWriteStream(target);
291
+ readStream.on('error', reject);
292
+ writeStream.on('error', reject);
293
+ writeStream.on('close', () => zipfile.readEntry());
294
+ readStream.pipe(writeStream);
295
+ });
296
+ });
297
+ zipfile.on('end', () => resolve());
298
+ zipfile.on('error', reject);
299
+ });
300
+ });
301
+ }
302
+
303
+ function findCloudflared() {
304
+ const isWin = os.platform() === 'win32';
305
+ const name = isWin ? 'cloudflared.exe' : 'cloudflared';
306
+ const candidates = [
307
+ path.join(__dirname, name),
308
+ path.join(process.cwd(), name),
309
+ ];
310
+ if (isWin) {
311
+ const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
312
+ candidates.push(path.join(localApp, 'cloudflared', name));
313
+ const pf = process.env.ProgramW6432 || process.env.ProgramFiles;
314
+ if (pf) candidates.push(path.join(pf, 'cloudflared', name));
315
+ } else {
316
+ candidates.push(path.join(os.homedir(), '.local', 'bin', name));
317
+ candidates.push('/usr/local/bin/' + name);
318
+ candidates.push('/usr/bin/' + name);
319
+ }
320
+ for (const c of candidates) {
321
+ try { if (fs.existsSync(c) && fs.statSync(c).isFile()) return c; } catch {}
322
+ }
323
+ try {
324
+ const cmd = isWin ? 'where cloudflared' : 'command -v cloudflared';
325
+ const out = execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().split(/\r?\n/)[0];
326
+ if (out && fs.existsSync(out)) return out;
327
+ } catch {}
328
+ return null;
329
+ }
330
+
331
+ function killPid(pid, signal = 'SIGTERM') {
332
+ if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return;
333
+ try {
334
+ if (os.platform() === 'win32') {
335
+ execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore' });
336
+ } else {
337
+ process.kill(pid, signal);
338
+ }
339
+ } catch {}
340
+ }
341
+
342
+ function buildSessionEnv() {
343
+ if (os.platform() === 'win32') {
344
+ const env = { ...process.env };
345
+ env.TERM = env.TERM || 'xterm-256color';
346
+ env.COLORTERM = env.COLORTERM || 'truecolor';
347
+ if (!env.HOME && env.USERPROFILE) env.HOME = env.USERPROFILE;
348
+ if (!env.USER && env.USERNAME) env.USER = env.USERNAME;
349
+ env.SHELL = SHELL;
350
+ // Prefer Path (Windows) over PATH if both set
351
+ if (env.Path && !env.PATH) env.PATH = env.Path;
352
+ return env;
353
+ }
354
+ const safe = {
355
+ TERM: 'xterm-256color',
356
+ COLORTERM: 'truecolor',
357
+ HOME: process.env.HOME || '',
358
+ USER: process.env.USER || '',
359
+ PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin',
360
+ LANG: process.env.LANG || 'C.UTF-8',
361
+ SHELL
362
+ };
363
+ if (process.env.NODE_ENV) safe.NODE_ENV = process.env.NODE_ENV;
364
+ // Preserve common terminal/locale vars when present
365
+ for (const k of ['LC_ALL', 'LC_CTYPE', 'TERM_PROGRAM', 'COLORFGBG']) {
366
+ if (process.env[k]) safe[k] = process.env[k];
367
+ }
368
+ return safe;
369
+ }
370
+
169
371
  async function safeStat(p) {
170
372
  try { return await fsPromises.stat(p); } catch { return null; }
171
373
  }
@@ -273,7 +475,7 @@ app.post('/api/files/rename', checkPin, async (req, res) => {
273
475
  }
274
476
  const oldPath = realPath(req.body.oldPath);
275
477
  const newPath = realPath(path.join(path.dirname(oldPath), req.body.newName));
276
- await fsPromises.rename(oldPath, newPath);
478
+ await renameWithFallback(oldPath, newPath);
277
479
  res.json({ success: true, newPath });
278
480
  } catch (e) {
279
481
  res.status(500).json({ error: e.message });
@@ -322,7 +524,7 @@ async function resolveCopyMove(src, dst, conflict, isMove) {
322
524
  }
323
525
  await fsPromises.rm(src, { recursive: true, force: true });
324
526
  } else {
325
- await fsPromises.rename(src, dst);
527
+ await renameWithFallback(src, dst);
326
528
  }
327
529
  } else {
328
530
  if (isDir) {
@@ -410,7 +612,7 @@ app.post('/api/files/zip', checkPin, async (req, res) => {
410
612
  return res.status(400).json({ error: 'path is required', usage: 'POST JSON { "path": "<file_or_dir>" }' });
411
613
  }
412
614
  const p = realPath(req.body.path);
413
- const st = await fsPromises.stat(p);
615
+ await fsPromises.stat(p);
414
616
  const baseName = path.basename(p);
415
617
  let zipName = baseName + '.zip';
416
618
  let zipPath = path.join(path.dirname(p), zipName);
@@ -421,13 +623,7 @@ app.post('/api/files/zip', checkPin, async (req, res) => {
421
623
  zipPath = path.join(path.dirname(p), zipName);
422
624
  counter++;
423
625
  }
424
- const zipDir = path.dirname(zipPath);
425
- const zipTarget = path.basename(zipPath);
426
- if (st.isDirectory()) {
427
- execSync(`zip -r -6 "${zipTarget}" "${baseName}"`, { cwd: zipDir, stdio: 'pipe' });
428
- } else {
429
- execSync(`zip -6 "${zipTarget}" "${baseName}"`, { cwd: zipDir, stdio: 'pipe' });
430
- }
626
+ await createZipArchive([{ fullPath: p, nameInZip: baseName }], zipPath);
431
627
  res.json({ success: true, name: zipName });
432
628
  } catch (e) {
433
629
  res.status(500).json({ error: e.message });
@@ -445,23 +641,8 @@ app.post('/api/files/unzip', checkPin, async (req, res) => {
445
641
  if (ext !== '.zip') return res.status(400).json({ error: 'Not a zip file' });
446
642
  const destDir = path.join(path.dirname(p), path.basename(p, '.zip'));
447
643
  await fsPromises.mkdir(destDir, { recursive: true });
448
- // Security: scan zip entries before extraction
449
- const unzipList = execSync(`unzip -l "${p}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
450
- for (const line of unzipList.split('\n')) {
451
- const match = line.match(/^\s*\S+\s+\S+\s+(.+)$/);
452
- if (match) {
453
- const entryPath = path.normalize(match[1]);
454
- if (entryPath.startsWith('..') || path.isAbsolute(entryPath)) {
455
- return res.status(400).json({ error: 'Invalid zip entry: ' + match[1] });
456
- }
457
- const target = path.join(destDir, entryPath);
458
- if (!target.startsWith(destDir + path.sep) && target !== destDir) {
459
- return res.status(400).json({ error: 'Zip entry escapes destination directory' });
460
- }
461
- }
462
- }
463
644
  try {
464
- execSync(`unzip -o "${p}" -d "${destDir}"`, { stdio: 'pipe' });
645
+ await extractZip(p, destDir);
465
646
  } catch (e) {
466
647
  return res.status(500).json({ error: e.message });
467
648
  }
@@ -526,13 +707,7 @@ app.get('/api/files/download', checkPin, async (req, res) => {
526
707
  if (st.isDirectory()) {
527
708
  res.setHeader('Content-Type', 'application/zip');
528
709
  res.setHeader('Content-Disposition', `attachment; filename="${path.basename(p)}.zip"`);
529
- // Stream zip to response via system zip command
530
- const zipProc = spawn('zip', ['-r', '-6', '-', path.basename(p)], { cwd: path.dirname(p), stdio: ['ignore', 'pipe', 'pipe'] });
531
- zipProc.stdout.pipe(res);
532
- zipProc.on('error', err => {
533
- if (!res.headersSent) res.status(500).json({ error: err.message });
534
- });
535
- zipProc.on('close', code => { if (code !== 0 && !res.headersSent) res.status(500).json({ error: 'zip failed' }); });
710
+ streamZipDirectory(p, res);
536
711
  return;
537
712
  } else {
538
713
  const mimeType = mimeLookup(p);
@@ -562,7 +737,7 @@ app.post('/api/files/upload', checkPin, (req, res) => {
562
737
  try {
563
738
  const safeName = path.basename(file.originalname).replace(/[^a-zA-Z0-9_.\-]/g, '_');
564
739
  const finalDest = path.join(destDir, safeName);
565
- if (!finalDest.startsWith(destDir + path.sep) && finalDest !== destDir) {
740
+ if (!pathContained(destDir, finalDest)) {
566
741
  return cb(new Error('Invalid upload destination'));
567
742
  }
568
743
  const subPath = path.dirname(finalDest);
@@ -630,8 +805,7 @@ app.get('/api/files/size', checkPin, async (req, res) => {
630
805
  if (!st.isDirectory()) {
631
806
  return res.json({ path: p, size: st.size, isDir: false });
632
807
  }
633
- const out = execFileSync('du', ['-sb', p], { encoding: 'utf8', stdio: 'pipe', timeout: 30000 });
634
- const size = parseInt(out.split('\t')[0], 10);
808
+ const size = await dirSize(p);
635
809
  res.json({ path: p, size, isDir: true });
636
810
  } catch (e) {
637
811
  res.status(500).json({ error: e.message });
@@ -801,23 +975,18 @@ app.post('/api/files/batch-zip', checkPin, async (req, res) => {
801
975
  }
802
976
  let dest = realPath(req.body.destination);
803
977
  const resolved = req.body.sources.map(s => realPath(s));
804
- // Prevent zipping workspace root
805
978
  // Auto-rename if destination exists
806
979
  let counter = 1;
807
980
  const ext = '.zip';
808
981
  const origDest = dest;
809
982
  while (true) {
810
983
  try { await fsPromises.access(dest); } catch { break; }
811
- dest = origDest.replace(/(\.zip)?$/, ` (${counter})${ext}`);
984
+ dest = origDest.replace(/(\.zip)?$/i, ` (${counter})${ext}`);
812
985
  counter++;
813
986
  }
814
- const zipDir = path.dirname(dest);
815
- const zipTarget = path.basename(dest);
816
- const zipArgs = resolved.map(s => {
817
- const rel = path.relative(zipDir, s);
818
- return `"${rel}"`;
819
- });
820
- execSync(`zip -r -6 "${zipTarget}" ${zipArgs.join(' ')}`, { cwd: zipDir, stdio: 'pipe' });
987
+ await fsPromises.mkdir(path.dirname(dest), { recursive: true });
988
+ const entries = resolved.map(s => ({ fullPath: s, nameInZip: path.basename(s) }));
989
+ await createZipArchive(entries, dest);
821
990
  res.json({ success: true, name: path.basename(dest), files: req.body.sources.length });
822
991
  } catch (e) {
823
992
  res.status(500).json({ error: e.message });
@@ -1080,11 +1249,7 @@ wss.on('connection', (ws, req) => {
1080
1249
  }
1081
1250
  const sessionId = (url.searchParams.get('session') || '').replace(/[^a-zA-Z0-9_-]/g, '');
1082
1251
 
1083
- const sessionEnv = (() => {
1084
- const safe = { TERM: 'xterm-256color', COLORTERM: 'truecolor', HOME: process.env.HOME || '', USER: process.env.USER || '', PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin', LANG: process.env.LANG || 'C.UTF-8', SHELL: SHELL };
1085
- if (process.env.NODE_ENV) safe.NODE_ENV = process.env.NODE_ENV;
1086
- return safe;
1087
- })();
1252
+ const sessionEnv = buildSessionEnv();
1088
1253
 
1089
1254
  const send = (type, payload) => {
1090
1255
  if (ws.readyState !== WebSocket.OPEN) return;
@@ -1370,16 +1535,31 @@ async function verifyTunnelUrl(url, retries = 3) {
1370
1535
  return false;
1371
1536
  }
1372
1537
 
1538
+ function spawnCloudflared(args, opts = {}) {
1539
+ const bin = findCloudflared();
1540
+ if (!bin) {
1541
+ const err = new Error('cloudflared not installed');
1542
+ err.code = 'ENOENT';
1543
+ throw err;
1544
+ }
1545
+ return spawn(bin, args, opts);
1546
+ }
1547
+
1373
1548
  function restartTunnel(id, entry) {
1374
1549
  if (!entry.localUrl) return;
1375
1550
  try { if (entry.proc) entry.proc.kill('SIGTERM'); } catch {}
1376
- try { if (entry.pid && isCloudflaredProcess(entry.pid)) process.kill(entry.pid, 'SIGTERM'); } catch {}
1551
+ try { if (entry.pid && isCloudflaredProcess(entry.pid)) killPid(entry.pid); } catch {}
1377
1552
  tunnels.delete(id);
1378
1553
 
1379
1554
  const url = entry.localUrl;
1380
- const proc = spawn('cloudflared', ['tunnel', '--url', url], {
1381
- detached: true, stdio: ['ignore', 'pipe', 'pipe']
1382
- });
1555
+ let proc;
1556
+ try {
1557
+ proc = spawnCloudflared(['tunnel', '--url', url], {
1558
+ detached: true, stdio: ['ignore', 'pipe', 'pipe']
1559
+ });
1560
+ } catch {
1561
+ return;
1562
+ }
1383
1563
  proc.unref();
1384
1564
 
1385
1565
  const handler = data => {
@@ -1453,12 +1633,18 @@ app.post('/api/tunnel', checkPin, async (req, res) => {
1453
1633
  const { url } = req.body;
1454
1634
  if (!url) return res.status(400).json({ error: 'url required' });
1455
1635
 
1456
- try { execSync(os.platform() === 'win32' ? 'where cloudflared' : 'command -v cloudflared', { stdio: 'ignore' }); }
1457
- catch { return res.status(500).json({ error: 'cloudflared not installed' }); }
1636
+ if (!findCloudflared()) {
1637
+ return res.status(500).json({ error: 'cloudflared not installed' });
1638
+ }
1458
1639
 
1459
- const proc = spawn('cloudflared', ['tunnel', '--url', url], {
1460
- detached: true, stdio: ['ignore', 'pipe', 'pipe']
1461
- });
1640
+ let proc;
1641
+ try {
1642
+ proc = spawnCloudflared(['tunnel', '--url', url], {
1643
+ detached: true, stdio: ['ignore', 'pipe', 'pipe']
1644
+ });
1645
+ } catch (e) {
1646
+ return res.status(500).json({ error: e.message });
1647
+ }
1462
1648
  proc.unref();
1463
1649
  let tunnelUrl = null;
1464
1650
  const timeout = 15000;
@@ -1482,7 +1668,7 @@ app.post('/api/tunnel', checkPin, async (req, res) => {
1482
1668
  });
1483
1669
 
1484
1670
  try {
1485
- const result = await Promise.race([
1671
+ await Promise.race([
1486
1672
  urlPromise,
1487
1673
  new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeout))
1488
1674
  ]);
@@ -1493,7 +1679,7 @@ app.post('/api/tunnel', checkPin, async (req, res) => {
1493
1679
  saveTunnels();
1494
1680
  res.json({ success: true, id, url: tunnelUrl, verified: urlOk });
1495
1681
  } catch (e) {
1496
- try { proc.kill(); } catch {}
1682
+ try { if (proc.pid) killPid(proc.pid); else proc.kill(); } catch {}
1497
1683
  res.status(500).json({ error: e.message === 'timeout' ? 'Timed out waiting for tunnel URL' : e.message });
1498
1684
  }
1499
1685
  });
@@ -1504,9 +1690,10 @@ app.delete('/api/tunnel', checkPin, (req, res) => {
1504
1690
  const entry = tunnels.get(id);
1505
1691
  try {
1506
1692
  if (entry.proc) {
1507
- entry.proc.kill('SIGTERM');
1693
+ try { entry.proc.kill('SIGTERM'); } catch {}
1694
+ if (entry.proc.pid) killPid(entry.proc.pid);
1508
1695
  } else if (entry.pid && isCloudflaredProcess(entry.pid)) {
1509
- process.kill(entry.pid, 'SIGTERM');
1696
+ killPid(entry.pid);
1510
1697
  }
1511
1698
  } catch {}
1512
1699
  tunnels.delete(id);
@@ -1519,8 +1706,12 @@ app.delete('/api/tunnel', checkPin, (req, res) => {
1519
1706
  function cleanup() {
1520
1707
  for (const [id, entry] of tunnels) {
1521
1708
  try {
1522
- if (entry.proc) entry.proc.kill('SIGTERM');
1523
- else if (entry.pid && isCloudflaredProcess(entry.pid)) process.kill(entry.pid, 'SIGTERM');
1709
+ if (entry.proc) {
1710
+ try { entry.proc.kill('SIGTERM'); } catch {}
1711
+ if (entry.proc.pid) killPid(entry.proc.pid);
1712
+ } else if (entry.pid && isCloudflaredProcess(entry.pid)) {
1713
+ killPid(entry.pid);
1714
+ }
1524
1715
  } catch {}
1525
1716
  }
1526
1717
  if (TMUX) {
@@ -1541,60 +1732,12 @@ function startServer(opts = {}) {
1541
1732
  loadTunnels();
1542
1733
  cleanupOrphanTmuxSessions();
1543
1734
 
1544
- return new Promise((resolve) => {
1735
+ return new Promise((resolve, reject) => {
1736
+ server.once('error', reject);
1545
1737
  server.listen(port, host, () => {
1546
- console.log(`\n WebTun running → http://localhost:${port}\n`);
1547
- if (PIN) console.log(` PIN protection enabled\n`);
1548
- console.log(` File API examples:`);
1549
- console.log(` GET /api/files?path=<dir> — list directory`);
1550
- console.log(` GET /api/files/read?path=<file> — read file content`);
1551
- console.log(` POST /api/files/write — write file { path, content }`);
1552
- console.log(` POST /api/files/upload?path=<dir> — upload files (multipart)`);
1553
- console.log(` GET /api/files/download?path=<path> — download file/dir`);
1554
- console.log(` GET /api/files/image?path=<file> — view image inline`);
1555
- console.log(` POST /api/files/rename — rename { oldPath, newName }`);
1556
- console.log(` POST /api/files/copy — copy { source, destination, conflict? }`);
1557
- console.log(` POST /api/files/move — move { source, destination, conflict? }`);
1558
- console.log(` DELETE /api/files?path=<path> — delete file/dir`);
1559
- console.log(` POST /api/files/mkdir — create dir { path }`);
1560
- console.log(` POST /api/files/touch — create file { path }`);
1561
- console.log(` POST /api/files/zip — create zip { path }`);
1562
- console.log(` POST /api/files/unzip — extract zip { path }`);
1563
- console.log(` GET /api/search?q=<query>&path=<dir> — search files`);
1564
- console.log(` GET /api/files/stat?path=<path> — file metadata`);
1565
- console.log(` POST /api/files/batch-delete — bulk delete { paths: [...] }`);
1566
- console.log(` POST /api/files/batch-copy — bulk copy { sources: [...], destination, conflict? }`);
1567
- console.log(` POST /api/files/batch-move — bulk move { sources: [...], destination, conflict? }`);
1568
- console.log(` POST /api/files/chmod — change perms { path, mode }`);
1569
- console.log(` POST /api/files/symlink — create symlink { target, linkPath }`);
1570
- console.log(` POST /api/files/search-content — full-text search { query, path, pattern?, maxResults? }`);
1571
- console.log(` POST /api/files/batch-zip — multi-source zip { sources: [...], destination }`);
1572
- console.log(` POST /api/files/trash — trash files { paths: [...] }`);
1573
- console.log(` GET /api/files/trash — list trash`);
1574
- console.log(` POST /api/files/trash/restore — restore trash { path }`);
1575
- console.log(` DELETE /api/files/trash?path=<path> — delete trash item permanently`);
1576
- console.log(` DELETE /api/files/trash/all — empty entire trash`);
1577
- console.log(` GET /api/files/preview?path=<file> — file preview (md→html, code)`);
1578
- console.log(` GET /api/files/tail?path=<file>&lines=N — tail log file (SSE)`);
1579
- console.log(` Git API:`);
1580
- console.log(` GET /api/git/status?path=<dir> — git status`);
1581
- console.log(` POST /api/git/diff — git diff { path, file? }`);
1582
- console.log(` POST /api/git/add — git add { path, files? }`);
1583
- console.log(` POST /api/git/commit — git commit { path, message }`);
1584
- console.log(` GET /api/git/log?path=<dir>&maxCount=N — git log`);
1585
- console.log(` POST /api/git/push — git push { path, remote?, branch? }`);
1586
- console.log(` POST /api/git/pull — git pull { path, remote?, branch? }`);
1587
- console.log(` GET /api/git/branches?path=<dir> — list branches`);
1588
- console.log(` POST /api/git/branch — create branch { path, name, switch? }`);
1589
- console.log(` GET /api/git/remote?path=<dir> — list remotes`);
1590
- console.log(` System:`);
1591
- console.log(` GET /api/system/network — network interfaces, ports`);
1592
- console.log(` GET /api/env — environment variables`);
1593
- console.log(` Clipboard:`);
1594
- console.log(` GET /api/clipboard — clipboard contents`);
1595
- console.log(` POST /api/clipboard — set clipboard { sources, action }`);
1596
- console.log(` POST /api/clipboard/paste — paste { destination, conflict? }`);
1597
- console.log(` DELETE /api/clipboard — clear clipboard`);
1738
+ console.log(`\n WebTun running → http://localhost:${port}`);
1739
+ if (PIN) console.log(` PIN protection enabled`);
1740
+ console.log('');
1598
1741
  resolve(server);
1599
1742
  });
1600
1743
  });
@@ -1613,7 +1756,7 @@ process.on('SIGTERM', () => { try { cleanup(); } catch {}; process.exit(0); });
1613
1756
  process.on('SIGINT', () => { try { cleanup(); } catch {}; process.exit(0); });
1614
1757
  process.on('exit', () => { try { cleanup(); } catch {} });
1615
1758
 
1616
- module.exports = { app, server, startServer, PORT, PIN, WORKSPACE_ROOT };
1759
+ module.exports = { app, server, startServer, PORT, PIN, WORKSPACE_ROOT, findCloudflared };
1617
1760
 
1618
1761
  if (require.main === module) {
1619
1762
  startServer();