webtun 1.4.2 → 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/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.2",
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,7 +14,6 @@ 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 {
@@ -33,8 +32,10 @@ try {
33
32
  console.error(' npm install -g webtun');
34
33
  console.error('');
35
34
  console.error(' Option 3 — If building from source, install build tools first:');
36
- console.error(' Linux: sudo apt-get install -y python3 make g++');
37
- console.error(' macOS: xcode-select --install');
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/');
38
39
  console.error('');
39
40
  process.exit(1);
40
41
  }
@@ -44,6 +45,10 @@ const path = require('path');
44
45
  const os = require('os');
45
46
  const crypto = require('crypto');
46
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');
47
52
 
48
53
  // MIME type lookup without mime-types dependency
49
54
  const MIME_MAP = {
@@ -147,6 +152,10 @@ app.get('/api/auth/required', (req, res) => {
147
152
  res.json({ required: !!PIN });
148
153
  });
149
154
 
155
+ app.get('/api/version', (req, res) => {
156
+ res.json({ version: require('./package.json').version });
157
+ });
158
+
150
159
  app.post('/api/auth', authRateLimiter, (req, res) => {
151
160
  const { pin } = req.body;
152
161
  if (!PIN || (pin && constantTimeEqual(pin, PIN))) {
@@ -177,6 +186,188 @@ function realPath(targetPath) {
177
186
  try { return fs.realpathSync(resolved); } catch { return resolved; }
178
187
  }
179
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
+
180
371
  async function safeStat(p) {
181
372
  try { return await fsPromises.stat(p); } catch { return null; }
182
373
  }
@@ -284,7 +475,7 @@ app.post('/api/files/rename', checkPin, async (req, res) => {
284
475
  }
285
476
  const oldPath = realPath(req.body.oldPath);
286
477
  const newPath = realPath(path.join(path.dirname(oldPath), req.body.newName));
287
- await fsPromises.rename(oldPath, newPath);
478
+ await renameWithFallback(oldPath, newPath);
288
479
  res.json({ success: true, newPath });
289
480
  } catch (e) {
290
481
  res.status(500).json({ error: e.message });
@@ -333,7 +524,7 @@ async function resolveCopyMove(src, dst, conflict, isMove) {
333
524
  }
334
525
  await fsPromises.rm(src, { recursive: true, force: true });
335
526
  } else {
336
- await fsPromises.rename(src, dst);
527
+ await renameWithFallback(src, dst);
337
528
  }
338
529
  } else {
339
530
  if (isDir) {
@@ -421,7 +612,7 @@ app.post('/api/files/zip', checkPin, async (req, res) => {
421
612
  return res.status(400).json({ error: 'path is required', usage: 'POST JSON { "path": "<file_or_dir>" }' });
422
613
  }
423
614
  const p = realPath(req.body.path);
424
- const st = await fsPromises.stat(p);
615
+ await fsPromises.stat(p);
425
616
  const baseName = path.basename(p);
426
617
  let zipName = baseName + '.zip';
427
618
  let zipPath = path.join(path.dirname(p), zipName);
@@ -432,13 +623,7 @@ app.post('/api/files/zip', checkPin, async (req, res) => {
432
623
  zipPath = path.join(path.dirname(p), zipName);
433
624
  counter++;
434
625
  }
435
- const zipDir = path.dirname(zipPath);
436
- const zipTarget = path.basename(zipPath);
437
- if (st.isDirectory()) {
438
- execSync(`zip -r -6 "${zipTarget}" "${baseName}"`, { cwd: zipDir, stdio: 'pipe' });
439
- } else {
440
- execSync(`zip -6 "${zipTarget}" "${baseName}"`, { cwd: zipDir, stdio: 'pipe' });
441
- }
626
+ await createZipArchive([{ fullPath: p, nameInZip: baseName }], zipPath);
442
627
  res.json({ success: true, name: zipName });
443
628
  } catch (e) {
444
629
  res.status(500).json({ error: e.message });
@@ -456,23 +641,8 @@ app.post('/api/files/unzip', checkPin, async (req, res) => {
456
641
  if (ext !== '.zip') return res.status(400).json({ error: 'Not a zip file' });
457
642
  const destDir = path.join(path.dirname(p), path.basename(p, '.zip'));
458
643
  await fsPromises.mkdir(destDir, { recursive: true });
459
- // Security: scan zip entries before extraction
460
- const unzipList = execSync(`unzip -l "${p}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
461
- for (const line of unzipList.split('\n')) {
462
- const match = line.match(/^\s*\S+\s+\S+\s+(.+)$/);
463
- if (match) {
464
- const entryPath = path.normalize(match[1]);
465
- if (entryPath.startsWith('..') || path.isAbsolute(entryPath)) {
466
- return res.status(400).json({ error: 'Invalid zip entry: ' + match[1] });
467
- }
468
- const target = path.join(destDir, entryPath);
469
- if (!target.startsWith(destDir + path.sep) && target !== destDir) {
470
- return res.status(400).json({ error: 'Zip entry escapes destination directory' });
471
- }
472
- }
473
- }
474
644
  try {
475
- execSync(`unzip -o "${p}" -d "${destDir}"`, { stdio: 'pipe' });
645
+ await extractZip(p, destDir);
476
646
  } catch (e) {
477
647
  return res.status(500).json({ error: e.message });
478
648
  }
@@ -537,13 +707,7 @@ app.get('/api/files/download', checkPin, async (req, res) => {
537
707
  if (st.isDirectory()) {
538
708
  res.setHeader('Content-Type', 'application/zip');
539
709
  res.setHeader('Content-Disposition', `attachment; filename="${path.basename(p)}.zip"`);
540
- // Stream zip to response via system zip command
541
- const zipProc = spawn('zip', ['-r', '-6', '-', path.basename(p)], { cwd: path.dirname(p), stdio: ['ignore', 'pipe', 'pipe'] });
542
- zipProc.stdout.pipe(res);
543
- zipProc.on('error', err => {
544
- if (!res.headersSent) res.status(500).json({ error: err.message });
545
- });
546
- zipProc.on('close', code => { if (code !== 0 && !res.headersSent) res.status(500).json({ error: 'zip failed' }); });
710
+ streamZipDirectory(p, res);
547
711
  return;
548
712
  } else {
549
713
  const mimeType = mimeLookup(p);
@@ -573,7 +737,7 @@ app.post('/api/files/upload', checkPin, (req, res) => {
573
737
  try {
574
738
  const safeName = path.basename(file.originalname).replace(/[^a-zA-Z0-9_.\-]/g, '_');
575
739
  const finalDest = path.join(destDir, safeName);
576
- if (!finalDest.startsWith(destDir + path.sep) && finalDest !== destDir) {
740
+ if (!pathContained(destDir, finalDest)) {
577
741
  return cb(new Error('Invalid upload destination'));
578
742
  }
579
743
  const subPath = path.dirname(finalDest);
@@ -641,8 +805,7 @@ app.get('/api/files/size', checkPin, async (req, res) => {
641
805
  if (!st.isDirectory()) {
642
806
  return res.json({ path: p, size: st.size, isDir: false });
643
807
  }
644
- const out = execFileSync('du', ['-sb', p], { encoding: 'utf8', stdio: 'pipe', timeout: 30000 });
645
- const size = parseInt(out.split('\t')[0], 10);
808
+ const size = await dirSize(p);
646
809
  res.json({ path: p, size, isDir: true });
647
810
  } catch (e) {
648
811
  res.status(500).json({ error: e.message });
@@ -812,23 +975,18 @@ app.post('/api/files/batch-zip', checkPin, async (req, res) => {
812
975
  }
813
976
  let dest = realPath(req.body.destination);
814
977
  const resolved = req.body.sources.map(s => realPath(s));
815
- // Prevent zipping workspace root
816
978
  // Auto-rename if destination exists
817
979
  let counter = 1;
818
980
  const ext = '.zip';
819
981
  const origDest = dest;
820
982
  while (true) {
821
983
  try { await fsPromises.access(dest); } catch { break; }
822
- dest = origDest.replace(/(\.zip)?$/, ` (${counter})${ext}`);
984
+ dest = origDest.replace(/(\.zip)?$/i, ` (${counter})${ext}`);
823
985
  counter++;
824
986
  }
825
- const zipDir = path.dirname(dest);
826
- const zipTarget = path.basename(dest);
827
- const zipArgs = resolved.map(s => {
828
- const rel = path.relative(zipDir, s);
829
- return `"${rel}"`;
830
- });
831
- 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);
832
990
  res.json({ success: true, name: path.basename(dest), files: req.body.sources.length });
833
991
  } catch (e) {
834
992
  res.status(500).json({ error: e.message });
@@ -1091,11 +1249,7 @@ wss.on('connection', (ws, req) => {
1091
1249
  }
1092
1250
  const sessionId = (url.searchParams.get('session') || '').replace(/[^a-zA-Z0-9_-]/g, '');
1093
1251
 
1094
- const sessionEnv = (() => {
1095
- 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 };
1096
- if (process.env.NODE_ENV) safe.NODE_ENV = process.env.NODE_ENV;
1097
- return safe;
1098
- })();
1252
+ const sessionEnv = buildSessionEnv();
1099
1253
 
1100
1254
  const send = (type, payload) => {
1101
1255
  if (ws.readyState !== WebSocket.OPEN) return;
@@ -1381,16 +1535,31 @@ async function verifyTunnelUrl(url, retries = 3) {
1381
1535
  return false;
1382
1536
  }
1383
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
+
1384
1548
  function restartTunnel(id, entry) {
1385
1549
  if (!entry.localUrl) return;
1386
1550
  try { if (entry.proc) entry.proc.kill('SIGTERM'); } catch {}
1387
- 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 {}
1388
1552
  tunnels.delete(id);
1389
1553
 
1390
1554
  const url = entry.localUrl;
1391
- const proc = spawn('cloudflared', ['tunnel', '--url', url], {
1392
- detached: true, stdio: ['ignore', 'pipe', 'pipe']
1393
- });
1555
+ let proc;
1556
+ try {
1557
+ proc = spawnCloudflared(['tunnel', '--url', url], {
1558
+ detached: true, stdio: ['ignore', 'pipe', 'pipe']
1559
+ });
1560
+ } catch {
1561
+ return;
1562
+ }
1394
1563
  proc.unref();
1395
1564
 
1396
1565
  const handler = data => {
@@ -1464,12 +1633,18 @@ app.post('/api/tunnel', checkPin, async (req, res) => {
1464
1633
  const { url } = req.body;
1465
1634
  if (!url) return res.status(400).json({ error: 'url required' });
1466
1635
 
1467
- try { execSync(os.platform() === 'win32' ? 'where cloudflared' : 'command -v cloudflared', { stdio: 'ignore' }); }
1468
- catch { return res.status(500).json({ error: 'cloudflared not installed' }); }
1636
+ if (!findCloudflared()) {
1637
+ return res.status(500).json({ error: 'cloudflared not installed' });
1638
+ }
1469
1639
 
1470
- const proc = spawn('cloudflared', ['tunnel', '--url', url], {
1471
- detached: true, stdio: ['ignore', 'pipe', 'pipe']
1472
- });
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
+ }
1473
1648
  proc.unref();
1474
1649
  let tunnelUrl = null;
1475
1650
  const timeout = 15000;
@@ -1493,7 +1668,7 @@ app.post('/api/tunnel', checkPin, async (req, res) => {
1493
1668
  });
1494
1669
 
1495
1670
  try {
1496
- const result = await Promise.race([
1671
+ await Promise.race([
1497
1672
  urlPromise,
1498
1673
  new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeout))
1499
1674
  ]);
@@ -1504,7 +1679,7 @@ app.post('/api/tunnel', checkPin, async (req, res) => {
1504
1679
  saveTunnels();
1505
1680
  res.json({ success: true, id, url: tunnelUrl, verified: urlOk });
1506
1681
  } catch (e) {
1507
- try { proc.kill(); } catch {}
1682
+ try { if (proc.pid) killPid(proc.pid); else proc.kill(); } catch {}
1508
1683
  res.status(500).json({ error: e.message === 'timeout' ? 'Timed out waiting for tunnel URL' : e.message });
1509
1684
  }
1510
1685
  });
@@ -1515,9 +1690,10 @@ app.delete('/api/tunnel', checkPin, (req, res) => {
1515
1690
  const entry = tunnels.get(id);
1516
1691
  try {
1517
1692
  if (entry.proc) {
1518
- entry.proc.kill('SIGTERM');
1693
+ try { entry.proc.kill('SIGTERM'); } catch {}
1694
+ if (entry.proc.pid) killPid(entry.proc.pid);
1519
1695
  } else if (entry.pid && isCloudflaredProcess(entry.pid)) {
1520
- process.kill(entry.pid, 'SIGTERM');
1696
+ killPid(entry.pid);
1521
1697
  }
1522
1698
  } catch {}
1523
1699
  tunnels.delete(id);
@@ -1530,8 +1706,12 @@ app.delete('/api/tunnel', checkPin, (req, res) => {
1530
1706
  function cleanup() {
1531
1707
  for (const [id, entry] of tunnels) {
1532
1708
  try {
1533
- if (entry.proc) entry.proc.kill('SIGTERM');
1534
- 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
+ }
1535
1715
  } catch {}
1536
1716
  }
1537
1717
  if (TMUX) {
@@ -1552,60 +1732,12 @@ function startServer(opts = {}) {
1552
1732
  loadTunnels();
1553
1733
  cleanupOrphanTmuxSessions();
1554
1734
 
1555
- return new Promise((resolve) => {
1735
+ return new Promise((resolve, reject) => {
1736
+ server.once('error', reject);
1556
1737
  server.listen(port, host, () => {
1557
- console.log(`\n WebTun running → http://localhost:${port}\n`);
1558
- if (PIN) console.log(` PIN protection enabled\n`);
1559
- console.log(` File API examples:`);
1560
- console.log(` GET /api/files?path=<dir> — list directory`);
1561
- console.log(` GET /api/files/read?path=<file> — read file content`);
1562
- console.log(` POST /api/files/write — write file { path, content }`);
1563
- console.log(` POST /api/files/upload?path=<dir> — upload files (multipart)`);
1564
- console.log(` GET /api/files/download?path=<path> — download file/dir`);
1565
- console.log(` GET /api/files/image?path=<file> — view image inline`);
1566
- console.log(` POST /api/files/rename — rename { oldPath, newName }`);
1567
- console.log(` POST /api/files/copy — copy { source, destination, conflict? }`);
1568
- console.log(` POST /api/files/move — move { source, destination, conflict? }`);
1569
- console.log(` DELETE /api/files?path=<path> — delete file/dir`);
1570
- console.log(` POST /api/files/mkdir — create dir { path }`);
1571
- console.log(` POST /api/files/touch — create file { path }`);
1572
- console.log(` POST /api/files/zip — create zip { path }`);
1573
- console.log(` POST /api/files/unzip — extract zip { path }`);
1574
- console.log(` GET /api/search?q=<query>&path=<dir> — search files`);
1575
- console.log(` GET /api/files/stat?path=<path> — file metadata`);
1576
- console.log(` POST /api/files/batch-delete — bulk delete { paths: [...] }`);
1577
- console.log(` POST /api/files/batch-copy — bulk copy { sources: [...], destination, conflict? }`);
1578
- console.log(` POST /api/files/batch-move — bulk move { sources: [...], destination, conflict? }`);
1579
- console.log(` POST /api/files/chmod — change perms { path, mode }`);
1580
- console.log(` POST /api/files/symlink — create symlink { target, linkPath }`);
1581
- console.log(` POST /api/files/search-content — full-text search { query, path, pattern?, maxResults? }`);
1582
- console.log(` POST /api/files/batch-zip — multi-source zip { sources: [...], destination }`);
1583
- console.log(` POST /api/files/trash — trash files { paths: [...] }`);
1584
- console.log(` GET /api/files/trash — list trash`);
1585
- console.log(` POST /api/files/trash/restore — restore trash { path }`);
1586
- console.log(` DELETE /api/files/trash?path=<path> — delete trash item permanently`);
1587
- console.log(` DELETE /api/files/trash/all — empty entire trash`);
1588
- console.log(` GET /api/files/preview?path=<file> — file preview (md→html, code)`);
1589
- console.log(` GET /api/files/tail?path=<file>&lines=N — tail log file (SSE)`);
1590
- console.log(` Git API:`);
1591
- console.log(` GET /api/git/status?path=<dir> — git status`);
1592
- console.log(` POST /api/git/diff — git diff { path, file? }`);
1593
- console.log(` POST /api/git/add — git add { path, files? }`);
1594
- console.log(` POST /api/git/commit — git commit { path, message }`);
1595
- console.log(` GET /api/git/log?path=<dir>&maxCount=N — git log`);
1596
- console.log(` POST /api/git/push — git push { path, remote?, branch? }`);
1597
- console.log(` POST /api/git/pull — git pull { path, remote?, branch? }`);
1598
- console.log(` GET /api/git/branches?path=<dir> — list branches`);
1599
- console.log(` POST /api/git/branch — create branch { path, name, switch? }`);
1600
- console.log(` GET /api/git/remote?path=<dir> — list remotes`);
1601
- console.log(` System:`);
1602
- console.log(` GET /api/system/network — network interfaces, ports`);
1603
- console.log(` GET /api/env — environment variables`);
1604
- console.log(` Clipboard:`);
1605
- console.log(` GET /api/clipboard — clipboard contents`);
1606
- console.log(` POST /api/clipboard — set clipboard { sources, action }`);
1607
- console.log(` POST /api/clipboard/paste — paste { destination, conflict? }`);
1608
- 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('');
1609
1741
  resolve(server);
1610
1742
  });
1611
1743
  });
@@ -1624,7 +1756,7 @@ process.on('SIGTERM', () => { try { cleanup(); } catch {}; process.exit(0); });
1624
1756
  process.on('SIGINT', () => { try { cleanup(); } catch {}; process.exit(0); });
1625
1757
  process.on('exit', () => { try { cleanup(); } catch {} });
1626
1758
 
1627
- module.exports = { app, server, startServer, PORT, PIN, WORKSPACE_ROOT };
1759
+ module.exports = { app, server, startServer, PORT, PIN, WORKSPACE_ROOT, findCloudflared };
1628
1760
 
1629
1761
  if (require.main === module) {
1630
1762
  startServer();