webtun 1.5.3 → 1.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -65,19 +65,20 @@ git clone https://github.com/unn-Known1/webtun.git && cd webtun && ./setup.sh &&
65
65
 
66
66
  ### Terminal
67
67
  - **Real shell sessions** — node-pty backed, full bash/zsh/PowerShell support
68
- - **Multi-tab** — drag to reorder, side-by-side sessions
69
- - **Bracketed paste** — Ctrl+V works in TUI apps (vim, nano, htop)
68
+ - **Multi-tab** — drag to reorder, side-by-side sessions, new-tab button next to last tab
69
+ - **Bracketed paste** — Ctrl+V works in TUI apps (vim, nano, htop) — 60KB chunked
70
70
  - **Command history** — keystroke-based capture, strips ANSI/control sequences
71
71
  - **Session persistence** — tmux or in-memory PTY; tabs survive page reload (all platforms)
72
72
  - **Keyboard Shortcuts** — reference dialog in the overflow menu (Ctrl+P/T/W/B/F, etc.)
73
73
 
74
74
  ### File Explorer
75
- - Browse, upload, download, rename, delete files
75
+ - Browse, upload, download, rename, delete files — **full filesystem access** (`ALLOW_FULL_FS=true` by default)
76
76
  - **Multi-select** — batch delete, download, zip, cut, copy
77
77
  - **Zip/Extract** — right-click any file or folder
78
78
  - **Image viewer** — preview inline (png, jpg, gif, svg, webp)
79
79
  - **Conflict resolution** — replace, merge, keep both, skip
80
- - **Breadcrumb navigation** — tappable directory segments
80
+ - **Breadcrumb navigation** — tappable segments, folder icon + path one line, `2px` tight spacing, header/breadcrumb unified `30px` bar
81
+ - **Editor** — CodeMirror with autosave draft, `F11` fullscreen (covers terminal area only, never the file explorer), `horizontal/vertical` split toggle, markdown/HTML preview
81
82
 
82
83
  ### Mobile
83
84
  - **Touch gestures** — swipe to close tabs, pull-to-refresh
@@ -96,7 +97,7 @@ git clone https://github.com/unn-Known1/webtun.git && cd webtun && ./setup.sh &&
96
97
  - CPU, Memory, Disk, Uptime — real-time with progress bars
97
98
  - GPU detection (nvidia-smi, system_profiler, WMI)
98
99
  - Multi-GPU support with VRAM, utilization, temperature
99
- - Top processes sorted by CPU
100
+ - Top processes sorted by CPU — **Kill** button per row (`POST /api/system/kill`)
100
101
 
101
102
  ### Desktop (Electron)
102
103
  - Cross-platform app for Linux, Windows, macOS
@@ -133,6 +134,7 @@ git clone https://github.com/unn-Known1/webtun.git && cd webtun && ./setup.sh &&
133
134
  | `PIN` | Auth PIN (empty = no auth) | none |
134
135
  | `SHELL` | Shell to use | platform default |
135
136
  | `WORKSPACE_ROOT` | File explorer root | `~` |
137
+ | `ALLOW_FULL_FS` | Allow browsing outside `WORKSPACE_ROOT` (full FS) | `true` |
136
138
  | `TRUST_PROXY` | Trust `X-Forwarded-For` from first proxy | `false` |
137
139
  | `WEBTUN_SHELL` | Override shell on Windows | PowerShell |
138
140
 
@@ -175,6 +177,24 @@ git clone https://github.com/unn-Known1/webtun.git && cd webtun && ./setup.sh &&
175
177
 
176
178
  ## Changelog
177
179
 
