cli-tunnel 1.2.0-beta.7 → 1.2.0-beta.9

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/dist/index.js CHANGED
@@ -113,6 +113,44 @@ function getGitInfo() {
113
113
  }
114
114
  // ─── Security: Session token for WebSocket auth ────────────
115
115
  const sessionToken = crypto.randomUUID();
116
+ // ─── Session file registry (IPC via filesystem) ────────────
117
+ const sessionsDir = path.join(os.homedir(), '.cli-tunnel', 'sessions');
118
+ fs.mkdirSync(sessionsDir, { recursive: true, mode: 0o700 });
119
+ let sessionFilePath = null;
120
+ function writeSessionFile(tunnelId, tunnelUrl, port) {
121
+ sessionFilePath = path.join(sessionsDir, `${tunnelId}.json`);
122
+ const data = JSON.stringify({
123
+ token: sessionToken, name: sessionName || command,
124
+ tunnelId, tunnelUrl, port, hubMode,
125
+ machine: os.hostname(), pid: process.pid,
126
+ createdAt: new Date().toISOString(),
127
+ });
128
+ fs.writeFileSync(sessionFilePath, data, { mode: 0o600 });
129
+ }
130
+ function removeSessionFile() {
131
+ if (sessionFilePath) {
132
+ try {
133
+ fs.unlinkSync(sessionFilePath);
134
+ }
135
+ catch { }
136
+ }
137
+ }
138
+ function readLocalSessions() {
139
+ try {
140
+ return fs.readdirSync(sessionsDir)
141
+ .filter(f => f.endsWith('.json'))
142
+ .map(f => { try {
143
+ return JSON.parse(fs.readFileSync(path.join(sessionsDir, f), 'utf-8'));
144
+ }
145
+ catch {
146
+ return null;
147
+ } })
148
+ .filter((s) => s !== null && !s.hubMode);
149
+ }
150
+ catch {
151
+ return [];
152
+ }
153
+ }
116
154
  // ─── F-18: Session TTL (24 hours) ──────────────────────────
117
155
  const SESSION_TTL = 24 * 60 * 60 * 1000; // 24 hours
118
156
  const sessionCreatedAt = Date.now();
@@ -189,26 +227,37 @@ const server = http.createServer((req, res) => {
189
227
  }
190
228
  }
191
229
  // Sessions API
