diff --git a/README.md b/README.md index ff794b1..414bb51 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,25 @@ Install-AITool -Name Gemini, Aider Install-AITool -Name All ``` +### Installation Scope (Linux) + +By default, tools install to user-local directories (`CurrentUser` scope) without requiring elevated privileges. On Linux, you can optionally install system-wide: + +```powershell +# User-local installation (default, no sudo required) +Install-AITool -Name Aider -Scope CurrentUser + +# System-wide installation (requires sudo on Linux) +Install-AITool -Name Gemini -Scope LocalMachine +``` + +When using `-Scope LocalMachine` on Linux: +- You'll be prompted for your sudo password if needed +- Prerequisites (Node.js, pipx) are installed via apt-get +- Tools are available to all users on the system + +On macOS, Homebrew handles installations without requiring sudo, so both scopes work without elevated privileges. + ## Set your default ```powershell diff --git a/Tests/aitools.Tests.ps1 b/Tests/aitools.Tests.ps1 index cd4aa3e..07ebede 100644 --- a/Tests/aitools.Tests.ps1 +++ b/Tests/aitools.Tests.ps1 @@ -203,6 +203,60 @@ function Get-TestData { } } + Context 'Install-AITool Scope Parameter' { + It 'Should accept CurrentUser scope' { + # CurrentUser is the default, should work without issues + $result = Install-AITool -Name Claude -Scope CurrentUser -SkipInitialization + $result | Should -Not -BeNullOrEmpty + $result.Result | Should -Be 'Success' + } + + It 'Should accept LocalMachine scope parameter' { + # Verify the parameter is accepted (actual installation may require sudo) + { Get-Command Install-AITool | Should -Not -BeNullOrEmpty } | Should -Not -Throw + $cmd = Get-Command Install-AITool + $cmd.Parameters['Scope'] | Should -Not -BeNullOrEmpty + $cmd.Parameters['Scope'].ParameterType.Name | Should -Be 'String' + } + + It 'Should have valid Scope parameter values' { + $cmd = Get-Command Install-AITool + $validateSet = $cmd.Parameters['Scope'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $validateSet.ValidValues | Should -Contain 'CurrentUser' + $validateSet.ValidValues | Should -Contain 'LocalMachine' + } + + It 'Should handle LocalMachine scope for already-installed tool' { + # Claude is already installed, so this tests the code path without needing sudo + $result = Install-AITool -Name Claude -Scope LocalMachine -SkipInitialization + $result | Should -Not -BeNullOrEmpty + $result.Result | Should -Be 'Success' + $result.Installer | Should -Be 'Already Installed' + } + } + + Context 'Uninstall-AITool Scope Parameter' { + It 'Should accept Scope parameter' { + $cmd = Get-Command Uninstall-AITool + $cmd.Parameters['Scope'] | Should -Not -BeNullOrEmpty + $cmd.Parameters['Scope'].ParameterType.Name | Should -Be 'String' + } + + It 'Should have valid Scope parameter values' { + $cmd = Get-Command Uninstall-AITool + $validateSet = $cmd.Parameters['Scope'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $validateSet.ValidValues | Should -Contain 'CurrentUser' + $validateSet.ValidValues | Should -Contain 'LocalMachine' + } + + It 'Should default to CurrentUser scope' { + $cmd = Get-Command Uninstall-AITool + $scopeParam = $cmd.Parameters['Scope'] + # Check that it has a default value + $scopeParam.Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } | Should -Not -BeNullOrEmpty + } + } + } AfterAll { diff --git a/private/Invoke-SudoCommand.ps1 b/private/Invoke-SudoCommand.ps1 new file mode 100644 index 0000000..7ad22ab --- /dev/null +++ b/private/Invoke-SudoCommand.ps1 @@ -0,0 +1,135 @@ +function Invoke-SudoCommand { + <# + .SYNOPSIS + Executes a command with sudo if required on Linux. + + .DESCRIPTION + Wraps command execution to handle sudo requirements on Linux. + On macOS and Windows, runs commands directly without modification. + + For Linux with LocalMachine scope: + - Validates sudo access is available before execution + - Prepends sudo to commands if not running as root + - Provides clear error messages if sudo access fails + + .PARAMETER Command + The command to execute. + + .PARAMETER Scope + The installation scope. Affects whether sudo is needed. + + .PARAMETER Description + A description of what the command does, for error messages. + + .OUTPUTS + [PSCustomObject] With properties: + - Success: [bool] Whether the command succeeded + - ExitCode: [int] The exit code + - Output: [string] Combined stdout/stderr + - Command: [string] The actual command that was run + + .EXAMPLE + Invoke-SudoCommand -Command 'apt-get update' -Scope LocalMachine -Description 'updating package lists' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Command, + + [Parameter()] + [ValidateSet('CurrentUser', 'LocalMachine')] + [string]$Scope = 'CurrentUser', + + [Parameter()] + [string]$Description = 'executing command' + ) + + $os = Get-OperatingSystem + $needsSudo = Test-SudoRequired -Scope $Scope + $actualCommand = $Command + + # On Linux with LocalMachine scope, we need to handle sudo + if ($needsSudo) { + Write-PSFMessage -Level Verbose -Message "Sudo required for: $Description" + + # First, validate that sudo access is available + # Use sudo -n (non-interactive) to check if we have passwordless sudo + # or if sudo credentials are cached + $sudoCheck = & bash -c 'sudo -n true 2>/dev/null; echo $?' 2>$null + $hasSudoAccess = ($sudoCheck -eq '0') + + if (-not $hasSudoAccess) { + # Try to prompt for sudo password by running sudo -v + # This will cache credentials for subsequent commands + Write-PSFMessage -Level Host -Message "Elevated privileges required for $Description. You may be prompted for your password." + + # Run sudo -v to prompt for password and cache credentials + # This needs to be interactive, so we use Start-Process with UseShellExecute + $validateResult = & bash -c 'sudo -v 2>&1; echo "EXIT:$?"' + $exitLine = $validateResult | Select-Object -Last 1 + $sudoValidated = $exitLine -eq 'EXIT:0' + + if (-not $sudoValidated) { + return [PSCustomObject]@{ + Success = $false + ExitCode = 1 + Output = "Failed to obtain sudo privileges. Please ensure you have sudo access and try again." + Command = $Command + } + } + } + + # Prepend sudo to the command if it doesn't already have it + if ($Command -notmatch '^\s*sudo\s') { + $actualCommand = "sudo $Command" + } + } + + Write-PSFMessage -Level Verbose -Message "Executing: $actualCommand" + + try { + if ($os -eq 'Windows') { + # On Windows, use cmd.exe + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = 'cmd.exe' + $psi.Arguments = "/c `"$actualCommand`"" + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + } else { + # On Linux/macOS, use bash + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = '/bin/bash' + $psi.Arguments = "-c `"$actualCommand`"" + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + } + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $psi + $process.Start() | Out-Null + + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + + $output = @($stdout, $stderr) | Where-Object { $_ } | Join-String -Separator "`n" + + return [PSCustomObject]@{ + Success = ($process.ExitCode -eq 0) + ExitCode = $process.ExitCode + Output = $output + Command = $actualCommand + } + } catch { + return [PSCustomObject]@{ + Success = $false + ExitCode = -1 + Output = $_.Exception.Message + Command = $actualCommand + } + } +} diff --git a/private/Test-SudoRequired.ps1 b/private/Test-SudoRequired.ps1 new file mode 100644 index 0000000..c30b115 --- /dev/null +++ b/private/Test-SudoRequired.ps1 @@ -0,0 +1,61 @@ +function Test-SudoRequired { + <# + .SYNOPSIS + Determines if sudo is required for a command on the current platform. + + .DESCRIPTION + Checks whether the current user needs sudo/elevated privileges to run + system-level commands. On Linux, checks if running as root. On macOS, + most operations (Homebrew, npm, pipx) do NOT require sudo. + + .PARAMETER Scope + The installation scope. LocalMachine typically requires elevated privileges on Linux. + + .OUTPUTS + [bool] True if sudo is required, False otherwise. + + .NOTES + - Linux with LocalMachine scope: Requires sudo for apt-get and system-wide installations + - macOS: Homebrew and most package managers do NOT require sudo + - Windows: Not applicable (uses different elevation model) + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter()] + [ValidateSet('CurrentUser', 'LocalMachine')] + [string]$Scope = 'CurrentUser' + ) + + $os = Get-OperatingSystem + + # Windows doesn't use sudo + if ($os -eq 'Windows') { + return $false + } + + # macOS: Homebrew and modern package managers don't need sudo + # They install to /usr/local or /opt/homebrew which are user-writable + if ($os -eq 'MacOS') { + return $false + } + + # Linux: Only LocalMachine scope needs sudo + if ($os -eq 'Linux') { + if ($Scope -eq 'CurrentUser') { + return $false + } + + # LocalMachine scope - check if already root + $userId = & id -u 2>$null + if ($userId -eq '0') { + # Already running as root + return $false + } + + # Need sudo for LocalMachine on Linux + return $true + } + + return $false +} diff --git a/public/Install-AITool.ps1 b/public/Install-AITool.ps1 index 6a4bf43..540cf4c 100644 --- a/public/Install-AITool.ps1 +++ b/public/Install-AITool.ps1 @@ -336,34 +336,22 @@ function Install-AITool { # Choose installation method based on Scope if ($Scope -eq 'LocalMachine') { Write-PSFMessage -Level Verbose -Message "Installing pipx system-wide (requires sudo)..." - $pipxInstallCmd = 'sudo apt-get update && sudo apt-get install -y pipx && pipx ensurepath' + $pipxInstallCmd = 'apt-get update && apt-get install -y pipx && pipx ensurepath' } else { Write-PSFMessage -Level Verbose -Message "Installing pipx for current user (no sudo required)..." $pipxInstallCmd = 'python3 -m pip install --user pipx && python3 -m pipx ensurepath' } try { - $psi = New-Object System.Diagnostics.ProcessStartInfo - $psi.FileName = '/bin/bash' - $psi.Arguments = "-c `"$pipxInstallCmd`"" - $psi.RedirectStandardOutput = $true - $psi.RedirectStandardError = $true - $psi.UseShellExecute = $false - $psi.CreateNoWindow = $true - - $process = New-Object System.Diagnostics.Process - $process.StartInfo = $psi - $process.Start() | Out-Null - $stdout = $process.StandardOutput.ReadToEnd() - $stderr = $process.StandardError.ReadToEnd() - $process.WaitForExit() + # Use Invoke-SudoCommand which handles sudo validation and prompting + $result = Invoke-SudoCommand -Command $pipxInstallCmd -Scope $Scope -Description 'installing pipx' - if ($process.ExitCode -ne 0) { + if (-not $result.Success) { Write-Progress -Activity "Installing $currentToolName" -Completed if ($Scope -eq 'LocalMachine') { - Stop-PSFFunction -Message "pipx installation failed. Please install pipx manually: sudo apt-get install pipx" -EnableException $true + Stop-PSFFunction -Message "pipx installation failed. Please install pipx manually: sudo apt-get install pipx`n$($result.Output)" -EnableException $true } else { - Stop-PSFFunction -Message "pipx installation failed. Please install pipx manually: python3 -m pip install --user pipx" -EnableException $true + Stop-PSFFunction -Message "pipx installation failed. Please install pipx manually: python3 -m pip install --user pipx`n$($result.Output)" -EnableException $true } return } @@ -419,13 +407,29 @@ function Install-AITool { # Choose installation method based on Scope if ($Scope -eq 'LocalMachine') { Write-PSFMessage -Level Verbose -Message "Installing Node.js system-wide (requires sudo)..." - $nodeInstallCmd = 'curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - && sudo apt-get install -y nodejs' + # Note: The nodesource script itself needs sudo, so we handle this specially + # First download the setup script, then run it with sudo, then install nodejs + $nodeInstallCmd = 'curl -fsSL https://deb.nodesource.com/setup_lts.x -o /tmp/nodesource_setup.sh && sudo -E bash /tmp/nodesource_setup.sh && sudo apt-get install -y nodejs && rm -f /tmp/nodesource_setup.sh' } else { Write-PSFMessage -Level Verbose -Message "Installing Node.js for current user using nvm (no sudo required)..." $nodeInstallCmd = 'curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash && export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" && nvm install --lts && nvm use --lts' } try { + if ($Scope -eq 'LocalMachine') { + # For LocalMachine, validate sudo access first + if (Test-SudoRequired -Scope $Scope) { + Write-PSFMessage -Level Host -Message "Elevated privileges required for installing Node.js. You may be prompted for your password." + $sudoCheck = & bash -c 'sudo -v 2>&1; echo "EXIT:$?"' + $exitLine = $sudoCheck | Select-Object -Last 1 + if ($exitLine -ne 'EXIT:0') { + Write-Progress -Activity "Installing $currentToolName" -Completed + Stop-PSFFunction -Message "Failed to obtain sudo privileges. Please ensure you have sudo access and try again." -EnableException $true + return + } + } + } + $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = '/bin/bash' $psi.Arguments = "-c `"$nodeInstallCmd`"" @@ -444,9 +448,9 @@ function Install-AITool { if ($process.ExitCode -ne 0) { Write-Progress -Activity "Installing $currentToolName" -Completed if ($Scope -eq 'LocalMachine') { - Stop-PSFFunction -Message "Node.js installation failed. Please install Node.js manually: sudo apt-get install nodejs" -EnableException $true + Stop-PSFFunction -Message "Node.js installation failed. Please install Node.js manually: sudo apt-get install nodejs`n$stdout`n$stderr" -EnableException $true } else { - Stop-PSFFunction -Message "Node.js installation failed. Please install Node.js manually using nvm or from https://nodejs.org/" -EnableException $true + Stop-PSFFunction -Message "Node.js installation failed. Please install Node.js manually using nvm or from https://nodejs.org/`n$stdout`n$stderr" -EnableException $true } return } diff --git a/public/Uninstall-AITool.ps1 b/public/Uninstall-AITool.ps1 index e1f8c03..1b3dc82 100644 --- a/public/Uninstall-AITool.ps1 +++ b/public/Uninstall-AITool.ps1 @@ -21,6 +21,10 @@ function Uninstall-AITool { Uninstall-AITool -Name Aider -Force Uninstalls Aider without confirmation. + .EXAMPLE + Uninstall-AITool -Name Gemini -Scope LocalMachine + Uninstalls Gemini CLI from system-wide installation (requires sudo on Linux). + .OUTPUTS AITools.UninstallResult An object containing Tool name, Result (Success/Failed), and Uninstaller command used. @@ -32,7 +36,11 @@ function Uninstall-AITool { [string]$Name, [Parameter()] - [switch]$Force + [switch]$Force, + + [Parameter()] + [ValidateSet('CurrentUser', 'LocalMachine')] + [string]$Scope = 'CurrentUser' ) begin { @@ -172,59 +180,72 @@ function Uninstall-AITool { } } else { # Use Start-Process for external executables - # Split the command into executable and arguments - $cmdParts = $uninstallCmd -split ' ', 2 - $executable = $cmdParts[0] - $arguments = if ($cmdParts.Count -gt 1) { $cmdParts[1] } else { '' } - - Write-PSFMessage -Level Verbose -Message "Executable: $executable" - Write-PSFMessage -Level Verbose -Message "Arguments: $arguments" - - # Resolve the full path to the executable to avoid PATH issues with UseShellExecute = $false - $executablePath = (Get-Command $executable -ErrorAction SilentlyContinue).Source - if (-not $executablePath) { - $executablePath = (Get-Command $executable -ErrorAction SilentlyContinue).Path - } - if (-not $executablePath) { - # If we still can't find it, use the executable as-is and hope for the best - $executablePath = $executable - } - - Write-PSFMessage -Level Verbose -Message "Resolved path: $executablePath" - - # If the resolved path is a .ps1 or .cmd file, we need to invoke it through the shell - # On Windows, npm resolves to npm.ps1 or npm.cmd which can't be directly executed - $psi = New-Object System.Diagnostics.ProcessStartInfo - if ($executablePath -match '\.(ps1|cmd)$') { - Write-PSFMessage -Level Verbose -Message "Detected shell script, using cmd.exe wrapper" - $psi.FileName = "cmd.exe" - $psi.Arguments = "/c `"$executable $arguments`"" - } else { - $psi.FileName = $executablePath - $psi.Arguments = $arguments - } - $psi.RedirectStandardOutput = $true - $psi.RedirectStandardError = $true - $psi.UseShellExecute = $false - $psi.CreateNoWindow = $true - - $process = New-Object System.Diagnostics.Process - $process.StartInfo = $psi - $process.Start() | Out-Null + # On Linux with LocalMachine scope, use Invoke-SudoCommand for proper sudo handling + if ($os -eq 'Linux' -and $Scope -eq 'LocalMachine') { + Write-PSFMessage -Level Verbose -Message "Using Invoke-SudoCommand for LocalMachine scope uninstall" + $result = Invoke-SudoCommand -Command $uninstallCmd -Scope $Scope -Description "uninstalling $Name" + $exitCode = $result.ExitCode + $stdout = $result.Output + $stderr = '' + + if ($stdout) { + $stdout -split "`n" | ForEach-Object { Write-PSFMessage -Level Verbose -Message $_ } + } + } else { + # Split the command into executable and arguments + $cmdParts = $uninstallCmd -split ' ', 2 + $executable = $cmdParts[0] + $arguments = if ($cmdParts.Count -gt 1) { $cmdParts[1] } else { '' } + + Write-PSFMessage -Level Verbose -Message "Executable: $executable" + Write-PSFMessage -Level Verbose -Message "Arguments: $arguments" + + # Resolve the full path to the executable to avoid PATH issues with UseShellExecute = $false + $executablePath = (Get-Command $executable -ErrorAction SilentlyContinue).Source + if (-not $executablePath) { + $executablePath = (Get-Command $executable -ErrorAction SilentlyContinue).Path + } + if (-not $executablePath) { + # If we still can't find it, use the executable as-is and hope for the best + $executablePath = $executable + } - $stdout = $process.StandardOutput.ReadToEnd() - $stderr = $process.StandardError.ReadToEnd() - $process.WaitForExit() + Write-PSFMessage -Level Verbose -Message "Resolved path: $executablePath" + + # If the resolved path is a .ps1 or .cmd file, we need to invoke it through the shell + # On Windows, npm resolves to npm.ps1 or npm.cmd which can't be directly executed + $psi = New-Object System.Diagnostics.ProcessStartInfo + if ($executablePath -match '\.(ps1|cmd)$') { + Write-PSFMessage -Level Verbose -Message "Detected shell script, using cmd.exe wrapper" + $psi.FileName = "cmd.exe" + $psi.Arguments = "/c `"$executable $arguments`"" + } else { + $psi.FileName = $executablePath + $psi.Arguments = $arguments + } + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $psi + $process.Start() | Out-Null + + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + + # Send output to verbose + if ($stdout) { + $stdout -split "`n" | ForEach-Object { Write-PSFMessage -Level Verbose -Message $_ } + } + if ($stderr) { + $stderr -split "`n" | ForEach-Object { Write-PSFMessage -Level Verbose -Message $_ } + } - # Send output to verbose - if ($stdout) { - $stdout -split "`n" | ForEach-Object { Write-PSFMessage -Level Verbose -Message $_ } + $exitCode = $process.ExitCode } - if ($stderr) { - $stderr -split "`n" | ForEach-Object { Write-PSFMessage -Level Verbose -Message $_ } - } - - $exitCode = $process.ExitCode } Write-PSFMessage -Level Verbose -Message "Uninstall command completed with exit code: $exitCode"