mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Clean up old unused files and components
- Remove old app-new entry points and components - Remove duplicate/experimental files - Keep only active session-list and file-browser components 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
920b040b61
commit
0bee6f13aa
5 changed files with 0 additions and 901 deletions
|
|
@ -1,2 +0,0 @@
|
||||||
// Entry point for the new app
|
|
||||||
import './app-new.js';
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
import { LitElement, html } from 'lit';
|
|
||||||
import { customElement, state } from 'lit/decorators.js';
|
|
||||||
|
|
||||||
// Import components
|
|
||||||
import './components/app-header.js';
|
|
||||||
import './components/session-create-form.js';
|
|
||||||
import './components/session-list.js';
|
|
||||||
|
|
||||||
import type { Session } from './components/session-list.js';
|
|
||||||
|
|
||||||
@customElement('vibetunnel-app-new')
|
|
||||||
export class VibeTunnelAppNew extends LitElement {
|
|
||||||
// Disable shadow DOM to use Tailwind
|
|
||||||
createRenderRoot() {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
@state() private errorMessage = '';
|
|
||||||
@state() private sessions: Session[] = [];
|
|
||||||
@state() private loading = false;
|
|
||||||
|
|
||||||
private hotReloadWs: WebSocket | null = null;
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
super.connectedCallback();
|
|
||||||
this.setupHotReload();
|
|
||||||
this.loadSessions();
|
|
||||||
this.startAutoRefresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback() {
|
|
||||||
super.disconnectedCallback();
|
|
||||||
if (this.hotReloadWs) {
|
|
||||||
this.hotReloadWs.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private showError(message: string) {
|
|
||||||
this.errorMessage = message;
|
|
||||||
// Clear error after 5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
this.errorMessage = '';
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
private clearError() {
|
|
||||||
this.errorMessage = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadSessions() {
|
|
||||||
this.loading = true;
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/sessions');
|
|
||||||
if (response.ok) {
|
|
||||||
const sessionsData = await response.json();
|
|
||||||
this.sessions = sessionsData.map((session: any) => ({
|
|
||||||
id: session.id,
|
|
||||||
command: session.command,
|
|
||||||
workingDir: session.workingDir,
|
|
||||||
status: session.status,
|
|
||||||
exitCode: session.exitCode,
|
|
||||||
startedAt: session.startedAt,
|
|
||||||
lastModified: session.lastModified,
|
|
||||||
pid: session.pid
|
|
||||||
}));
|
|
||||||
this.clearError();
|
|
||||||
} else {
|
|
||||||
this.showError('Failed to load sessions');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading sessions:', error);
|
|
||||||
this.showError('Failed to load sessions');
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private startAutoRefresh() {
|
|
||||||
// Refresh sessions every 3 seconds
|
|
||||||
setInterval(() => {
|
|
||||||
this.loadSessions();
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleSessionCreated(e: CustomEvent) {
|
|
||||||
console.log('Session created:', e.detail);
|
|
||||||
this.showError('Session created successfully!');
|
|
||||||
this.loadSessions(); // Refresh the list
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleSessionSelect(e: CustomEvent) {
|
|
||||||
const session = e.detail as Session;
|
|
||||||
console.log('Session selected:', session);
|
|
||||||
this.showError(`Terminal view not implemented yet for session: ${session.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleSessionKilled(e: CustomEvent) {
|
|
||||||
console.log('Session killed:', e.detail);
|
|
||||||
this.loadSessions(); // Refresh the list
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleRefresh() {
|
|
||||||
this.loadSessions();
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleError(e: CustomEvent) {
|
|
||||||
this.showError(e.detail);
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupHotReload(): void {
|
|
||||||
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}?hotReload=true`;
|
|
||||||
|
|
||||||
this.hotReloadWs = new WebSocket(wsUrl);
|
|
||||||
this.hotReloadWs.onmessage = (event) => {
|
|
||||||
const message = JSON.parse(event.data);
|
|
||||||
if (message.type === 'reload') {
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return html`
|
|
||||||
<div class="max-w-4xl mx-auto">
|
|
||||||
<app-header></app-header>
|
|
||||||
<session-create-form
|
|
||||||
@session-created=${this.handleSessionCreated}
|
|
||||||
@error=${this.handleError}
|
|
||||||
></session-create-form>
|
|
||||||
<session-list
|
|
||||||
.sessions=${this.sessions}
|
|
||||||
.loading=${this.loading}
|
|
||||||
@session-select=${this.handleSessionSelect}
|
|
||||||
@session-killed=${this.handleSessionKilled}
|
|
||||||
@refresh=${this.handleRefresh}
|
|
||||||
@error=${this.handleError}
|
|
||||||
></session-list>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { LitElement, html } from 'lit';
|
|
||||||
import { customElement } from 'lit/decorators.js';
|
|
||||||
|
|
||||||
@customElement('app-header')
|
|
||||||
export class AppHeader extends LitElement {
|
|
||||||
createRenderRoot() {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return html`
|
|
||||||
<div class="p-4">
|
|
||||||
<h1 class="text-vs-user font-mono text-sm m-0">VibeTunnel</h1>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
import { LitElement, html } from 'lit';
|
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
|
||||||
import './file-browser.js';
|
|
||||||
|
|
||||||
export interface SessionCreateData {
|
|
||||||
command: string[];
|
|
||||||
workingDir: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@customElement('session-create-form')
|
|
||||||
export class SessionCreateForm extends LitElement {
|
|
||||||
// Disable shadow DOM to use Tailwind
|
|
||||||
createRenderRoot() {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
@property({ type: String }) workingDir = '~/';
|
|
||||||
@property({ type: String }) command = '';
|
|
||||||
@property({ type: Boolean }) disabled = false;
|
|
||||||
|
|
||||||
@state() private isCreating = false;
|
|
||||||
@state() private showFileBrowser = false;
|
|
||||||
|
|
||||||
private handleWorkingDirChange(e: Event) {
|
|
||||||
const input = e.target as HTMLInputElement;
|
|
||||||
this.workingDir = input.value;
|
|
||||||
this.dispatchEvent(new CustomEvent('working-dir-change', {
|
|
||||||
detail: this.workingDir
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleCommandChange(e: Event) {
|
|
||||||
const input = e.target as HTMLInputElement;
|
|
||||||
this.command = input.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleBrowse() {
|
|
||||||
this.showFileBrowser = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleDirectorySelected(e: CustomEvent) {
|
|
||||||
this.workingDir = e.detail;
|
|
||||||
this.showFileBrowser = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleBrowserCancel() {
|
|
||||||
this.showFileBrowser = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleCreate() {
|
|
||||||
if (!this.workingDir.trim() || !this.command.trim()) {
|
|
||||||
this.dispatchEvent(new CustomEvent('error', {
|
|
||||||
detail: 'Please fill in both working directory and command'
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isCreating = true;
|
|
||||||
|
|
||||||
const sessionData: SessionCreateData = {
|
|
||||||
command: this.parseCommand(this.command.trim()),
|
|
||||||
workingDir: this.workingDir.trim()
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/sessions', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(sessionData)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const result = await response.json();
|
|
||||||
this.command = ''; // Clear command on success
|
|
||||||
this.dispatchEvent(new CustomEvent('session-created', {
|
|
||||||
detail: result
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
const error = await response.json();
|
|
||||||
this.dispatchEvent(new CustomEvent('error', {
|
|
||||||
detail: `Failed to create session: ${error.error}`
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating session:', error);
|
|
||||||
this.dispatchEvent(new CustomEvent('error', {
|
|
||||||
detail: 'Failed to create session'
|
|
||||||
}));
|
|
||||||
} finally {
|
|
||||||
this.isCreating = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseCommand(commandStr: string): string[] {
|
|
||||||
// Simple command parsing - split by spaces but respect quotes
|
|
||||||
const args: string[] = [];
|
|
||||||
let current = '';
|
|
||||||
let inQuotes = false;
|
|
||||||
let quoteChar = '';
|
|
||||||
|
|
||||||
for (let i = 0; i < commandStr.length; i++) {
|
|
||||||
const char = commandStr[i];
|
|
||||||
|
|
||||||
if ((char === '"' || char === "'") && !inQuotes) {
|
|
||||||
inQuotes = true;
|
|
||||||
quoteChar = char;
|
|
||||||
} else if (char === quoteChar && inQuotes) {
|
|
||||||
inQuotes = false;
|
|
||||||
quoteChar = '';
|
|
||||||
} else if (char === ' ' && !inQuotes) {
|
|
||||||
if (current) {
|
|
||||||
args.push(current);
|
|
||||||
current = '';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
current += char;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current) {
|
|
||||||
args.push(current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return args;
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return html`
|
|
||||||
<div class="border border-vs-accent font-mono text-sm p-4 m-4 rounded">
|
|
||||||
<div class="text-vs-assistant text-sm mb-4">Create New Session</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="text-vs-muted mb-2">Working Directory:</div>
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="flex-1 bg-vs-bg text-vs-text border border-vs-border outline-none font-mono px-4 py-2"
|
|
||||||
.value=${this.workingDir}
|
|
||||||
@input=${this.handleWorkingDirChange}
|
|
||||||
placeholder="~/"
|
|
||||||
?disabled=${this.disabled || this.isCreating}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-4 py-2 border-none"
|
|
||||||
@click=${this.handleBrowse}
|
|
||||||
?disabled=${this.disabled || this.isCreating}
|
|
||||||
>
|
|
||||||
browse
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="text-vs-muted mb-2">Command:</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="w-full bg-vs-bg text-vs-text border border-vs-border outline-none font-mono px-4 py-2"
|
|
||||||
.value=${this.command}
|
|
||||||
@input=${this.handleCommandChange}
|
|
||||||
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.handleCreate()}
|
|
||||||
placeholder="zsh"
|
|
||||||
?disabled=${this.disabled || this.isCreating}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-2 border-none"
|
|
||||||
@click=${this.handleCreate}
|
|
||||||
?disabled=${this.disabled || this.isCreating || !this.workingDir.trim() || !this.command.trim()}
|
|
||||||
>
|
|
||||||
${this.isCreating ? 'creating...' : 'create'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<file-browser
|
|
||||||
.visible=${this.showFileBrowser}
|
|
||||||
.currentPath=${this.workingDir}
|
|
||||||
@directory-selected=${this.handleDirectorySelected}
|
|
||||||
@browser-cancel=${this.handleBrowserCancel}
|
|
||||||
></file-browser>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,555 +0,0 @@
|
||||||
import express from 'express';
|
|
||||||
import { createServer } from 'http';
|
|
||||||
import { WebSocketServer } from 'ws';
|
|
||||||
import path from 'path';
|
|
||||||
import fs from 'fs';
|
|
||||||
import os from 'os';
|
|
||||||
import { spawn } from 'child_process';
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const server = createServer(app);
|
|
||||||
const wss = new WebSocketServer({ server });
|
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
|
||||||
|
|
||||||
// tty-fwd binary path - check multiple possible locations
|
|
||||||
const possibleTtyFwdPaths = [
|
|
||||||
path.resolve(__dirname, '..', '..', 'tty-fwd', 'target', 'release', 'tty-fwd'),
|
|
||||||
path.resolve(__dirname, '..', '..', '..', 'tty-fwd', 'target', 'release', 'tty-fwd'),
|
|
||||||
'tty-fwd' // System PATH
|
|
||||||
];
|
|
||||||
|
|
||||||
let TTY_FWD_PATH = '';
|
|
||||||
for (const pathToCheck of possibleTtyFwdPaths) {
|
|
||||||
if (fs.existsSync(pathToCheck)) {
|
|
||||||
TTY_FWD_PATH = pathToCheck;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!TTY_FWD_PATH) {
|
|
||||||
console.error('tty-fwd binary not found. Please ensure it is built and available.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const TTY_FWD_CONTROL_DIR = process.env.TTY_FWD_CONTROL_DIR || path.join(os.homedir(), '.vibetunnel');
|
|
||||||
|
|
||||||
// Ensure control directory exists
|
|
||||||
if (!fs.existsSync(TTY_FWD_CONTROL_DIR)) {
|
|
||||||
fs.mkdirSync(TTY_FWD_CONTROL_DIR, { recursive: true });
|
|
||||||
console.log(`Created control directory: ${TTY_FWD_CONTROL_DIR}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Using tty-fwd at: ${TTY_FWD_PATH}`);
|
|
||||||
console.log(`Control directory: ${TTY_FWD_CONTROL_DIR}`);
|
|
||||||
|
|
||||||
// Types for tty-fwd responses
|
|
||||||
interface TtyFwdSession {
|
|
||||||
cmdline: string[];
|
|
||||||
cwd: string;
|
|
||||||
exit_code: number | null;
|
|
||||||
name: string;
|
|
||||||
pid: number;
|
|
||||||
started_at: string;
|
|
||||||
status: "running" | "exited";
|
|
||||||
stdin: string;
|
|
||||||
"stream-out": string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TtyFwdListResponse {
|
|
||||||
[sessionId: string]: TtyFwdSession;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to execute tty-fwd commands
|
|
||||||
async function executeTtyFwd(args: string[]): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const child = spawn(TTY_FWD_PATH, args);
|
|
||||||
let output = '';
|
|
||||||
|
|
||||||
child.stdout.on('data', (data) => {
|
|
||||||
output += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('close', (code) => {
|
|
||||||
if (code === 0) {
|
|
||||||
resolve(output);
|
|
||||||
} else {
|
|
||||||
reject(new Error(`tty-fwd failed with code ${code}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('error', (error) => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to resolve paths with ~ expansion
|
|
||||||
function resolvePath(inputPath: string, fallback?: string): string {
|
|
||||||
if (!inputPath) {
|
|
||||||
return fallback || process.cwd();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inputPath.startsWith('~')) {
|
|
||||||
return path.join(os.homedir(), inputPath.slice(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
return path.resolve(inputPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Middleware
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
|
||||||
|
|
||||||
// Hot reload functionality for development
|
|
||||||
const hotReloadClients = new Set<any>();
|
|
||||||
|
|
||||||
// === SESSION MANAGEMENT ===
|
|
||||||
|
|
||||||
// List all sessions
|
|
||||||
app.get('/api/sessions', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const output = await executeTtyFwd(['--control-path', TTY_FWD_CONTROL_DIR, '--list-sessions']);
|
|
||||||
const sessions: TtyFwdListResponse = JSON.parse(output || '{}');
|
|
||||||
|
|
||||||
const sessionData = Object.entries(sessions).map(([sessionId, sessionInfo]) => {
|
|
||||||
// Get actual last modified time from stream-out file
|
|
||||||
let lastModified = sessionInfo.started_at;
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(sessionInfo["stream-out"])) {
|
|
||||||
const stats = fs.statSync(sessionInfo["stream-out"]);
|
|
||||||
lastModified = stats.mtime.toISOString();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Use started_at as fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: sessionId,
|
|
||||||
command: sessionInfo.cmdline.join(' '),
|
|
||||||
workingDir: sessionInfo.cwd,
|
|
||||||
status: sessionInfo.status,
|
|
||||||
exitCode: sessionInfo.exit_code,
|
|
||||||
startedAt: sessionInfo.started_at,
|
|
||||||
lastModified: lastModified,
|
|
||||||
pid: sessionInfo.pid
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by lastModified, most recent first
|
|
||||||
sessionData.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
|
||||||
res.json(sessionData);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to list sessions:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to list sessions' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create new session
|
|
||||||
app.post('/api/sessions', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { command, workingDir } = req.body;
|
|
||||||
|
|
||||||
if (!command || !Array.isArray(command) || command.length === 0) {
|
|
||||||
return res.status(400).json({ error: 'Command array is required and cannot be empty' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionName = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
const cwd = resolvePath(workingDir, process.cwd());
|
|
||||||
|
|
||||||
const args = [
|
|
||||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
|
||||||
'--session-name', sessionName,
|
|
||||||
'--'
|
|
||||||
].concat(command);
|
|
||||||
|
|
||||||
console.log(`Creating session: ${TTY_FWD_PATH} ${args.join(' ')}`);
|
|
||||||
|
|
||||||
const child = spawn(TTY_FWD_PATH, args, {
|
|
||||||
cwd: cwd,
|
|
||||||
detached: false,
|
|
||||||
stdio: 'pipe'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log output for debugging
|
|
||||||
child.stdout.on('data', (data) => {
|
|
||||||
console.log(`Session ${sessionName} stdout:`, data.toString());
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stderr.on('data', (data) => {
|
|
||||||
console.log(`Session ${sessionName} stderr:`, data.toString());
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('close', (code) => {
|
|
||||||
console.log(`Session ${sessionName} exited with code: ${code}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Respond immediately - don't wait for completion
|
|
||||||
res.json({ sessionId: sessionName });
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating session:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to create session' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Kill session (just kill the process)
|
|
||||||
app.delete('/api/sessions/:sessionId', async (req, res) => {
|
|
||||||
const sessionId = req.params.sessionId;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const output = await executeTtyFwd(['--control-path', TTY_FWD_CONTROL_DIR, '--list-sessions']);
|
|
||||||
const sessions: TtyFwdListResponse = JSON.parse(output || '{}');
|
|
||||||
const session = sessions[sessionId];
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return res.status(404).json({ error: 'Session not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.pid) {
|
|
||||||
try {
|
|
||||||
process.kill(session.pid, 'SIGTERM');
|
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
process.kill(session.pid, 0); // Check if still alive
|
|
||||||
process.kill(session.pid, 'SIGKILL'); // Force kill
|
|
||||||
} catch (e) {
|
|
||||||
// Process already dead
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
} catch (error) {
|
|
||||||
// Process already dead
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ success: true, message: 'Session killed' });
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error killing session:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to kill session' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup session files
|
|
||||||
app.delete('/api/sessions/:sessionId/cleanup', async (req, res) => {
|
|
||||||
const sessionId = req.params.sessionId;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await executeTtyFwd([
|
|
||||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
|
||||||
'--session', sessionId,
|
|
||||||
'--cleanup'
|
|
||||||
]);
|
|
||||||
|
|
||||||
res.json({ success: true, message: 'Session cleaned up' });
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
// If tty-fwd cleanup fails, force remove directory
|
|
||||||
console.log('tty-fwd cleanup failed, force removing directory');
|
|
||||||
const sessionDir = path.join(TTY_FWD_CONTROL_DIR, sessionId);
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(sessionDir)) {
|
|
||||||
fs.rmSync(sessionDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
res.json({ success: true, message: 'Session force cleaned up' });
|
|
||||||
} catch (fsError) {
|
|
||||||
console.error('Error force removing session directory:', fsError);
|
|
||||||
res.status(500).json({ error: 'Failed to cleanup session' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// === TERMINAL I/O ===
|
|
||||||
|
|
||||||
// Server-sent events for terminal output streaming
|
|
||||||
app.get('/api/sessions/:sessionId/stream', (req, res) => {
|
|
||||||
const sessionId = req.params.sessionId;
|
|
||||||
const streamOutPath = path.join(TTY_FWD_CONTROL_DIR, sessionId, 'stream-out');
|
|
||||||
|
|
||||||
if (!fs.existsSync(streamOutPath)) {
|
|
||||||
return res.status(404).json({ error: 'Session not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.writeHead(200, {
|
|
||||||
'Content-Type': 'text/event-stream',
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
'Connection': 'keep-alive',
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
'Access-Control-Allow-Headers': 'Cache-Control'
|
|
||||||
});
|
|
||||||
|
|
||||||
const startTime = Date.now() / 1000;
|
|
||||||
let headerSent = false;
|
|
||||||
|
|
||||||
// Send existing content first
|
|
||||||
// NOTE: Small race condition possible between reading file and starting tail
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(streamOutPath, 'utf8');
|
|
||||||
const lines = content.trim().split('\n');
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.trim()) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(line);
|
|
||||||
if (parsed.version && parsed.width && parsed.height) {
|
|
||||||
res.write(`data: ${line}\n\n`);
|
|
||||||
headerSent = true;
|
|
||||||
} else if (Array.isArray(parsed) && parsed.length >= 3) {
|
|
||||||
const instantEvent = [0, parsed[1], parsed[2]];
|
|
||||||
res.write(`data: ${JSON.stringify(instantEvent)}\n\n`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Skip invalid lines
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error reading existing content:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send default header if none found
|
|
||||||
if (!headerSent) {
|
|
||||||
const defaultHeader = {
|
|
||||||
version: 2,
|
|
||||||
width: 80,
|
|
||||||
height: 24,
|
|
||||||
timestamp: Math.floor(startTime),
|
|
||||||
env: { TERM: "xterm-256color" }
|
|
||||||
};
|
|
||||||
res.write(`data: ${JSON.stringify(defaultHeader)}\n\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stream new content
|
|
||||||
const tailProcess = spawn('tail', ['-f', streamOutPath]);
|
|
||||||
let buffer = '';
|
|
||||||
|
|
||||||
tailProcess.stdout.on('data', (chunk) => {
|
|
||||||
buffer += chunk.toString();
|
|
||||||
const lines = buffer.split('\n');
|
|
||||||
buffer = lines.pop() || '';
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.trim()) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(line);
|
|
||||||
if (parsed.version && parsed.width && parsed.height) {
|
|
||||||
return; // Skip duplicate headers
|
|
||||||
}
|
|
||||||
if (Array.isArray(parsed) && parsed.length >= 3) {
|
|
||||||
const currentTime = Date.now() / 1000;
|
|
||||||
const realTimeEvent = [currentTime - startTime, parsed[1], parsed[2]];
|
|
||||||
res.write(`data: ${JSON.stringify(realTimeEvent)}\n\n`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Handle non-JSON as raw output
|
|
||||||
const currentTime = Date.now() / 1000;
|
|
||||||
const castEvent = [currentTime - startTime, "o", line];
|
|
||||||
res.write(`data: ${JSON.stringify(castEvent)}\n\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup on disconnect
|
|
||||||
req.on('close', () => tailProcess.kill('SIGTERM'));
|
|
||||||
req.on('aborted', () => tailProcess.kill('SIGTERM'));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get session snapshot (asciinema cast with adjusted timestamps for immediate playback)
|
|
||||||
app.get('/api/sessions/:sessionId/snapshot', (req, res) => {
|
|
||||||
const sessionId = req.params.sessionId;
|
|
||||||
const streamOutPath = path.join(TTY_FWD_CONTROL_DIR, sessionId, 'stream-out');
|
|
||||||
|
|
||||||
if (!fs.existsSync(streamOutPath)) {
|
|
||||||
return res.status(404).json({ error: 'Session not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(streamOutPath, 'utf8');
|
|
||||||
const lines = content.trim().split('\n');
|
|
||||||
|
|
||||||
let header = null;
|
|
||||||
const events = [];
|
|
||||||
let startTime = null;
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.trim()) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(line);
|
|
||||||
|
|
||||||
// Header line
|
|
||||||
if (parsed.version && parsed.width && parsed.height) {
|
|
||||||
header = parsed;
|
|
||||||
}
|
|
||||||
// Event line [timestamp, type, data]
|
|
||||||
else if (Array.isArray(parsed) && parsed.length >= 3) {
|
|
||||||
if (startTime === null) {
|
|
||||||
startTime = parsed[0];
|
|
||||||
}
|
|
||||||
// Adjust timestamp to start from 0 and compress time
|
|
||||||
const adjustedTime = (parsed[0] - startTime) * 0.1; // 10x speed
|
|
||||||
events.push([adjustedTime, parsed[1], parsed[2]]);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Skip invalid lines
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the complete asciinema cast
|
|
||||||
const cast = [];
|
|
||||||
|
|
||||||
// Add header if found, otherwise use default
|
|
||||||
if (header) {
|
|
||||||
cast.push(JSON.stringify(header));
|
|
||||||
} else {
|
|
||||||
cast.push(JSON.stringify({
|
|
||||||
version: 2,
|
|
||||||
width: 80,
|
|
||||||
height: 24,
|
|
||||||
timestamp: Math.floor(Date.now() / 1000),
|
|
||||||
env: { TERM: "xterm-256color" }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add all events
|
|
||||||
events.forEach(event => {
|
|
||||||
cast.push(JSON.stringify(event));
|
|
||||||
});
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/plain');
|
|
||||||
res.send(cast.join('\n'));
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error reading session snapshot:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to read session snapshot' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send input to session
|
|
||||||
app.post('/api/sessions/:sessionId/input', async (req, res) => {
|
|
||||||
const sessionId = req.params.sessionId;
|
|
||||||
const { text } = req.body;
|
|
||||||
|
|
||||||
if (text === undefined || text === null) {
|
|
||||||
return res.status(400).json({ error: 'Text is required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Sending input to session ${sessionId}:`, JSON.stringify(text));
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if this is a special key that should use --send-key
|
|
||||||
const specialKeys = ['arrow_up', 'arrow_down', 'arrow_left', 'arrow_right', 'escape', 'enter'];
|
|
||||||
const isSpecialKey = specialKeys.includes(text);
|
|
||||||
|
|
||||||
if (isSpecialKey) {
|
|
||||||
await executeTtyFwd([
|
|
||||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
|
||||||
'--session', sessionId,
|
|
||||||
'--send-key', text
|
|
||||||
]);
|
|
||||||
console.log(`Successfully sent key: ${text}`);
|
|
||||||
} else {
|
|
||||||
await executeTtyFwd([
|
|
||||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
|
||||||
'--session', sessionId,
|
|
||||||
'--send-text', text
|
|
||||||
]);
|
|
||||||
console.log(`Successfully sent text: ${text}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ success: true });
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error sending input via tty-fwd:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to send input' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// === FILE SYSTEM ===
|
|
||||||
|
|
||||||
// Directory listing for file browser
|
|
||||||
app.get('/api/fs/browse', (req, res) => {
|
|
||||||
const dirPath = req.query.path as string || '~';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const expandedPath = resolvePath(dirPath, '~');
|
|
||||||
|
|
||||||
if (!fs.existsSync(expandedPath)) {
|
|
||||||
return res.status(404).json({ error: 'Directory not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = fs.statSync(expandedPath);
|
|
||||||
if (!stats.isDirectory()) {
|
|
||||||
return res.status(400).json({ error: 'Path is not a directory' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = fs.readdirSync(expandedPath).map(name => {
|
|
||||||
const filePath = path.join(expandedPath, name);
|
|
||||||
const fileStats = fs.statSync(filePath);
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
created: fileStats.birthtime.toISOString(),
|
|
||||||
lastModified: fileStats.mtime.toISOString(),
|
|
||||||
size: fileStats.size,
|
|
||||||
isDir: fileStats.isDirectory()
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
absolutePath: expandedPath,
|
|
||||||
files: files.sort((a, b) => {
|
|
||||||
// Directories first, then files
|
|
||||||
if (a.isDir && !b.isDir) return -1;
|
|
||||||
if (!a.isDir && b.isDir) return 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error listing directory:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to list directory' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// === WEBSOCKETS ===
|
|
||||||
|
|
||||||
// WebSocket for hot reload
|
|
||||||
wss.on('connection', (ws, req) => {
|
|
||||||
const url = new URL(req.url!, `http://${req.headers.host}`);
|
|
||||||
const isHotReload = url.searchParams.get('hotReload') === 'true';
|
|
||||||
|
|
||||||
if (isHotReload) {
|
|
||||||
hotReloadClients.add(ws);
|
|
||||||
ws.on('close', () => {
|
|
||||||
hotReloadClients.delete(ws);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.close(1008, 'Only hot reload connections supported');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hot reload file watching in development
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
const chokidar = require('chokidar');
|
|
||||||
const watcher = chokidar.watch(['public/**/*', 'src/**/*'], {
|
|
||||||
ignored: /node_modules/,
|
|
||||||
persistent: true
|
|
||||||
});
|
|
||||||
|
|
||||||
watcher.on('change', (path: string) => {
|
|
||||||
console.log(`File changed: ${path}`);
|
|
||||||
hotReloadClients.forEach((ws: any) => {
|
|
||||||
if (ws.readyState === ws.OPEN) {
|
|
||||||
ws.send(JSON.stringify({ type: 'reload' }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
|
||||||
console.log(`VibeTunnel New Server running on http://localhost:${PORT}`);
|
|
||||||
console.log(`Using tty-fwd: ${TTY_FWD_PATH}`);
|
|
||||||
});
|
|
||||||
Loading…
Reference in a new issue