192
- if (req.url === '/api/sessions' && req.method === 'GET') {
230
+ if ((req.url === '/api/sessions' || req.url?.startsWith('/api/sessions?')) && req.method === 'GET') {
193
231
  try {
194
232
  const output = execFileSync('devtunnel', ['list', '--labels', 'cli-tunnel', '--json'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
195
233
  const data = JSON.parse(output);
234
+ const localMachine = os.hostname();
235
+ const localSessions = hubMode ? readLocalSessions() : [];
236
+ const tokenMap = new Map(localSessions.map(s => [s.tunnelId, s.token]));
196
237
  const sessions = (data.tunnels || []).map((t) => {
197
238
  const labels = t.labels || [];
198
239
  const id = t.tunnelId?.replace(/\.\w+$/, '') || t.tunnelId;
199
240
  const cluster = t.tunnelId?.split('.').pop() || 'euw';
200
241
  const portLabel = labels.find((l) => l.startsWith('port-'));
201
242
  const p = portLabel ? parseInt(portLabel.replace('port-', ''), 10) : 3456;
202
- return {
243
+ const machine = labels[4] || 'unknown';
244
+ const session = {
203
245
  id, tunnelId: t.tunnelId,
204
246
  name: labels[1] || 'unnamed',
205
247
  repo: labels[2] || 'unknown',
206
248
  branch: (labels[3] || 'unknown').replace(/_/g, '/'),
207
- machine: labels[4] || 'unknown',
249
+ machine,
208
250
  online: (t.hostConnections || 0) > 0,
209
251
  port: p,
210
252
  url: `https://${id}-${p}.${cluster}.devtunnels.ms`,
253
+ isLocal: machine === localMachine,
211
254
  };
255
+ // Attach token from local session files (hub mode only)
256
+ const baseId = t.tunnelId?.split('.')[0] || t.tunnelId;
257
+ const token = tokenMap.get(baseId) || tokenMap.get(t.tunnelId);
258
+ if (token)
259
+ session.token = token;
260
+ return session;
212
261
  });
213
262
  res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
214
263
  res.end(JSON.stringify({ sessions }));
@@ -243,7 +292,9 @@ const server = http.createServer((req, res) => {
243
292
  // #18: Guard against malformed URI encoding
244
293
  let decodedUrl;
245
294
  try {
246
- decodedUrl = decodeURIComponent(req.url || '/');
295
+ // Strip query string before resolving file path
296
+ const urlPath = (req.url || '/').split('?')[0];
297
+ decodedUrl = decodeURIComponent(urlPath);
247
298
  }
248
299
  catch {
249
300
  res.writeHead(400);
@@ -531,17 +582,19 @@ async function main() {
531
582
  });
532
583
  const tunnelUrlWithToken = `${url}?token=${sessionToken}`;
533
584
  console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${tunnelUrlWithToken}${RESET}\n`);
585
+ // Write session file for hub discovery
586
+ writeSessionFile(tunnelId, url, actualPort);
534
587
  try {
535
588
  // @ts-ignore
536
589
  const qr = (await import('qrcode-terminal'));
537
590
  qr.default.generate(tunnelUrlWithToken, { small: true }, (code) => console.log(code));
538
591
  }
539
592
  catch { }
540
- process.on('SIGINT', () => { hostProc.kill(); try {
593
+ process.on('SIGINT', () => { removeSessionFile(); hostProc.kill(); try {
541
594
  execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
542
595
  }
543
596
  catch { } });
544
- process.on('exit', () => { hostProc.kill(); try {
597
+ process.on('exit', () => { removeSessionFile(); hostProc.kill(); try {
545
598
  execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
546
599
  }
547
600
  catch { } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-tunnel",
3
- "version": "1.2.0-beta.7",
3
+ "version": "1.2.0-beta.9",
4
4
  "description": "Tunnel any CLI app to your phone — PTY + devtunnel + xterm.js",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/remote-ui/app.js CHANGED
@@ -45,7 +45,9 @@
45
45
  const permOverlay = $('#permission-overlay');
46
46
  const dashboard = $('#dashboard');
47
47
  const termContainer = $('#terminal-container');
48
- let currentView = 'terminal'; // 'dashboard' or 'terminal'
48
+ let currentView = 'terminal'; // 'dashboard', 'terminal', or 'grid'
49
+ let cachedSessions = [];
50
+ let gridTerminals = []; // { xterm, fitAddon, ws, session }
49
51
 
50
52
  // ─── xterm.js Terminal ───────────────────────────────────
51
53
  let xterm = null;
@@ -127,41 +129,52 @@
127
129
  const filtered = showOffline ? sessions : sessions.filter(s => s.online);
128
130
  const offlineCount = sessions.filter(s => !s.online).length;
129
131
  const onlineCount = sessions.filter(s => s.online).length;
132
+ const connectable = filtered.filter(s => s.online && s.token);
130
133
 
131
134
  let html = `<div style="padding:8px 4px;display:flex;align-items:center;gap:8px">
132
135
  <span style="color:var(--text-dim);font-size:12px">${onlineCount} online${offlineCount > 0 ? ', ' + offlineCount + ' offline' : ''}</span>
133
136
  <span style="flex:1"></span>
134
- <button onclick="toggleOffline()" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">${showOffline ? 'Hide offline' : 'Show offline'}</button>
135
- ${offlineCount > 0 ? '<button onclick="cleanOffline()" style="background:none;border:1px solid var(--red);color:var(--red);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">Clean offline</button>' : ''}
136
- <button onclick="loadSessions()" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">↻</button>
137
+ ${connectable.length > 1 ? '<button data-action="grid-view" style="background:none;border:1px solid var(--blue);color:var(--blue);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">⊞ Grid</button>' : ''}
138
+ <button data-action="toggle-offline" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">${showOffline ? 'Hide offline' : 'Show offline'}</button>
139
+ ${offlineCount > 0 ? '<button data-action="clean-offline" style="background:none;border:1px solid var(--red);color:var(--red);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">Clean offline</button>' : ''}
140
+ <button data-action="refresh" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">↻</button>
137
141
  </div>`;
138
142
 
139
143
  if (filtered.length === 0) {
140
144
  html += '<div style="padding:20px 12px;color:var(--text-dim);text-align:center">' +
141
- (sessions.length === 0 ? 'No Squad RC sessions found.' : 'No online sessions. Tap "Show offline" to see stale ones.') +
145
+ (sessions.length === 0 ? 'No cli-tunnel sessions found.' : 'No online sessions. Tap "Show offline" to see stale ones.') +
142
146
  '</div>';
143
147
  } else {
144
- html += filtered.map(s => `
145
- <div class="session-card" ${s.online ? 'data-session-url="' + escapeHtml(s.url) + '"' : ''}>
148
+ html += filtered.map(s => {
149
+ const sessionUrl = s.token ? s.url + '?token=' + encodeURIComponent(s.token) : '';
150
+ return `
151
+ <div class="session-card" ${s.online && sessionUrl ? 'data-session-url="' + escapeHtml(sessionUrl) + '"' : ''}>
146
152
  <span class="status-dot ${s.online ? 'online' : 'offline'}"></span>
147
153
  <div class="info">
154
+ <div class="session-name">${escapeHtml(s.name)}</div>
148
155
  <div class="repo">📦 ${escapeHtml(s.repo)}</div>
149
156
  <div class="branch">🌿 ${escapeHtml(s.branch)}</div>
150
- <div class="machine">💻 ${escapeHtml(s.machine)}</div>
157
+ <div class="machine">💻 ${escapeHtml(s.machine)}${!s.token && s.online ? ' 🔒' : ''}</div>
151
158
  </div>
152
- ${s.online ? '<span class="arrow">→</span>' :
153
- '<button data-delete-id="' + escapeHtml(s.id) + '" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px" title="Remove">✕</button>'}
154
- </div>
155
- `).join('');
159
+ ${s.online && sessionUrl ? '<span class="arrow">→</span>' :
160
+ !s.online ? '<button data-delete-id="' + escapeHtml(s.id) + '" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px" title="Remove">✕</button>'
161
+ : '<span style="color:var(--text-dim);font-size:11px">remote</span>'}
162
+ </div>`;
163
+ }).join('');
156
164
  }
157
165
  dashboard.innerHTML = html;
158
- // #16: XSS fix — use event delegation instead of inline onclick
166
+ cachedSessions = sessions;
167
+ // Event delegation
159
168
  dashboard.querySelectorAll('.session-card[data-session-url]').forEach(function(card) {
160
169
  card.addEventListener('click', function() { openSession(card.dataset.sessionUrl); });
161
170
  });
162
171
  dashboard.querySelectorAll('[data-delete-id]').forEach(function(btn) {
163
172
  btn.addEventListener('click', function(e) { e.stopPropagation(); deleteSession(btn.dataset.deleteId); });
164
173
  });
174
+ dashboard.querySelector('[data-action="toggle-offline"]')?.addEventListener('click', function() { toggleOffline(); });
175
+ dashboard.querySelector('[data-action="clean-offline"]')?.addEventListener('click', function() { cleanOffline(); });
176
+ dashboard.querySelector('[data-action="refresh"]')?.addEventListener('click', function() { loadSessions(); });
177
+ dashboard.querySelector('[data-action="grid-view"]')?.addEventListener('click', function() { showGridView(sessions); });
165
178
  }
166
179
 
167
180
  window.openSession = (url) => {
@@ -188,7 +201,145 @@
188
201
  loadSessions();
189
202
  };
190
203
 
204
+ // ─── Grid View (tmux-style multi-terminal) ────────────────
205
+ function showGridView(sessions) {
206
+ const connectable = sessions.filter(function(s) { return s.online && s.token; });
207
+ if (connectable.length === 0) return;
208
+
209
+ // Clean up previous grid
210
+ destroyGrid();
211
+
212
+ currentView = 'grid';
213
+ dashboard.classList.add('hidden');
214
+ terminal.classList.add('hidden');
215
+ termContainer.classList.add('hidden');
216
+ $('#input-area').classList.add('hidden');
217
+
218
+ var gridEl = document.getElementById('grid-view');
219
+ if (!gridEl) {
220
+ gridEl = document.createElement('div');
221
+ gridEl.id = 'grid-view';
222
+ document.getElementById('app').insertBefore(gridEl, document.getElementById('input-area'));
223
+ }
224
+ gridEl.classList.remove('hidden');
225
+ gridEl.innerHTML = '';
226
+
227
+ // Calculate grid dimensions
228
+ var cols = connectable.length <= 2 ? connectable.length : connectable.length <= 4 ? 2 : 3;
229
+ gridEl.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
230
+
231
+ connectable.forEach(function(s) {
232
+ var panel = document.createElement('div');
233
+ panel.className = 'grid-panel';
234
+
235
+ // Header
236
+ var header = document.createElement('div');
237
+ header.className = 'grid-panel-header';
238
+ header.innerHTML = '<span class="grid-panel-name">' + escapeHtml(s.name) + '</span>' +
239
+ '<span class="grid-panel-machine">' + escapeHtml(s.machine) + '</span>' +
240
+ '<span class="grid-panel-status">●</span>';
241
+ panel.appendChild(header);
242
+
243
+ // Terminal container
244
+ var termDiv = document.createElement('div');
245
+ termDiv.className = 'grid-panel-terminal';
246
+ panel.appendChild(termDiv);
247
+
248
+ gridEl.appendChild(panel);
249
+
250
+ // Create xterm instance for this panel
251
+ var panelXterm = new Terminal({
252
+ theme: {
253
+ background: '#0d1117', foreground: '#c9d1d9', cursor: '#3fb950',
254
+ selectionBackground: '#264f78',
255
+ },
256
+ fontFamily: "'Cascadia Code', 'SF Mono', 'Fira Code', 'Menlo', monospace",
257
+ fontSize: 11,
258
+ scrollback: 1000,
259
+ cursorBlink: true,
260
+ });
261
+ var panelFit = new FitAddon.FitAddon();
262
+ panelXterm.loadAddon(panelFit);
263
+ panelXterm.open(termDiv);
264
+
265
+ // Delay fit to ensure container has size
266
+ setTimeout(function() { panelFit.fit(); }, 100);
267
+
268
+ // Connect WebSocket to this session
269
+ var proto = 'wss:';
270
+ var wsUrl = s.url.replace('https://', 'wss://') + '?token=' + encodeURIComponent(s.token);
271
+ var panelWs = new WebSocket(wsUrl);
272
+ var statusDot = header.querySelector('.grid-panel-status');
273
+
274
+ panelWs.onopen = function() {
275
+ if (statusDot) { statusDot.style.color = 'var(--green)'; statusDot.title = 'Connected'; }
276
+ // Send initial size
277
+ panelWs.send(JSON.stringify({ type: 'pty_resize', cols: panelXterm.cols, rows: panelXterm.rows }));
278
+ };
279
+ panelWs.onclose = function() {
280
+ if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Disconnected'; }
281
+ };
282
+ panelWs.onerror = function() {
283
+ if (statusDot) { statusDot.style.color = 'var(--red)'; }
284
+ };
285
+ panelWs.onmessage = function(e) {
286
+ try {
287
+ var msg = JSON.parse(e.data);
288
+ if (msg.type === 'pty') {
289
+ panelXterm.write(msg.data);
290
+ }
291
+ } catch (err) {}
292
+ };
293
+
294
+ // Keyboard input from this panel → send to its session
295
+ panelXterm.onData(function(data) {
296
+ if (panelWs.readyState === WebSocket.OPEN) {
297
+ panelWs.send(JSON.stringify({ type: 'pty_input', data: data }));
298
+ }
299
+ });
300
+
301
+ // Click header to go full-screen on this session
302
+ header.addEventListener('click', function() {
303
+ window.location.href = s.url + '?token=' + encodeURIComponent(s.token);
304
+ });
305
+
306
+ gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: panelWs, session: s });
307
+ });
308
+
309
+ // Handle window resize for grid panels
310
+ window.addEventListener('resize', fitGridPanels);
311
+
312
+ // Add back button
313
+ if ($('#btn-sessions')) { $('#btn-sessions').textContent = 'List'; }
314
+ }
315
+
316
+ function fitGridPanels() {
317
+ gridTerminals.forEach(function(gt) {
318
+ if (gt.fitAddon) { try { gt.fitAddon.fit(); } catch(e) {} }
319
+ });
320
+ }
321
+
322
+ function destroyGrid() {
323
+ gridTerminals.forEach(function(gt) {
324
+ if (gt.ws) { try { gt.ws.close(); } catch(e) {} }
325
+ if (gt.xterm) { try { gt.xterm.dispose(); } catch(e) {} }
326
+ });
327
+ gridTerminals = [];
328
+ window.removeEventListener('resize', fitGridPanels);
329
+ var gridEl = document.getElementById('grid-view');
330
+ if (gridEl) { gridEl.innerHTML = ''; gridEl.classList.add('hidden'); }
331
+ }
332
+
191
333
  window.toggleView = () => {
334
+ if (currentView === 'grid') {
335
+ // Grid → dashboard (list view)
336
+ destroyGrid();
337
+ currentView = 'dashboard';
338
+ dashboard.classList.remove('hidden');
339
+ $('#btn-sessions').textContent = 'Terminal';
340
+ loadSessions();
341
+ return;
342
+ }
192
343
  if (currentView === 'terminal') {
193
344
  currentView = 'dashboard';
194
345
  terminal.classList.add('hidden');
@@ -198,6 +349,7 @@
198
349
  $('#btn-sessions').textContent = 'Terminal';
199
350
  loadSessions();
200
351
  } else {
352
+ destroyGrid();
201
353
  currentView = 'terminal';
202
354
  dashboard.classList.add('hidden');
203
355
  $('#input-area').classList.remove('hidden');
@@ -242,10 +242,51 @@ header {
242
242
  .session-card .status-dot.offline { background: var(--text-dim); }
243
243
  .session-card .info { flex: 1; min-width: 0; }
244
244
  .session-card .repo { color: var(--blue); font-weight: bold; font-size: 13px; }
245
+ .session-card .session-name { color: var(--text-bright); font-weight: bold; font-size: 14px; }
245
246
  .session-card .branch { color: var(--text-dim); font-size: 11px; }
246
247
  .session-card .machine { color: var(--text-dim); font-size: 11px; }
247
248
  .session-card .arrow { color: var(--text-dim); }
248
249
 
250
+ /* Grid View (tmux-style multi-terminal) */
251
+ #grid-view {
252
+ flex: 1;
253
+ display: grid;
254
+ gap: 2px;
255
+ padding: 2px;
256
+ overflow: hidden;
257
+ background: var(--border);
258
+ }
259
+ .grid-panel {
260
+ display: flex;
261
+ flex-direction: column;
262
+ background: var(--bg);
263
+ overflow: hidden;
264
+ min-height: 0;
265
+ }
266
+ .grid-panel-header {
267
+ display: flex;
268
+ align-items: center;
269
+ gap: 6px;
270
+ padding: 3px 8px;
271
+ background: var(--bg-tool);
272
+ border-bottom: 1px solid var(--border);
273
+ flex-shrink: 0;
274
+ cursor: pointer;
275
+ font-size: 11px;
276
+ }
277
+ .grid-panel-header:hover { background: var(--border); }
278
+ .grid-panel-name { color: var(--blue); font-weight: bold; }
279
+ .grid-panel-machine { color: var(--text-dim); flex: 1; }
280
+ .grid-panel-status { font-size: 8px; color: var(--yellow); }
281
+ .grid-panel-terminal {
282
+ flex: 1;
283
+ overflow: hidden;
284
+ }
285
+ .grid-panel-terminal .xterm {
286
+ height: 100%;
287
+ padding: 2px;
288
+ }
289
+
249
290
  /* Scrollbar */
250
291
  ::-webkit-scrollbar { width: 6px; }
251
292
  ::-webkit-scrollbar-track { background: transparent; }