180
+ ### v1.5.5
181
+ - Explorer: full filesystem access by default (`ALLOW_FULL_FS=true` unless `ALLOW_FULL_FS=false`), symlink dirs now navigable (`stat` `isDirectory`), fixed silent `403` bounce on `..` (now `toast` + error div), `parent` hidden at workspace root, `history`/`breadcrumb` desync fixed (deferred to success), stale closures on reused file items fixed (`dataset` live), drag overlay stuck fixed (`relatedTarget`/`dragend`/`blur`), relative `path-input` now `joinPath(currentPath)`, `rename` dialog no longer closes on error
182
+ - Settings: drawer flicker fixed (`#main:isolation`, `#sidebar`/`#settings-panel` `will-change`/`backface-visibility`, `#file-list-wrap` `content-visibility` removed, `inert`/`focusTrap` deferred via `rAF` + `preventScroll`, `fileWatcher` skips when drawer/overlay open)
183
+ - Editor: `F11` fullscreen now `absolute` inside terminal area only (`#editor-split-area` `relative`), never covers file explorer/sidebar or header, removed `requestFullscreen` browser overlay, `CodeMirror` height `100%`
184
+ - Terminal: removed `Shift+F11` fullscreen button and `#terminals.fullscreen`/`#content.terminal-fullscreen` per request
185
+ - UI: `#conn-status` `margin-left:6px` before dot
186
+
187
+ ### v1.5.4
188
+ - Security hardening: path sandbox `ALLOW_FULL_FS` (default `~`), symlink containment, null-byte/array guards, `rename` traversal block, `delete` uses `lstat`, batch caps 100, zip 1GB/unzip bomb guards, `download` header sanitization, `image` no-store, `write` 10MB cap, tunnel SSRF (localhost-only) + URL validation
189
+ - Auth & transport: `checkPin` array guard, length-leak free `constantTimeEqual`, rate limiter uses `req.ip` with `trust proxy` + Map cap 10k, `--pin` arg consumption fix, WS `cols/rows` clamp 2-500, Origin check + `constantTimeEqual` token, `sessionId` length 1-64, per-IP `clipboard`, `history` max 1k & `cmdhist.json` 0600 atomic writes, `ptySessions` TTL 30m + cap 100, `TMUX_PREFIX=wt-webtun-`
190
+ - Build: predictable `tmp` via `mkdtemp`, tar-slip `tzf` validation, heredoc `printf` no-expansion, `.env` parser first-`=` fix, `pkill $SCRIPT_DIR` + `kill -0` race, `electron` `parseInt PORT` + `autostart` filter, `ws ^8.17.1`
191
+ - UI/UX: touch 32-36px, `aria-pressed`/`aria-expanded`/`aria-modal`/`inert` (siblings), focus-trap fix, `confirmDialog` queue, `skip-link`, `pan-y` + `contain`, `visualViewport` Samsung+scale, `pull-to-refresh`, `safe-area`, `cmd-lib` trap, `tiles min(300px,100%)`, `upload 6px+cancel`, `paste 60KB`, `WebGL<4`, `iframe allow-scripts`, `autosave draft 2s` + `jsx:true`
192
+ - Explorer: `new-tab` next to last tab (sticky), `sidebar-header`/`breadcrumb` unified `30px` `3px 6px gap3`, `file-item 2→1px` `breadcrumb 1px sep`, `sidebar-footer/bookmarks` compact, folder icon + path one line `flex gap2`
193
+ - Editor: `F11` fullscreen (`fixed inset0`) + `horizontal/vertical` split toggle stays usable, `Esc` to exit, `requestFullscreen` fallback, `closeEditor` clears
194
+ - Terminal: `Shift+F11` fullscreen (`#content.terminal-fullscreen` + `#terminals.fullscreen`), header button, `fitTerm` refit
195
+ - System Stats: **Kill** `POST /api/system/kill` per-row button (refuse `1/self/cloudflared`)
196
+ - PWA: `sw.js` no immediate `skipWaiting`, `token`/`/ws` bypass, stale-while-revalidate, `manifest` `?newTerm=1` + `titlebar-area` CSS
197
+
178
198
  ### v1.5.3
179
199
  - Terminal session persistence without tmux (in-memory PTY on Windows + Linux)
180
200
  - Session IDs always generated; tabs now survive page reload on all platforms
package/bin/webtun.js CHANGED
@@ -49,14 +49,26 @@ function parseArgs(argv) {
49
49
  process.exit(0);
50
50
  }
