webtun 1.5.2 → 1.5.4

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,10 +65,11 @@ 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
- - **tmux sessions** — persistent sessions survive page reload
71
+ - **Session persistence** — tmux or in-memory PTY; tabs survive page reload (all platforms)
72
+ - **Fullscreen** — terminal fullscreen `Shift+F11` (header button), fits and refocuses
72
73
  - **Keyboard Shortcuts** — reference dialog in the overflow menu (Ctrl+P/T/W/B/F, etc.)
73
74
 
74
75
  ### File Explorer
@@ -77,7 +78,8 @@ git clone https://github.com/unn-Known1/webtun.git && cd webtun && ./setup.sh &&
77
78
  - **Zip/Extract** — right-click any file or folder
78
79
  - **Image viewer** — preview inline (png, jpg, gif, svg, webp)
79
80
  - **Conflict resolution** — replace, merge, keep both, skip
80
- - **Breadcrumb navigation** — tappable directory segments
81
+ - **Breadcrumb navigation** — tappable segments, folder icon + path one line, `2px` tight spacing, header/breadcrumb unified `30px` bar
82
+ - **Editor** — CodeMirror with autosave draft, `F11` fullscreen + `horizontal/vertical` split toggle, markdown/HTML preview
81
83
 
82
84
  ### Mobile
83
85
  - **Touch gestures** — swipe to close tabs, pull-to-refresh
@@ -96,7 +98,7 @@ git clone https://github.com/unn-Known1/webtun.git && cd webtun && ./setup.sh &&
96
98
  - CPU, Memory, Disk, Uptime — real-time with progress bars
97
99
  - GPU detection (nvidia-smi, system_profiler, WMI)
98
100
  - Multi-GPU support with VRAM, utilization, temperature
99
- - Top processes sorted by CPU
101
+ - Top processes sorted by CPU — **Kill** button per row (`POST /api/system/kill`)
100
102
 
101
103
  ### Desktop (Electron)
102
104
  - Cross-platform app for Linux, Windows, macOS
@@ -133,6 +135,7 @@ git clone https://github.com/unn-Known1/webtun.git && cd webtun && ./setup.sh &&
133
135
  | `PIN` | Auth PIN (empty = no auth) | none |
134
136
  | `SHELL` | Shell to use | platform default |
135
137
  | `WORKSPACE_ROOT` | File explorer root | `~` |
138
+ | `ALLOW_FULL_FS` | Allow browsing outside `WORKSPACE_ROOT` (full FS) | `false` |
136
139
  | `TRUST_PROXY` | Trust `X-Forwarded-For` from first proxy | `false` |
137
140
  | `WEBTUN_SHELL` | Override shell on Windows | PowerShell |
138
141
 
@@ -175,6 +178,21 @@ git clone https://github.com/unn-Known1/webtun.git && cd webtun && ./setup.sh &&
175
178
 
176
179
  ## Changelog
177
180
 
181
+ ### v1.5.4
182
+ - 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
183
+ - 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-`
184
+ - 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`
185
+ - 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`
186
+ - 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`
187
+ - Editor: `F11` fullscreen (`fixed inset0`) + `horizontal/vertical` split toggle stays usable, `Esc` to exit, `requestFullscreen` fallback, `closeEditor` clears
188
+ - Terminal: `Shift+F11` fullscreen (`#content.terminal-fullscreen` + `#terminals.fullscreen`), header button, `fitTerm` refit
189
+ - System Stats: **Kill** `POST /api/system/kill` per-row button (refuse `1/self/cloudflared`)
190
+ - PWA: `sw.js` no immediate `skipWaiting`, `token`/`/ws` bypass, stale-while-revalidate, `manifest` `?newTerm=1` + `titlebar-area` CSS
191
+
192
+ ### v1.5.3
193
+ - Terminal session persistence without tmux (in-memory PTY on Windows + Linux)
194
+ - Session IDs always generated; tabs now survive page reload on all platforms
195
+
178
196
  ### v1.5.2
179
197
  - UI/UX audit fixes: theme-aware xterm selection color, `--fg3` across all 6 themes
180
198
  - Replaced native `confirm()` with a theme-aware, focus-trapped dialog
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.2",
3
+ "version": "1.5.4",
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
  ];