Skip to content

Commit 144b550

Browse files
Sudo and Scope (#8)
* Add sudo-aware install/uninstall for Linux LocalMachine scope Introduces Invoke-SudoCommand and Test-SudoRequired helpers to handle sudo requirements for system-wide installs and uninstalls on Linux. Updates Install-AITool and Uninstall-AITool to use these helpers, ensuring proper privilege escalation and user prompts. Improves documentation in README to clarify installation scopes and privilege requirements. * Add tests for Scope parameter in Install/Uninstall-AITool Introduces tests to verify the Scope parameter handling for both Install-AITool and Uninstall-AITool, including validation of accepted values, parameter types, and default behaviors for CurrentUser and LocalMachine scopes.
1 parent c070992 commit 144b550

File tree

6 files changed

+366
-72
lines changed

6 files changed

+366
-72
lines changed

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,25 @@ Install-AITool -Name Gemini, Aider
8484
Install-AITool -Name All
8585
```
8686

87+
### Installation Scope (Linux)
88+
89+
By default, tools install to user-local directories (`CurrentUser` scope) without requiring elevated privileges. On Linux, you can optionally install system-wide:
90+
91+
```powershell
92+
# User-local installation (default, no sudo required)
93+
Install-AITool -Name Aider -Scope CurrentUser
94+
95+
# System-wide installation (requires sudo on Linux)
96+
Install-AITool -Name Gemini -Scope LocalMachine
97+
```
98+
99+
When using `-Scope LocalMachine` on Linux:
100+
- You'll be prompted for your sudo password if needed
101+
- Prerequisites (Node.js, pipx) are installed via apt-get
102+
- Tools are available to all users on the system
103+
104+
On macOS, Homebrew handles installations without requiring sudo, so both scopes work without elevated privileges.
105+
87106
## Set your default
88107

89108
```powershell

Tests/aitools.Tests.ps1

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,60 @@ function Get-TestData {
203203
}
204204
}
205205

206+
Context 'Install-AITool Scope Parameter' {
207+
It 'Should accept CurrentUser scope' {
208+
# CurrentUser is the default, should work without issues
209+
$result = Install-AITool -Name Claude -Scope CurrentUser -SkipInitialization
210+
$result | Should -Not -BeNullOrEmpty
211+
$result.Result | Should -Be 'Success'
212+
}
213+
214+
It 'Should accept LocalMachine scope parameter' {
215+
# Verify the parameter is accepted (actual installation may require sudo)
216+
{ Get-Command Install-AITool | Should -Not -BeNullOrEmpty } | Should -Not -Throw
217+
$cmd = Get-Command Install-AITool
218+
$cmd.Parameters['Scope'] | Should -Not -BeNullOrEmpty
219+
$cmd.Parameters['Scope'].ParameterType.Name | Should -Be 'String'
220+
}
221+
222+
It 'Should have valid Scope parameter values' {
223+
$cmd = Get-Command Install-AITool
224+
$validateSet = $cmd.Parameters['Scope'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
225+
$validateSet.ValidValues | Should -Contain 'CurrentUser'
226+
$validateSet.ValidValues | Should -Contain 'LocalMachine'
227+
}
228+
229+
It 'Should handle LocalMachine scope for already-installed tool' {
230+
# Claude is already installed, so this tests the code path without needing sudo
231+
$result = Install-AITool -Name Claude -Scope LocalMachine -SkipInitialization
232+
$result | Should -Not -BeNullOrEmpty
233+
$result.Result | Should -Be 'Success'
234+
$result.Installer | Should -Be 'Already Installed'
235+
}
236+
}
237+
238+
Context 'Uninstall-AITool Scope Parameter' {
239+
It 'Should accept Scope parameter' {
240+
$cmd = Get-Command Uninstall-AITool
241+
$cmd.Parameters['Scope'] | Should -Not -BeNullOrEmpty
242+
$cmd.Parameters['Scope'].ParameterType.Name | Should -Be 'String'
243+
}
244+
245+
It 'Should have valid Scope parameter values' {
246+
$cmd = Get-Command Uninstall-AITool
247+
$validateSet = $cmd.Parameters['Scope'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
248+
$validateSet.ValidValues | Should -Contain 'CurrentUser'
249+
$validateSet.ValidValues | Should -Contain 'LocalMachine'
250+
}
251+
252+
It 'Should default to CurrentUser scope' {
253+
$cmd = Get-Command Uninstall-AITool
254+
$scopeParam = $cmd.Parameters['Scope']
255+
# Check that it has a default value
256+
$scopeParam.Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } | Should -Not -BeNullOrEmpty
257+
}
258+
}
259+
206260
}
207261