51
51
  if (arg === '--port' || arg === '-p') {
52
+ if (!argv[i+1] || argv[i+1].startsWith('-')) {
53
+ console.error('Error: --port requires a value');
54
+ process.exit(1);
55
+ }
52
56
  opts.port = parseInt(argv[++i], 10);
53
57
  if (isNaN(opts.port)) {
54
58
  console.error('Error: --port requires a numeric value');
55
59
  process.exit(1);
56
60
  }
57
61
  } else if (arg === '--host' || arg === '-h') {
62
+ if (!argv[i+1] || argv[i+1].startsWith('-')) {
63
+ console.error('Error: --host requires a value');
64
+ process.exit(1);
65
+ }
58
66
  opts.host = argv[++i];
59
67
  } else if (arg === '--pin') {
68
+ if (!argv[i+1] || argv[i+1].startsWith('-')) {
69
+ console.error('Error: --pin requires a value');
70
+ process.exit(1);
71
+ }
60
72
  process.env.PIN = argv[++i] || '';
61
73
  } else if (arg === '--tunnel' || arg === '-t') {
62
74
  opts.tunnel = true;
@@ -0,0 +1,176 @@
1
+ const { app, BrowserWindow, Menu, dialog, ipcMain } = require('electron');
2
+ const { fork } = require('child_process');
3
+ const path = require('path');
4
+ const http = require('http');
5
+ const fs = require('fs');
6
+
7
+ let mainWindow;
8
+ let serverProcess;
9
+ const PORT = (() => {
10
+ const p = parseInt(process.env.PORT, 10);
11
+ return Number.isFinite(p) && p > 0 && p <= 65535 ? p : 3000;
12
+ })();
13
+ const PIN = process.env.PIN || '';
14
+ if (PIN && typeof PIN !== 'string') {
15
+ console.warn('Invalid PIN type — expected string');
16
+ }
17
+
18
+ function resolveNodeModules() {
19
+ // Packaged app: resources/node_modules or app.asar.unpacked/node_modules
20
+ if (app.isPackaged) {
21
+ const candidates = [
22
+ path.join(process.resourcesPath, 'node_modules'),
23
+ path.join(process.resourcesPath, 'app.asar.unpacked', 'node_modules'),
24
+ path.join(path.dirname(app.getAppPath()), 'node_modules'),
25
+ ];
26
+ for (const c of candidates) {
27
+ if (fs.existsSync(c)) return c;
28
+ }
29
+ }
30
+ return path.join(__dirname, '..', 'node_modules');
31
+ }
32
+
33
+ function startServer() {
34
+ return new Promise((resolve, reject) => {
35
+ const serverPath = path.join(__dirname, '..', 'server.js');
36
+ const nodeModules = resolveNodeModules();
37
+ const env = {
38
+ ...process.env,
39
+ PORT: String(PORT),
40
+ HOST: '127.0.0.1',
41
+ };
42
+ // Validate PIN exists and is a non-empty string before passing to server
43
+ if (PIN && typeof PIN === 'string' && PIN.trim().length > 0) {
44
+ env.PIN = PIN.trim();
45
+ } else if (PIN) {
46
+ console.warn('Invalid PIN — starting without authentication');
47
+ delete env.PIN;
48
+ }
49
+ // Ensure forked server can resolve deps when packaged
50
+ env.NODE_PATH = [nodeModules, env.NODE_PATH].filter(Boolean).join(path.delimiter);
51
+
52
+ serverProcess = fork(serverPath, [], {
53
+ env,
54
+ stdio: ['ignore', 'pipe', 'pipe']
55
+ });
56
+
57
+ serverProcess.stdout.on('data', d => console.log('[server]', d.toString().trim()));
58
+ serverProcess.stderr.on('data', d => console.error('[server]', d.toString().trim()));
59
+
60
+ let settled = false;
61
+ const fail = (msg) => {
62
+ if (settled) return;
63
+ settled = true;
64
+ reject(new Error(msg));
65
+ };
66
+
67
+ serverProcess.on('exit', code => {
68
+ if (code !== 0) {
69
+ console.error(`Server exited with code ${code}`);
70
+ fail(`Server exited with code ${code}`);
71
+ }
72
+ });
73
+ serverProcess.on('error', err => fail(err.message));
74
+
75
+ const deadline = Date.now() + 30000;
76
+ const check = () => {
77
+ if (settled) return;
78
+ if (Date.now() > deadline) return fail('Server start timed out');
79
+ http.get(`http://127.0.0.1:${PORT}/api/auth/required`, res => {
80
+ if (res.statusCode === 200) {
81
+ settled = true;
82
+ resolve();
83
+ } else setTimeout(check, 200);
84
+ }).on('error', () => setTimeout(check, 200));
85
+ };
86
+ setTimeout(check, 500);
87
+ });
88
+ }
89
+
90
+ function createWindow() {
91
+ mainWindow = new BrowserWindow({
92
+ width: 1100,
93
+ height: 700,
94
+ minWidth: 600,
95
+ minHeight: 400,
96
+ title: 'WebTun',
97
+ icon: path.join(__dirname, '..', 'public', 'icon-512.png'),
98
+ webPreferences: {
99
+ nodeIntegration: false,
100
+ contextIsolation: true,
101
+ preload: path.join(__dirname, 'preload.js')
102
+ }
103
+ });
104
+
105
+ const menu = Menu.buildFromTemplate([
106
+ {
107
+ label: 'WebTun',
108
+ submenu: [
109
+ { label: 'About WebTun', role: 'about' },
110
+ { type: 'separator' },
111
+ { label: 'Quit', accelerator: 'CmdOrCtrl+Q', click: () => app.quit() }
112
+ ]
113
+ },
114
+ {
115
+ label: 'Edit',
116
+ submenu: [
117
+ { role: 'undo' }, { role: 'redo' }, { type: 'separator' },
118
+ { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' }
119
+ ]
120
+ },
121
+ {
122
+ label: 'View',
123
+ submenu: [
124
+ { role: 'reload' }, { role: 'forceReload' },
125
+ { type: 'separator' },
126
+ { role: 'togglefullscreen' },
127
+ { type: 'separator' },
128
+ { role: 'toggleDevTools' }
129
+ ]
130
+ }
131
+ ]);
132
+ Menu.setApplicationMenu(menu);
133
+
134
+ mainWindow.loadURL(`http://127.0.0.1:${PORT}`);
135
+
136
+ mainWindow.on('closed', () => { mainWindow = null; });
137
+ }
138
+
139
+ app.whenReady().then(async () => {
140
+ try {
141
+ await startServer();
142
+ createWindow();
143
+ } catch (e) {
144
+ dialog.showErrorBox('WebTun Error', `Failed to start server:\n${e.message}`);
145
+ app.quit();
146
+ }
147
+ });
148
+
149
+ app.on('window-all-closed', () => {
150
+ if (process.platform !== 'darwin') app.quit();
151
+ });
152
+
153
+ app.on('before-quit', () => {
154
+ if (serverProcess) {
155
+ try { serverProcess.kill('SIGTERM'); } catch {}
156
+ serverProcess = null;
157
+ }
158
+ });
159
+
160
+ app.on('activate', () => {
161
+ if (mainWindow === null) createWindow();
162
+ });
163
+
164
+ // ── Auto-start on login ──────────────────────────────────────
165
+ ipcMain.handle('get-autostart', () => {
166
+ return app.getLoginItemSettings().openAtLogin;
167
+ });
168
+
169
+ ipcMain.handle('set-autostart', (_event, enabled) => {
170
+ app.setLoginItemSettings({
171
+ openAtLogin: enabled,
172
+ path: process.execPath,
173
+ args: process.argv.slice(1).filter(a => !a.startsWith('--'))
174
+ });
175
+ return app.getLoginItemSettings().openAtLogin;
176
+ });
@@ -0,0 +1,7 @@
1
+ const { contextBridge, ipcRenderer } = require('electron');
2
+
3
+ contextBridge.exposeInMainWorld('electronAPI', {
4
+ isElectron: true,
5
+ getAutostart: () => ipcRenderer.invoke('get-autostart'),
6
+ setAutostart: (enabled) => ipcRenderer.invoke('set-autostart', enabled)
7
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webtun",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
4
4
  "description": "Self-hosted web terminal with Cloudflare Tunnel, file explorer, and PWA support",
5
5
  "author": {
6
6
  "name": "Gaurang Patel",
@@ -15,7 +15,8 @@
15
15
  "bin/",
16
16
  "server.js",
17
17
  "public/",
18
- "postinstall.js"
18
+ "postinstall.js",
19
+ "electron/"
19
20
  ],
20
21
  "engines": {
21
22
  "node": ">=18.0.0"
@@ -106,9 +107,47 @@
106
107
  "nodemon": "^3.1.14"
107
108
  },
108
109
  "overrides": {
109
- "form-data": "4.0.6"
110
+ "array-flatten": "npm:@socketregistry/array-flatten@^1",
111
+ "define-properties": "npm:@socketregistry/define-properties@^1",
112
+ "es-define-property": "npm:@socketregistry/es-define-property@^1",
113
+ "es-set-tostringtag": "npm:@socketregistry/es-set-tostringtag@^1",
114
+ "form-data": "4.0.6",
115
+ "function-bind": "npm:@socketregistry/function-bind@^1",
116
+ "globalthis": "npm:@socketregistry/globalthis@^1",
117
+ "gopd": "npm:@socketregistry/gopd@^1",
118
+ "has-property-descriptors": "npm:@socketregistry/has-property-descriptors@^1",
119
+ "has-symbols": "npm:@socketregistry/has-symbols@^1",
120
+ "has-tostringtag": "npm:@socketregistry/has-tostringtag@^1",
121
+ "hasown": "npm:@socketregistry/hasown@^1",
122
+ "isarray": "npm:@socketregistry/isarray@^1",
123
+ "object-keys": "npm:@socketregistry/object-keys@^1",
124
+ "safe-buffer": "npm:@socketregistry/safe-buffer@^1",
125
+ "safer-buffer": "npm:@socketregistry/safer-buffer@^1",
126
+ "side-channel": "npm:@socketregistry/side-channel@^1",
127
+ "typedarray": "npm:@socketregistry/typedarray@^1",
128
+ "ws": "^8.21.0"
129
+ },
130
+ "resolutions": {
131
+ "array-flatten": "npm:@socketregistry/array-flatten@^1",
132
+ "define-properties": "npm:@socketregistry/define-properties@^1",
133
+ "es-define-property": "npm:@socketregistry/es-define-property@^1",
134
+ "es-set-tostringtag": "npm:@socketregistry/es-set-tostringtag@^1",
135
+ "function-bind": "npm:@socketregistry/function-bind@^1",
136
+ "globalthis": "npm:@socketregistry/globalthis@^1",
137
+ "gopd": "npm:@socketregistry/gopd@^1",
138
+ "has-property-descriptors": "npm:@socketregistry/has-property-descriptors@^1",
139
+ "has-symbols": "npm:@socketregistry/has-symbols@^1",
140
+ "has-tostringtag": "npm:@socketregistry/has-tostringtag@^1",
141
+ "hasown": "npm:@socketregistry/hasown@^1",
142
+ "isarray": "npm:@socketregistry/isarray@^1",
143
+ "object-keys": "npm:@socketregistry/object-keys@^1",
144
+ "safe-buffer": "npm:@socketregistry/safe-buffer@^1",
145
+ "safer-buffer": "npm:@socketregistry/safer-buffer@^1",
146
+ "side-channel": "npm:@socketregistry/side-channel@^1",
147
+ "typedarray": "npm:@socketregistry/typedarray@^1",
148
+ "ws": "^8.21.0"
110
149
  },
111
150
  "allowScripts": {
112
- "node-pty@1.1.0": true
151
+ "node-pty": true
113
152
  }
114
153
  }
package/postinstall.js CHANGED
@@ -17,12 +17,21 @@ function rebuildNodePty() {
17
17
 
18
18
  console.log(' rebuilding node-pty...');
19
19
  try {
20
- spawnSync('npm', ['rebuild', 'node-pty'], {
20
+ const r = spawnSync('npm', ['rebuild', 'node-pty'], {
21
21
  cwd: __dirname,
22
- stdio: 'inherit',
23
- timeout: 120000,
24
- shell: os.platform() === 'win32'
22
+ stdio: 'pipe',
23
+ timeout: 120000
25
24
  });
25
+ if (r.status !== 0) {
26
+ const out = (r.stderr ? r.stderr.toString() : '') + (r.stdout ? r.stdout.toString() : '');
27
+ if (out.includes('allow-scripts') || out.toLowerCase().includes('not allowed')) {
28
+ console.log(' npm blocked build scripts (allow-scripts). To fix:');
29
+ console.log(' npm install -g --allow-scripts=webtun,node-pty webtun');
30
+ console.log(' Or: npm config set allow-scripts=webtun,node-pty --location=user');
31
+ console.log(' Then: npm install -g webtun');
32
+ }
33
+ throw new Error(out.trim() || 'rebuild failed with status ' + r.status);
34
+ }
26
35
  // Verify it worked
27
36
  delete require.cache[require.resolve('node-pty')];
28
37
  require('node-pty');
@@ -34,6 +43,9 @@ function rebuildNodePty() {
34
43
  console.log(' macOS: xcode-select --install');
35
44
  console.log(' Windows: install Visual Studio Build Tools with "Desktop development with C++"');
36
45
  console.log(' https://visualstudio.microsoft.com/visual-cpp-build-tools/');
46
+ console.log(' If npm v10+ blocks scripts, allow them:');
47
+ console.log(' npm config set allow-scripts=webtun,node-pty --location=user');
48
+ console.log(' npm install --allow-scripts=webtun,node-pty webtun');
37
49
  }
38
50
  }
39
51
 
@@ -89,11 +101,13 @@ if (!file) process.exit(0);
89
101
  console.log(' installing cloudflared...');
90
102
 
91
103
  const url = CF_RELEASES + '/' + file;
92
- const tmpDir = os.tmpdir();
93
- const tmp = path.join(tmpDir, 'cloudflared-' + process.pid + (platform === 'win32' ? '.exe' : ''));
104
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webtun-'));
105
+ const tmp = path.join(tmpDir, 'cloudflared' + (platform === 'win32' ? '.exe' : ''));
94
106
 
95
107
  function cleanup() {
96
108
  try { fs.unlinkSync(tmp); } catch {}
109
+ try { fs.rmdirSync(tmpDir); } catch {}
110
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
97
111
  }
98
112
 
99
113
  function downloadFile(downloadUrl, dest) {
@@ -121,22 +135,14 @@ function downloadFile(downloadUrl, dest) {
121
135
  });
122
136
  }
123
137
 
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
138
  async function main() {
132
139
  try {
133
140
  await downloadFile(url, tmp);
134
141
  } catch (e) {
135
- if (!downloadWithCurlOrWget()) {
136
- console.log(' cloudflared install failed (download error) skipping');
137
- cleanup();
138
- return;
139
- }
142
+ console.log(' cloudflared install failed (download error) — skipping: ' + e.message);
143
+ console.log(' (Node https download failed; ensure network access to github.com)');
144
+ cleanup();
145
+ return;
140
146
  }
141
147
 
142
148
  // Validate downloaded file is not HTML (e.g. 404 page)
@@ -183,8 +189,20 @@ async function main() {
183
189
 
184
190
  function extractDarwinIfNeeded() {
185
191
  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');
192
+ // Validate tar entries to prevent tar slip (filter .. and absolute paths)
193
+ const list = spawnSync('tar', ['tzf', tmp], { encoding: 'utf8', timeout: 10000 });
194
+ if (list.stdout) {
195
+ const entries = list.stdout.split('\n').map(s => s.trim()).filter(Boolean);
196
+ for (const entry of entries) {
197
+ if (entry.includes('..') || path.isAbsolute(entry) || entry.startsWith('/')) {
198
+ throw new Error('tar slip detected: invalid entry ' + entry);
199
+ }
200
+ }
201
+ } else if (list.status !== 0) {
202
+ throw new Error('tar list failed: ' + (list.stderr ? list.stderr.toString().trim() : 'unknown error'));
203
+ }
204
+ const result = spawnSync('tar', ['xzf', tmp, '-C', tmpDir], { stdio: 'pipe', timeout: 30000 });
205
+ if (result.status !== 0) throw new Error('tar extraction failed: ' + (result.stderr ? result.stderr.toString().trim() : 'unknown'));
188
206
  try { fs.unlinkSync(tmp); } catch {}
189
207
  return path.join(tmpDir, 'cloudflared');
190
208
  }
@@ -2,5 +2,5 @@ window.DEFAULT_CMDS = [
2
2
  { name: 'Install OpenCode AI', cmd: 'npm install -g opencode-ai', cat: 'AI Tools' },
3
3
  { name: 'Install 9Router', cmd: 'npm install -g 9router', cat: 'AI Tools' },
4
4
  { name: 'GitHub TUI', cmd: 'npx github-tui', cat: 'Tools' },
5
- { name: 'Colab Setup', cmd: 'curl -fsSL https://raw.githubusercontent.com/unn-Known1/unn-Known1/main/colab_setup.sh | bash', cat: 'Setup' },
5
+ { name: 'Colab Setup', cmd: 'curl -fsSL https://raw.githubusercontent.com/unn-Known1/unn-Known1/main/colab_setup.sh | bash', cat: 'Setup', requiresConfirm: true },
6
6
  ];