From 87454cf4b2f07c916d12bd6e4a6714eefae8ac58 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 17 Jul 2025 19:04:30 +0200 Subject: [PATCH] Fix vt test for conditional installation (#393) --- mac/VibeTunnel/Utilities/CLIInstaller.swift | 6 +- web/CLAUDE.md | 9 +- web/README.md | 3 +- web/docs/VT_INSTALLATION.md | 113 ++++++++++++ web/package.npm.json | 3 +- web/scripts/build-npm.js | 3 +- web/scripts/install-vt-command.js | 121 +++++++++++++ web/scripts/postinstall-npm.js | 22 +-- web/scripts/test-vt-install.js | 94 ++++++++++ web/scripts/test-vt-syntax.sh | 16 +- web/src/test/unit/postinstall-vt.test.ts | 188 ++++++++++++++++++++ 11 files changed, 550 insertions(+), 28 deletions(-) create mode 100644 web/docs/VT_INSTALLATION.md create mode 100644 web/scripts/install-vt-command.js create mode 100755 web/scripts/test-vt-install.js create mode 100644 web/src/test/unit/postinstall-vt.test.ts diff --git a/mac/VibeTunnel/Utilities/CLIInstaller.swift b/mac/VibeTunnel/Utilities/CLIInstaller.swift index 26146f9f..f13d3604 100644 --- a/mac/VibeTunnel/Utilities/CLIInstaller.swift +++ b/mac/VibeTunnel/Utilities/CLIInstaller.swift @@ -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)") diff --git a/web/CLAUDE.md b/web/CLAUDE.md index 396719a8..5d9826eb 100644 --- a/web/CLAUDE.md +++ b/web/CLAUDE.md @@ -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 \ No newline at end of file +- 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 \ No newline at end of file diff --git a/web/README.md b/web/README.md index 2dc0d469..45826528 100644 --- a/web/README.md +++ b/web/README.md @@ -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**: diff --git a/web/docs/VT_INSTALLATION.md b/web/docs/VT_INSTALLATION.md new file mode 100644 index 00000000..da133203 --- /dev/null +++ b/web/docs/VT_INSTALLATION.md @@ -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" +``` \ No newline at end of file diff --git a/web/package.npm.json b/web/package.npm.json index 047346f5..56b184b5 100644 --- a/web/package.npm.json +++ b/web/package.npm.json @@ -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/", diff --git a/web/scripts/build-npm.js b/web/scripts/build-npm.js index e7111e14..05fc9006 100644 --- a/web/scripts/build-npm.js +++ b/web/scripts/build-npm.js @@ -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) { diff --git a/web/scripts/install-vt-command.js b/web/scripts/install-vt-command.js new file mode 100644 index 00000000..c4ba621e --- /dev/null +++ b/web/scripts/install-vt-command.js @@ -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 +}; \ No newline at end of file diff --git a/web/scripts/postinstall-npm.js b/web/scripts/postinstall-npm.js index 9aac1131..03c7cab8 100755 --- a/web/scripts/postinstall-npm.js +++ b/web/scripts/postinstall-npm.js @@ -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) { diff --git a/web/scripts/test-vt-install.js b/web/scripts/test-vt-install.js new file mode 100755 index 00000000..97b80a6a --- /dev/null +++ b/web/scripts/test-vt-install.js @@ -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.'); \ No newline at end of file diff --git a/web/scripts/test-vt-syntax.sh b/web/scripts/test-vt-syntax.sh index c8a55f7f..32f07847 100755 --- a/web/scripts/test-vt-syntax.sh +++ b/web/scripts/test-vt-syntax.sh @@ -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!" \ No newline at end of file diff --git a/web/src/test/unit/postinstall-vt.test.ts b/web/src/test/unit/postinstall-vt.test.ts new file mode 100644 index 00000000..606520f8 --- /dev/null +++ b/web/src/test/unit/postinstall-vt.test.ts @@ -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'); + }); + }); +});