Fix vt test for conditional installation (#393)

This commit is contained in:
Peter Steinberger 2025-07-17 19:04:30 +02:00 committed by GitHub
parent 5bdc7f7b1b
commit 87454cf4b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 550 additions and 28 deletions

View file

@ -66,9 +66,13 @@ final class CLIInstaller {
// Check if it contains the correct app path reference
if let content = try? String(contentsOfFile: path, encoding: .utf8) {
// Verify it's our wrapper script with all expected components
// Check for the exec command with flexible quoting and optional arguments
// Allow for optional variables or arguments between $VIBETUNNEL_BIN and fwd
let hasValidExecCommand = content.range(of: #"exec\s+["']?\$VIBETUNNEL_BIN["']?\s+fwd"#, options: .regularExpression) != nil
if content.contains("VibeTunnel CLI wrapper") &&
content.contains("$TRY_PATH/Contents/Resources/vibetunnel") &&
content.contains("exec \"$VIBETUNNEL_BIN\" fwd")
hasValidExecCommand
{
isCorrectlyInstalled = true
logger.info("CLIInstaller: Found valid vt script at \(path)")

View file

@ -80,4 +80,11 @@ Do NOT use three separate commands (add, commit, push) as this is slow.
- Do NOT modify `package.json` or `pnpm-lock.yaml` unless explicitly requested
- Always ask for permission before suggesting new dependencies
- Understand and work with the existing codebase architecture first
- This project has custom implementations - don't assume we need standard packages
- This project has custom implementations - don't assume we need standard packages
## CRITICAL: vt Command in package.json
**IMPORTANT: DO NOT add "vt": "./bin/vt" to the bin section of package.json or package.npm.json!**
- The vt command must NOT be registered as a global binary in package.json
- This is because it conflicts with other tools that use 'vt' (there are many)
- Instead, vt is conditionally installed via postinstall script only if available
- The postinstall script checks if vt already exists before creating a symlink

View file

@ -28,7 +28,8 @@ pnpm run build
**npm package**:
- Pre-built binaries for common platforms (macOS x64/arm64, Linux x64/arm64)
- Automatic fallback to source compilation if pre-built binaries unavailable
- Global installation makes `vibetunnel` and `vt` commands available system-wide
- Global installation makes `vibetunnel` command available system-wide
- Conditional `vt` command installation (see [VT Installation Guide](docs/VT_INSTALLATION.md))
- Includes production dependencies only
**Source installation**:

113
web/docs/VT_INSTALLATION.md Normal file
View file

@ -0,0 +1,113 @@
# VT Command Installation Guide
The `vt` command is VibeTunnel's convenient wrapper that allows you to run any command with terminal sharing enabled. This guide explains how the installation works and how to manage it.
## Installation Behavior
When you install VibeTunnel via npm, the `vt` command installation follows these rules:
### Global Installation (`npm install -g vibetunnel`)
- **Checks for existing `vt` command** to avoid conflicts with other tools
- If no `vt` command exists, creates it globally
- If `vt` already exists, skips installation and shows a warning
- You can still use `npx vt` or `vibetunnel fwd` as alternatives
### Local Installation (`npm install vibetunnel`)
- Configures `vt` for local use only
- Access via `npx vt` within your project
## Platform Support
### macOS and Linux
- Creates a symlink to the `vt` script
- Falls back to copying if symlink creation fails
- Script is made executable automatically
### Windows
- Creates a `.cmd` wrapper for proper command execution
- Copies the actual script alongside the wrapper
- Works with Command Prompt, PowerShell, and Git Bash
## Common Scenarios
### Existing VT Command
If you already have a `vt` command from another tool:
```bash
# You'll see this warning during installation:
⚠️ A "vt" command already exists in your system
VibeTunnel's vt wrapper was not installed to avoid conflicts
You can still use "npx vt" or the full path to run VibeTunnel's vt
```
**Alternatives:**
- Use `npx vt` (works globally if installed with -g)
- Use `vibetunnel fwd` directly
- Manually install to a different name (see below)
### Manual Installation
If automatic installation fails or you want to customize:
```bash
# Find where npm installs global packages
npm config get prefix
# On macOS/Linux, create symlink manually
ln -s $(npm root -g)/vibetunnel/bin/vt /usr/local/bin/vt
# Or copy and rename to avoid conflicts
cp $(npm root -g)/vibetunnel/bin/vt /usr/local/bin/vibetunnel-vt
chmod +x /usr/local/bin/vibetunnel-vt
```
### Force Reinstallation
To force VibeTunnel to overwrite an existing `vt` command:
```bash
# Remove existing vt first
rm -f $(which vt)
# Then reinstall VibeTunnel
npm install -g vibetunnel
```
## Troubleshooting
### Permission Denied
If you get permission errors during global installation:
```bash
# Option 1: Use a Node version manager (recommended)
# With nvm: https://github.com/nvm-sh/nvm
# With fnm: https://github.com/Schniz/fnm
# Option 2: Change npm's default directory
# See: https://docs.npmjs.com/resolving-eacces-permissions-errors
```
### Command Not Found
If `vt` is installed but not found:
```bash
# Check if npm bin directory is in PATH
echo $PATH
npm config get prefix
# Add to your shell profile (.bashrc, .zshrc, etc.)
export PATH="$(npm config get prefix)/bin:$PATH"
```
### Windows Specific Issues
- Ensure Node.js is in your system PATH
- Restart your terminal after installation
- Try using `vt.cmd` explicitly if `vt` doesn't work
## Uninstallation
The `vt` command is removed automatically when you uninstall VibeTunnel:
```bash
npm uninstall -g vibetunnel
```
If it persists, remove manually:
```bash
rm -f $(which vt)
# On Windows: del "%APPDATA%\npm\vt.cmd"
```

View file

@ -4,8 +4,7 @@
"description": "Terminal sharing server with web interface - supports macOS, Linux, and headless environments",
"main": "lib/cli.js",
"bin": {
"vibetunnel": "./bin/vibetunnel",
"vt": "./bin/vt"
"vibetunnel": "./bin/vibetunnel"
},
"files": [
"lib/",

View file

@ -600,7 +600,8 @@ async function main() {
// Scripts
{ src: 'scripts/postinstall-npm.js', dest: 'scripts/postinstall.js' },
{ src: 'scripts/node-pty-plugin.js', dest: 'scripts/node-pty-plugin.js' }
{ src: 'scripts/node-pty-plugin.js', dest: 'scripts/node-pty-plugin.js' },
{ src: 'scripts/install-vt-command.js', dest: 'scripts/install-vt-command.js' }
];
function copyRecursive(src, dest) {

View file

@ -0,0 +1,121 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// Helper function to detect global installation
const detectGlobalInstall = () => {
if (process.env.npm_config_global === 'true') return true;
if (process.env.npm_config_global === 'false') return false;
try {
const globalPrefix = execSync('npm config get prefix', { encoding: 'utf8' }).trim();
const globalModules = path.join(globalPrefix, process.platform === 'win32' ? 'node_modules' : 'lib/node_modules');
const packagePath = path.resolve(__dirname, '..');
return packagePath.startsWith(globalModules);
} catch {
return false; // Default to local install
}
};
// Helper function to get npm global bin directory
const getNpmBinDir = () => {
try {
// Try npm config first (more reliable)
const npmPrefix = execSync('npm config get prefix', { encoding: 'utf8' }).trim();
return path.join(npmPrefix, 'bin');
} catch (e) {
console.warn('⚠️ Could not determine npm global bin directory');
return null;
}
};
// Helper function to install vt globally
const installGlobalVt = (vtSource, npmBinDir) => {
const vtTarget = path.join(npmBinDir, 'vt');
const isWindows = process.platform === 'win32';
// Check if vt already exists
if (fs.existsSync(vtTarget) || (isWindows && fs.existsSync(vtTarget + '.cmd'))) {
console.log('⚠️ A "vt" command already exists in your system');
console.log(' VibeTunnel\'s vt wrapper was not installed to avoid conflicts');
console.log(' You can still use "npx vt" or the full path to run VibeTunnel\'s vt');
return true;
}
try {
if (isWindows) {
// On Windows, create a .cmd wrapper
const cmdContent = `@echo off\r\nnode "%~dp0\\vt" %*\r\n`;
fs.writeFileSync(vtTarget + '.cmd', cmdContent);
// Also copy the actual script
fs.copyFileSync(vtSource, vtTarget);
console.log('✓ vt command installed globally (Windows)');
} else {
// On Unix-like systems, create symlink
fs.symlinkSync(vtSource, vtTarget);
console.log('✓ vt command installed globally');
}
console.log(' You can now use "vt" to wrap commands with VibeTunnel');
return true;
} catch (symlinkError) {
// If symlink fails on Unix, try copying the file
if (!isWindows) {
try {
fs.copyFileSync(vtSource, vtTarget);
fs.chmodSync(vtTarget, '755');
console.log('✓ vt command installed globally (copied)');
console.log(' You can now use "vt" to wrap commands with VibeTunnel');
return true;
} catch (copyError) {
console.warn('⚠️ Could not install vt command globally:', copyError.message);
console.log(' Use "npx vt" or "vibetunnel fwd" instead');
return false;
}
} else {
console.warn('⚠️ Could not install vt command on Windows:', symlinkError.message);
console.log(' Use "npx vt" or "vibetunnel fwd" instead');
return false;
}
}
};
// Install vt command handler
const installVtCommand = (vtSource, isGlobalInstall) => {
if (!fs.existsSync(vtSource)) {
console.warn('⚠️ vt command script not found in package');
console.log(' Use "vibetunnel" command instead');
return false;
}
try {
// Make vt script executable (Unix-like systems only)
if (process.platform !== 'win32') {
fs.chmodSync(vtSource, '755');
}
if (!isGlobalInstall) {
console.log('✓ vt command configured for local use');
console.log(' Use "npx vt" to run the vt wrapper');
return true;
}
const npmBinDir = getNpmBinDir();
if (!npmBinDir) {
return false;
}
return installGlobalVt(vtSource, npmBinDir);
} catch (error) {
console.warn('⚠️ Could not configure vt command:', error.message);
console.log(' Use "vibetunnel" command instead');
return false;
}
};
module.exports = {
detectGlobalInstall,
getNpmBinDir,
installGlobalVt,
installVtCommand
};

View file

@ -232,27 +232,19 @@ for (const module of modules) {
}
}
// Import vt installation functions
const { detectGlobalInstall, installVtCommand } = require('./install-vt-command');
// Install vt symlink/wrapper
if (!hasErrors && !isDevelopment) {
console.log('\nSetting up vt command...');
const vtSource = path.join(__dirname, '..', 'bin', 'vt');
// Check if vt script exists
if (!fs.existsSync(vtSource)) {
console.warn('⚠️ vt command script not found in package');
console.log(' Use "vibetunnel" command instead');
} else {
try {
// Make vt script executable
fs.chmodSync(vtSource, '755');
console.log('✓ vt command configured');
console.log(' Note: The vt command is available through npm/npx');
} catch (error) {
console.warn('⚠️ Could not configure vt command:', error.message);
console.log(' Use "vibetunnel" command instead');
}
}
// Use the improved global install detection
const isGlobalInstall = detectGlobalInstall();
console.log(` Detected ${isGlobalInstall ? 'global' : 'local'} installation`);
installVtCommand(vtSource, isGlobalInstall);
}
if (hasErrors) {

94
web/scripts/test-vt-install.js Executable file
View file

@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* Test script for vt installation functionality
* This tests the install-vt-command module without relying on command substitution
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
let { execSync } = require('child_process');
// Import the module we're testing
const { detectGlobalInstall, installVtCommand } = require('./install-vt-command');
// Create a test directory
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vt-install-test-'));
const testBinDir = path.join(testDir, 'bin');
const vtPath = path.join(testBinDir, 'vt');
// Create test vt script
fs.mkdirSync(testBinDir, { recursive: true });
fs.writeFileSync(vtPath, '#!/bin/bash\necho "test vt script"', { mode: 0o755 });
console.log('Running vt installation tests...\n');
// Test 1: Local installation
console.log('Test 1: Local installation');
process.env.npm_config_global = 'false';
const localResult = installVtCommand(vtPath, false);
console.log(` Result: ${localResult ? 'PASS' : 'FAIL'}`);
console.log(` Expected: vt configured for local use\n`);
// Test 2: Global installation detection
console.log('Test 2: Global installation detection');
process.env.npm_config_global = 'true';
const isGlobal = detectGlobalInstall();
console.log(` Detected as global: ${isGlobal}`);
console.log(` Result: ${isGlobal === true ? 'PASS' : 'FAIL'}\n`);
// Test 3: Global installation with existing vt
console.log('Test 3: Global installation with existing vt');
// Mock the existence check
const originalExistsSync = fs.existsSync;
const originalSymlinkSync = fs.symlinkSync;
let existsCheckCalled = false;
let symlinkCalled = false;
fs.existsSync = (path) => {
if (path.endsWith('/vt')) {
existsCheckCalled = true;
return true; // Simulate existing vt
}
return originalExistsSync(path);
};
fs.symlinkSync = (target, path) => {
symlinkCalled = true;
return originalSymlinkSync(target, path);
};
// Create a mock npm bin directory
const mockNpmBinDir = path.join(testDir, 'npm-bin');
fs.mkdirSync(mockNpmBinDir, { recursive: true });
// Mock execSync to return our test directory
const originalExecSync = require('child_process').execSync;
require('child_process').execSync = (cmd, opts) => {
if (cmd.includes('npm config get prefix')) {
return testDir;
}
return originalExecSync(cmd, opts);
};
process.env.npm_config_global = 'true';
const globalResult = installVtCommand(vtPath, true);
console.log(` Existing vt check called: ${existsCheckCalled}`);
console.log(` Symlink attempted: ${symlinkCalled}`);
console.log(` Result: ${globalResult && existsCheckCalled && !symlinkCalled ? 'PASS' : 'FAIL'}`);
console.log(` Expected: Should detect existing vt and skip installation\n`);
// Restore original functions
fs.existsSync = originalExistsSync;
fs.symlinkSync = originalSymlinkSync;
require('child_process').execSync = originalExecSync;
// Test 4: Missing vt script
console.log('Test 4: Missing vt script');
const missingResult = installVtCommand(path.join(testDir, 'nonexistent'), false);
console.log(` Result: ${!missingResult ? 'PASS' : 'FAIL'}`);
console.log(` Expected: Should return false for missing script\n`);
// Cleanup
fs.rmSync(testDir, { recursive: true, force: true });
console.log('All tests completed.');

View file

@ -53,20 +53,22 @@ if awk '/if.*then/{start=NR; in_if=1; has_content=0} in_if && !/^[[:space:]]*#/
fi
echo "✅ vt script has no empty if statements"
# Test 6: Check if package.json includes vt in bin section
# Test 6: Check that package.json does NOT include vt in bin section
# (vt is installed conditionally via postinstall script to avoid conflicts)
PACKAGE_JSON="$PROJECT_ROOT/package.json"
if [ -f "$PACKAGE_JSON" ]; then
if ! grep -q '"vt".*:.*"./bin/vt"' "$PACKAGE_JSON"; then
echo "❌ ERROR: package.json missing vt in bin section"
if grep -q '"vt".*:.*"./bin/vt"' "$PACKAGE_JSON"; then
echo "❌ ERROR: package.json should NOT include vt in bin section"
echo " vt must be installed conditionally via postinstall to avoid conflicts"
exit 1
fi
echo "✅ package.json includes vt in bin section"
echo "✅ package.json correctly omits vt from bin section (installed conditionally)"
fi
# Test 7: Basic functionality test (help command)
# Skip this test if we're already inside a VibeTunnel session
# Skip if already inside a VibeTunnel session (recursive sessions not supported)
if [ -n "$VIBETUNNEL_SESSION_ID" ]; then
echo "✅ vt script detected we're inside a VibeTunnel session (expected behavior)"
echo "⚠️ Skipping vt --help test (already inside VibeTunnel session)"
else
# Use gtimeout if available, otherwise skip timeout
if command -v gtimeout >/dev/null 2>&1; then
@ -81,7 +83,7 @@ else
exit 1
fi
fi
echo "✅ vt --help command works"
fi
echo "✅ vt --help command works"
echo "🎉 All vt command tests passed!"

View file

@ -0,0 +1,188 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
describe('postinstall vt installation', () => {
let testDir: string;
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
// Create a temporary directory for testing
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vt-test-'));
originalEnv = { ...process.env };
vi.clearAllMocks();
});
afterEach(() => {
// Clean up
process.env = originalEnv;
fs.rmSync(testDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe('installVtCommand - local installation', () => {
it('should configure vt for local use when not global install', () => {
const vtSource = path.join(testDir, 'vt');
fs.writeFileSync(vtSource, '#!/bin/bash\necho "test vt"');
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const { installVtCommand } = require('../../../scripts/install-vt-command');
const result = installVtCommand(vtSource, false);
expect(result).toBe(true);
expect(consoleSpy).toHaveBeenCalledWith('✓ vt command configured for local use');
expect(consoleSpy).toHaveBeenCalledWith(' Use "npx vt" to run the vt wrapper');
// Check file is executable on Unix
if (process.platform !== 'win32') {
const stats = fs.statSync(vtSource);
expect(stats.mode & 0o111).toBeTruthy(); // Check execute bit
}
consoleSpy.mockRestore();
});
it('should handle missing vt script gracefully', () => {
const vtSource = path.join(testDir, 'nonexistent');
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const { installVtCommand } = require('../../../scripts/install-vt-command');
const result = installVtCommand(vtSource, false);
expect(result).toBe(false);
expect(consoleWarnSpy).toHaveBeenCalledWith('⚠️ vt command script not found in package');
expect(consoleLogSpy).toHaveBeenCalledWith(' Use "vibetunnel" command instead');
consoleWarnSpy.mockRestore();
consoleLogSpy.mockRestore();
});
});
describe('detectGlobalInstall - environment variables', () => {
it('should detect global install when npm_config_global is true', () => {
process.env.npm_config_global = 'true';
const { detectGlobalInstall } = require('../../../scripts/install-vt-command');
const result = detectGlobalInstall();
expect(result).toBe(true);
});
it('should detect local install when npm_config_global is false', () => {
process.env.npm_config_global = 'false';
const { detectGlobalInstall } = require('../../../scripts/install-vt-command');
const result = detectGlobalInstall();
expect(result).toBe(false);
});
});
describe('Installation helpers', () => {
it('should check for existing vt command', () => {
const mockBinDir = path.join(testDir, 'bin');
fs.mkdirSync(mockBinDir);
// Test when vt doesn't exist
const vtPath = path.join(mockBinDir, 'vt');
expect(fs.existsSync(vtPath)).toBe(false);
// Create vt and test it exists
fs.writeFileSync(vtPath, '#!/bin/bash\necho "test"');
expect(fs.existsSync(vtPath)).toBe(true);
});
it('should handle Windows .cmd files', () => {
if (process.platform !== 'win32') {
// Skip on non-Windows
return;
}
const mockBinDir = path.join(testDir, 'bin');
fs.mkdirSync(mockBinDir);
const cmdPath = path.join(mockBinDir, 'vt.cmd');
const cmdContent = '@echo off\r\nnode "%~dp0\\vt" %*\r\n';
fs.writeFileSync(cmdPath, cmdContent);
const content = fs.readFileSync(cmdPath, 'utf8');
expect(content).toContain('@echo off');
expect(content).toContain('node "%~dp0\\vt" %*');
});
it('should handle symlink creation', () => {
if (process.platform === 'win32') {
// Skip on Windows
return;
}
const sourceFile = path.join(testDir, 'source');
const targetLink = path.join(testDir, 'target');
fs.writeFileSync(sourceFile, '#!/bin/bash\necho "test"');
// Create symlink
fs.symlinkSync(sourceFile, targetLink);
// Verify symlink exists and points to correct file
expect(fs.existsSync(targetLink)).toBe(true);
expect(fs.lstatSync(targetLink).isSymbolicLink()).toBe(true);
expect(fs.readlinkSync(targetLink)).toBe(sourceFile);
});
it('should handle file copying as fallback', () => {
const sourceFile = path.join(testDir, 'source');
const targetFile = path.join(testDir, 'target');
const content = '#!/bin/bash\necho "test"';
fs.writeFileSync(sourceFile, content);
// Copy file
fs.copyFileSync(sourceFile, targetFile);
// Make executable on Unix
if (process.platform !== 'win32') {
fs.chmodSync(targetFile, '755');
}
// Verify copy
expect(fs.existsSync(targetFile)).toBe(true);
expect(fs.readFileSync(targetFile, 'utf8')).toBe(content);
if (process.platform !== 'win32') {
const stats = fs.statSync(targetFile);
expect(stats.mode & 0o111).toBeTruthy(); // Check execute bit
}
});
});
describe('Edge cases', () => {
it('should handle permission errors gracefully', () => {
const vtSource = path.join(testDir, 'vt');
fs.writeFileSync(vtSource, '#!/bin/bash\necho "test vt"');
// Create a read-only directory to trigger permission error
const readOnlyDir = path.join(testDir, 'readonly');
fs.mkdirSync(readOnlyDir);
// This would normally fail with permission denied if we made it truly read-only
// but that's hard to test cross-platform, so we just verify the setup
expect(fs.existsSync(readOnlyDir)).toBe(true);
});
it('should handle path with spaces', () => {
const dirWithSpaces = path.join(testDir, 'dir with spaces');
fs.mkdirSync(dirWithSpaces);
const vtSource = path.join(dirWithSpaces, 'vt');
fs.writeFileSync(vtSource, '#!/bin/bash\necho "test vt"');
expect(fs.existsSync(vtSource)).toBe(true);
expect(fs.readFileSync(vtSource, 'utf8')).toContain('test vt');
});
});
});