208262
AfterAll {

private/Invoke-SudoCommand.ps1

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
function Invoke-SudoCommand {
2+
<#
3+
.SYNOPSIS
4+
Executes a command with sudo if required on Linux.
5+
6+
.DESCRIPTION
7+
Wraps command execution to handle sudo requirements on Linux.
8+
On macOS and Windows, runs commands directly without modification.
9+
10+
For Linux with LocalMachine scope:
11+
- Validates sudo access is available before execution
12+
- Prepends sudo to commands if not running as root
13+
- Provides clear error messages if sudo access fails
14+
15+
.PARAMETER Command
16+
The command to execute.
17+
18+
.PARAMETER Scope
19+
The installation scope. Affects whether sudo is needed.
20+
21+
.PARAMETER Description
22+
A description of what the command does, for error messages.
23+
24+
.OUTPUTS
25+
[PSCustomObject] With properties:
26+
- Success: [bool] Whether the command succeeded
27+
- ExitCode: [int] The exit code
28+
- Output: [string] Combined stdout/stderr
29+
- Command: [string] The actual command that was run
30+
31+
.EXAMPLE
32+
Invoke-SudoCommand -Command 'apt-get update' -Scope LocalMachine -Description 'updating package lists'
33+
#>
34+
[CmdletBinding()]
35+
param(
36+
[Parameter(Mandatory)]
37+
[string]$Command,
38+
39+
[Parameter()]
40+
[ValidateSet('CurrentUser', 'LocalMachine')]
41+
[string]$Scope = 'CurrentUser',
42+
43+
[Parameter()]
44+
[string]$Description = 'executing command'
45+
)
46+
47+
$os = Get-OperatingSystem
48+
$needsSudo = Test-SudoRequired -Scope $Scope
49+
$actualCommand = $Command
50+
51+
# On Linux with LocalMachine scope, we need to handle sudo
52+
if ($needsSudo) {
53+
Write-PSFMessage -Level Verbose -Message "Sudo required for: $Description"
54+
55+
# First, validate that sudo access is available
56+
# Use sudo -n (non-interactive) to check if we have passwordless sudo
57+
# or if sudo credentials are cached
58+
$sudoCheck = & bash -c 'sudo -n true 2>/dev/null; echo $?' 2>$null
59+
$hasSudoAccess = ($sudoCheck -eq '0')
60+
61+
if (-not $hasSudoAccess) {
62+
# Try to prompt for sudo password by running sudo -v
63+
# This will cache credentials for subsequent commands
64+
Write-PSFMessage -Level Host -Message "Elevated privileges required for $Description. You may be prompted for your password."
65+
66+
# Run sudo -v to prompt for password and cache credentials
67+
# This needs to be interactive, so we use Start-Process with UseShellExecute
68+
$validateResult = & bash -c 'sudo -v 2>&1; echo "EXIT:$?"'
69+
$exitLine = $validateResult | Select-Object -Last 1
70+
$sudoValidated = $exitLine -eq 'EXIT:0'
71+
72+
if (-not $sudoValidated) {
73+
return [PSCustomObject]@{
74+
Success = $false
75+
ExitCode = 1
76+
Output = "Failed to obtain sudo privileges. Please ensure you have sudo access and try again."
77+
Command = $Command
78+
}
79+
}
80+
}
81+
82+
# Prepend sudo to the command if it doesn't already have it
83+
if ($Command -notmatch '^\s*sudo\s') {
84+
$actualCommand = "sudo $Command"
85+
}
86+
}
87+
88+
Write-PSFMessage -Level Verbose -Message "Executing: $actualCommand"
89+
90+
try {
91+
if ($os -eq 'Windows') {
92+
# On Windows, use cmd.exe
93+
$psi = New-Object System.Diagnostics.ProcessStartInfo
94+
$psi.FileName = 'cmd.exe'
95+
$psi.Arguments = "/c `"$actualCommand`""
96+
$psi.RedirectStandardOutput = $true
97+
$psi.RedirectStandardError = $true
98+
$psi.UseShellExecute = $false
99+
$psi.CreateNoWindow = $true
100+
} else {
101+
# On Linux/macOS, use bash
102+
$psi = New-Object System.Diagnostics.ProcessStartInfo
103+
$psi.FileName = '/bin/bash'
104+
$psi.Arguments = "-c `"$actualCommand`""
105+
$psi.RedirectStandardOutput = $true
106+
$psi.RedirectStandardError = $true
107+
$psi.UseShellExecute = $false
108+
$psi.CreateNoWindow = $true
109+
}
110+
111+
$process = New-Object System.Diagnostics.Process
112+
$process.StartInfo = $psi
113+
$process.Start() | Out-Null
114+
115+
$stdout = $process.StandardOutput.ReadToEnd()
116+
$stderr = $process.StandardError.ReadToEnd()
117+
$process.WaitForExit()
118+
119+
$output = @($stdout, $stderr) | Where-Object { $_ } | Join-String -Separator "`n"
120+
121+
return [PSCustomObject]@{
122+
Success = ($process.ExitCode -eq 0)
123+
ExitCode = $process.ExitCode
124+
Output = $output
125+
Command = $actualCommand
126+
}
127+
} catch {
128+
return [PSCustomObject]@{
129+
Success = $false
130+
ExitCode = -1
131+
Output = $_.Exception.Message
132+
Command = $actualCommand
133+
}
134+
}
135+
}

private/Test-SudoRequired.ps1

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
function Test-SudoRequired {
2+
<#
3+
.SYNOPSIS
4+
Determines if sudo is required for a command on the current platform.
5+
6+
.DESCRIPTION
7+
Checks whether the current user needs sudo/elevated privileges to run
8+
system-level commands. On Linux, checks if running as root. On macOS,
9+
most operations (Homebrew, npm, pipx) do NOT require sudo.
10+
11+
.PARAMETER Scope
12+
The installation scope. LocalMachine typically requires elevated privileges on Linux.
13+
14+
.OUTPUTS
15+
[bool] True if sudo is required, False otherwise.
16+
17+
.NOTES
18+
- Linux with LocalMachine scope: Requires sudo for apt-get and system-wide installations
19+
- macOS: Homebrew and most package managers do NOT require sudo
20+
- Windows: Not applicable (uses different elevation model)
21+
#>
22+
[CmdletBinding()]
23+
[OutputType([bool])]
24+
param(
25+
[Parameter()]
26+
[ValidateSet('CurrentUser', 'LocalMachine')]
27+
[string]$Scope = 'CurrentUser'
28+
)
29+
30+
$os = Get-OperatingSystem
31+
32+
# Windows doesn't use sudo
33+
if ($os -eq 'Windows') {
34+
return $false
35+
}
36+
37+
# macOS: Homebrew and modern package managers don't need sudo
38+
# They install to /usr/local or /opt/homebrew which are user-writable
39+
if ($os -eq 'MacOS') {
40+
return $false
41+
}
42+
43+
# Linux: Only LocalMachine scope needs sudo
44+
if ($os -eq 'Linux') {
45+
if ($Scope -eq 'CurrentUser') {
46+
return $false
47+
}
48+
49+
# LocalMachine scope - check if already root
50+
$userId = & id -u 2>$null
51+
if ($userId -eq '0') {
52+
# Already running as root
53+
return $false
54+
}
55+
56+
# Need sudo for LocalMachine on Linux
57+
return $true
58+
}
59+
60+
return $false
61+
}

public/Install-AITool.ps1

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -336,34 +336,22 @@ function Install-AITool {
336336
# Choose installation method based on Scope
337337
if ($Scope -eq 'LocalMachine') {
338338
Write-PSFMessage -Level Verbose -Message "Installing pipx system-wide (requires sudo)..."
339-
$pipxInstallCmd = 'sudo apt-get update && sudo apt-get install -y pipx && pipx ensurepath'
339+
$pipxInstallCmd = 'apt-get update && apt-get install -y pipx && pipx ensurepath'
340340
} else {
341341
Write-PSFMessage -Level Verbose -Message "Installing pipx for current user (no sudo required)..."
342342
$pipxInstallCmd = 'python3 -m pip install --user pipx && python3 -m pipx ensurepath'
343343
}
344344

345345
try {
346-
$psi = New-Object System.Diagnostics.ProcessStartInfo
347-
$psi.FileName = '/bin/bash'
348-
$psi.Arguments = "-c `"$pipxInstallCmd`""
349-
$psi.RedirectStandardOutput = $true
350-
$psi.RedirectStandardError = $true
351-
$psi.UseShellExecute = $false
352-
$psi.CreateNoWindow = $true
353-
354-
$process = New-Object System.Diagnostics.Process
355-
$process.StartInfo = $psi
356-
$process.Start() | Out-Null
357-
$stdout = $process.StandardOutput.ReadToEnd()
358-
$stderr = $process.StandardError.ReadToEnd()
359-
$process.WaitForExit()
346+
# Use Invoke-SudoCommand which handles sudo validation and prompting
347+
$result = Invoke-SudoCommand -Command $pipxInstallCmd -Scope $Scope -Description 'installing pipx'
360348

361-
if ($process.ExitCode -ne 0) {
349+
if (-not $result.Success) {
362350
Write-Progress -Activity "Installing $currentToolName" -Completed
363351
if ($Scope -eq 'LocalMachine') {
364-
Stop-PSFFunction -Message "pipx installation failed. Please install pipx manually: sudo apt-get install pipx" -EnableException $true
352+
Stop-PSFFunction -Message "pipx installation failed. Please install pipx manually: sudo apt-get install pipx`n$($result.Output)" -EnableException $true
365353
} else {
366-
Stop-PSFFunction -Message "pipx installation failed. Please install pipx manually: python3 -m pip install --user pipx" -EnableException $true
354+
Stop-PSFFunction -Message "pipx installation failed. Please install pipx manually: python3 -m pip install --user pipx`n$($result.Output)" -EnableException $true
367355
}
368356
return
369357
}
@@ -419,13 +407,29 @@ function Install-AITool {
419407
# Choose installation method based on Scope
420408
if ($Scope -eq 'LocalMachine') {
421409
Write-PSFMessage -Level Verbose -Message "Installing Node.js system-wide (requires sudo)..."
422-
$nodeInstallCmd = 'curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - && sudo apt-get install -y nodejs'
410+
# Note: The nodesource script itself needs sudo, so we handle this specially
411+
# First download the setup script, then run it with sudo, then install nodejs
412+
$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'
423413
} else {
424414
Write-PSFMessage -Level Verbose -Message "Installing Node.js for current user using nvm (no sudo required)..."
425415
$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'
426416
}
427417

428418
try {
419+
if ($Scope -eq 'LocalMachine') {
420+
# For LocalMachine, validate sudo access first
421+
if (Test-SudoRequired -Scope $Scope) {
422+
Write-PSFMessage -Level Host -Message "Elevated privileges required for installing Node.js. You may be prompted for your password."
423+
$sudoCheck = & bash -c 'sudo -v 2>&1; echo "EXIT:$?"'
424+
$exitLine = $sudoCheck | Select-Object -Last 1
425+
if ($exitLine -ne 'EXIT:0') {
426+
Write-Progress -Activity "Installing $currentToolName" -Completed
427+
Stop-PSFFunction -Message "Failed to obtain sudo privileges. Please ensure you have sudo access and try again." -EnableException $true
428+
return
429+
}
430+
}
431+
}
432+
429433
$psi = New-Object System.Diagnostics.ProcessStartInfo
430434
$psi.FileName = '/bin/bash'
431435
$psi.Arguments = "-c `"$nodeInstallCmd`""
@@ -444,9 +448,9 @@ function Install-AITool {
444448
if ($process.ExitCode -ne 0) {
445449
Write-Progress -Activity "Installing $currentToolName" -Completed
446450
if ($Scope -eq 'LocalMachine') {
447-
Stop-PSFFunction -Message "Node.js installation failed. Please install Node.js manually: sudo apt-get install nodejs" -EnableException $true
451+
Stop-PSFFunction -Message "Node.js installation failed. Please install Node.js manually: sudo apt-get install nodejs`n$stdout`n$stderr" -EnableException $true
448452
} else {
449-
Stop-PSFFunction -Message "Node.js installation failed. Please install Node.js manually using nvm or from https://nodejs.org/" -EnableException $true
453+
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
450454
}
451455
return
452456
}

0 commit comments

Comments
 (0)