mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-25 14:57:37 +00:00
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: raghav-latte <131696988+raghav-latte@users.noreply.github.com>
This commit is contained in:
parent
48ea8898fa
commit
966c671755
4 changed files with 319 additions and 7 deletions
|
|
@ -112,15 +112,28 @@ async function build() {
|
|||
// Build native executable
|
||||
console.log('Building native executable...');
|
||||
|
||||
// Check for --custom-node flag
|
||||
const useCustomNode = process.argv.includes('--custom-node');
|
||||
// Check if native binaries already exist (skip build for development)
|
||||
const nativeDir = path.join(__dirname, '..', 'native');
|
||||
const vibetunnelPath = path.join(nativeDir, 'vibetunnel');
|
||||
const ptyNodePath = path.join(nativeDir, 'pty.node');
|
||||
const spawnHelperPath = path.join(nativeDir, 'spawn-helper');
|
||||
|
||||
if (useCustomNode) {
|
||||
console.log('Using custom Node.js for smaller binary size...');
|
||||
execSync('node build-native.js --custom-node', { stdio: 'inherit' });
|
||||
if (fs.existsSync(vibetunnelPath) && fs.existsSync(ptyNodePath) && fs.existsSync(spawnHelperPath)) {
|
||||
console.log('✅ Native binaries already exist, skipping build...');
|
||||
console.log(' - vibetunnel executable: ✓');
|
||||
console.log(' - pty.node: ✓');
|
||||
console.log(' - spawn-helper: ✓');
|
||||
} else {
|
||||
console.log('Using system Node.js...');
|
||||
execSync('node build-native.js', { stdio: 'inherit' });
|
||||
// Check for --custom-node flag
|
||||
const useCustomNode = process.argv.includes('--custom-node');
|
||||
|
||||
if (useCustomNode) {
|
||||
console.log('Using custom Node.js for smaller binary size...');
|
||||
execSync('node build-native.js --custom-node', { stdio: 'inherit' });
|
||||
} else {
|
||||
console.log('Using system Node.js...');
|
||||
execSync('node build-native.js', { stdio: 'inherit' });
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Build completed successfully!');
|
||||
|
|
|
|||
106
web/src/server/pty/fish-handler.ts
Normal file
106
web/src/server/pty/fish-handler.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Fish Shell Handler
|
||||
*
|
||||
* Provides fish shell tab completion support.
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
export class FishHandler {
|
||||
/**
|
||||
* Get completion suggestions for a partial command
|
||||
*/
|
||||
async getCompletions(partial: string, cwd: string = process.cwd()): Promise<string[]> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
// Use fish's built-in completion system with proper escaping
|
||||
const fishProcess = spawn('fish', ['-c', `complete -C ${JSON.stringify(partial)}`], {
|
||||
cwd,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
const timeout = setTimeout(() => {
|
||||
fishProcess.kill('SIGTERM');
|
||||
resolve([]);
|
||||
}, 2000);
|
||||
|
||||
fishProcess.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
fishProcess.on('close', (code) => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (code !== 0 || !stdout.trim()) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const completions = stdout
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => line.split('\t')[0]) // Fish completions are tab-separated
|
||||
.filter((completion) => completion && completion !== partial);
|
||||
|
||||
resolve(completions);
|
||||
});
|
||||
|
||||
fishProcess.on('error', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve([]);
|
||||
});
|
||||
} catch (_error) {
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current shell is fish
|
||||
*/
|
||||
static isFishShell(shellPath: string): boolean {
|
||||
const basename = path.basename(shellPath);
|
||||
// Exact match for fish or fish with version suffix (e.g., fish3)
|
||||
return basename === 'fish' || /^fish\d*$/.test(basename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fish shell version
|
||||
*/
|
||||
static async getFishVersion(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const fishProcess = spawn('fish', ['--version'], {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
const timeout = setTimeout(() => {
|
||||
fishProcess.kill('SIGTERM');
|
||||
resolve(null);
|
||||
}, 1000);
|
||||
|
||||
fishProcess.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
fishProcess.on('close', (code) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(code === 0 && stdout.trim() ? stdout.trim() : null);
|
||||
});
|
||||
|
||||
fishProcess.on('error', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(null);
|
||||
});
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const fishHandler = new FishHandler();
|
||||
|
|
@ -34,6 +34,7 @@ import {
|
|||
import { WriteQueue } from '../utils/write-queue.js';
|
||||
import { VERSION } from '../version.js';
|
||||
import { AsciinemaWriter } from './asciinema-writer.js';
|
||||
import { FishHandler } from './fish-handler.js';
|
||||
import { ProcessUtils } from './process-utils.js';
|
||||
import { SessionManager } from './session-manager.js';
|
||||
import {
|
||||
|
|
@ -220,6 +221,9 @@ export class PtyManager extends EventEmitter {
|
|||
const { command: finalCommand, args: finalArgs } = resolved;
|
||||
const resolvedCommand = [finalCommand, ...finalArgs];
|
||||
|
||||
// Note: Fish shell expansion was removed for simplicity
|
||||
// Only basic tab completion is supported via getFishCompletions()
|
||||
|
||||
// Log resolution details
|
||||
if (resolved.resolvedFrom === 'alias') {
|
||||
logger.log(
|
||||
|
|
@ -884,6 +888,30 @@ export class PtyManager extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fish shell completions for a partial command
|
||||
*/
|
||||
async getFishCompletions(sessionId: string, partial: string): Promise<string[]> {
|
||||
try {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const userShell = ProcessUtils.getUserShell();
|
||||
if (!FishHandler.isFishShell(userShell)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { fishHandler } = await import('./fish-handler.js');
|
||||
const cwd = session.currentWorkingDir || process.cwd();
|
||||
return await fishHandler.getCompletions(partial, cwd);
|
||||
} catch (error) {
|
||||
logger.warn(`Fish completions failed: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send text input to a session
|
||||
*/
|
||||
|
|
|
|||
165
web/src/test/server/pty/fish-handler.test.ts
Normal file
165
web/src/test/server/pty/fish-handler.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FishHandler } from '../../../server/pty/fish-handler.js';
|
||||
|
||||
// Mock child_process
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
const mockSpawn = vi.mocked(spawn);
|
||||
|
||||
// Helper to create mock process
|
||||
const createMockProcess = (stdout: string, exitCode: number = 0, shouldError = false) => {
|
||||
const mockProcess = {
|
||||
stdout: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'data' && !shouldError) {
|
||||
callback(Buffer.from(stdout));
|
||||
}
|
||||
}),
|
||||
},
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === 'close') {
|
||||
setTimeout(() => callback(exitCode), 0);
|
||||
} else if (event === 'error' && shouldError) {
|
||||
setTimeout(() => callback(new Error('Process error')), 0);
|
||||
}
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
};
|
||||
return mockProcess;
|
||||
};
|
||||
|
||||
describe('FishHandler', () => {
|
||||
let fishHandler: FishHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
fishHandler = new FishHandler();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getCompletions', () => {
|
||||
it('should return empty array when fish command fails', async () => {
|
||||
const mockProcess = createMockProcess('', 1);
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
|
||||
const result = await fishHandler.getCompletions('ls');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when fish has no stdout', async () => {
|
||||
const mockProcess = createMockProcess('', 0);
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
|
||||
const result = await fishHandler.getCompletions('ls');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse fish completions correctly', async () => {
|
||||
const mockProcess = createMockProcess(
|
||||
'ls\t\nls-color\tColorized ls\nls-files\tList files only\n',
|
||||
0
|
||||
);
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
|
||||
const result = await fishHandler.getCompletions('ls');
|
||||
expect(result).toEqual(['ls-color', 'ls-files']);
|
||||
});
|
||||
|
||||
it('should filter out the original partial command', async () => {
|
||||
const mockProcess = createMockProcess(
|
||||
'git\t\ngit-add\tAdd files\ngit-commit\tCommit changes\n',
|
||||
0
|
||||
);
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
|
||||
const result = await fishHandler.getCompletions('git');
|
||||
expect(result).toEqual(['git-add', 'git-commit']);
|
||||
});
|
||||
|
||||
it('should handle empty completions gracefully', async () => {
|
||||
const mockProcess = createMockProcess('\n\n\n', 0);
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
|
||||
const result = await fishHandler.getCompletions('nonexistent');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle fish command timeout/errors', async () => {
|
||||
const mockProcess = createMockProcess('', 0, true);
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
|
||||
const result = await fishHandler.getCompletions('ls');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should call fish with correct parameters', async () => {
|
||||
const mockProcess = createMockProcess('test\n', 0);
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
|
||||
await fishHandler.getCompletions('ls /tmp', '/home/user');
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith('fish', ['-c', 'complete -C "ls /tmp"'], {
|
||||
cwd: '/home/user',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should use current working directory as default', async () => {
|
||||
const mockProcess = createMockProcess('test\n', 0);
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
|
||||
await fishHandler.getCompletions('ls');
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith('fish', ['-c', 'complete -C "ls"'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFishShell', () => {
|
||||
it('should return true for fish shell paths', () => {
|
||||
expect(FishHandler.isFishShell('/usr/bin/fish')).toBe(true);
|
||||
expect(FishHandler.isFishShell('/opt/homebrew/bin/fish')).toBe(true);
|
||||
expect(FishHandler.isFishShell('fish')).toBe(true);
|
||||
expect(FishHandler.isFishShell('/usr/bin/fish3')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-fish shells', () => {
|
||||
expect(FishHandler.isFishShell('/bin/bash')).toBe(false);
|
||||
expect(FishHandler.isFishShell('/bin/zsh')).toBe(false);
|
||||
expect(FishHandler.isFishShell('/bin/sh')).toBe(false);
|
||||
expect(FishHandler.isFishShell('/usr/bin/catfish')).toBe(false);
|
||||
expect(FishHandler.isFishShell('/usr/bin/fisherman')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFishVersion', () => {
|
||||
it('should return version when fish is available', async () => {
|
||||
const mockProcess = createMockProcess('fish, version 3.6.1', 0);
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
|
||||
const version = await FishHandler.getFishVersion();
|
||||
expect(version).toBe('fish, version 3.6.1');
|
||||
});
|
||||
|
||||
it('should return null when fish is not available', async () => {
|
||||
const mockProcess = createMockProcess('', 1);
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
|
||||
const version = await FishHandler.getFishVersion();
|
||||
expect(version).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when fish command throws', async () => {
|
||||
const mockProcess = createMockProcess('', 0, true);
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
|
||||
const version = await FishHandler.getFishVersion();
|
||||
expect(version).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue