Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions Tests/aitools.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
135 changes: 135 additions & 0 deletions private/Invoke-SudoCommand.ps1
Original file line number Diff line number Diff line change
@@ -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
}
}
}
61 changes: 61 additions & 0 deletions private/Test-SudoRequired.ps1
Original file line number Diff line number Diff line change
@@ -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
}
46 changes: 25 additions & 21 deletions public/Install-AITool.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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`""
Expand All @@ -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
}
Expand Down
Loading
Loading