From 025f8b8a0fa51e11141d70092c19d54d5c2de8cd Mon Sep 17 00:00:00 2001 From: Tesfaye Yeabsira Mersha <122813658+yeabwang@users.noreply.github.com> Date: Sat, 21 Feb 2026 05:52:34 +0000 Subject: [PATCH 1/6] installer fix --- install.cmd | 24 ++++++++++++++-- install.ps1 | 81 +++++++++++++++++++---------------------------------- 2 files changed, 50 insertions(+), 55 deletions(-) diff --git a/install.cmd b/install.cmd index 27d836e..8947650 100644 --- a/install.cmd +++ b/install.cmd @@ -1,4 +1,22 @@ @echo off -setlocal -powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0install.ps1" %* -exit /b %errorlevel% +setlocal EnableExtensions + +set "SCRIPT_DIR=%~dp0" +set "LOCAL_SCRIPT=%SCRIPT_DIR%install.ps1" + +if exist "%LOCAL_SCRIPT%" ( + powershell -NoProfile -ExecutionPolicy Bypass -File "%LOCAL_SCRIPT%" %* + exit /b %errorlevel% +) + +set "REMOTE_SCRIPT=%TEMP%\pichu-install-%RANDOM%%RANDOM%.ps1" +curl -fsSL "https://raw.githubusercontent.com/yeabwang/pichu/main/install.ps1" -o "%REMOTE_SCRIPT%" +if errorlevel 1 ( + echo Failed to download install.ps1 from GitHub. + exit /b 1 +) + +powershell -NoProfile -ExecutionPolicy Bypass -File "%REMOTE_SCRIPT%" %* +set "EXIT_CODE=%errorlevel%" +del /q "%REMOTE_SCRIPT%" >nul 2>&1 +exit /b %EXIT_CODE% diff --git a/install.ps1 b/install.ps1 index 925d430..23f882b 100644 --- a/install.ps1 +++ b/install.ps1 @@ -79,12 +79,15 @@ function Get-CommandPath([string]$Name) { if ($null -eq $command) { return $null } - if ($command.Source) { + if ($command.PSObject.Properties.Match("Source").Count -gt 0 -and $command.Source) { return $command.Source } - if ($command.Path) { + if ($command.PSObject.Properties.Match("Path").Count -gt 0 -and $command.Path) { return $command.Path } + if ($command.PSObject.Properties.Match("Definition").Count -gt 0 -and (Test-Path -LiteralPath $command.Definition -PathType Leaf)) { + return $command.Definition + } return $null } @@ -108,72 +111,46 @@ function Refresh-ProcessPath { $env:Path = (@($userPath, $machinePath, $env:Path) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ";" } -function Install-UvFromPackageManager { - if (Get-Command winget -ErrorAction SilentlyContinue) { - Write-Info "Installing uv with winget..." - & winget install --id astral-sh.uv --exact --silent --accept-package-agreements --accept-source-agreements - if ($LASTEXITCODE -eq 0 -and $?) { - Refresh-ProcessPath - if (Get-Command uv -ErrorAction SilentlyContinue) { - return $true - } - } - Write-Warn "winget install for uv did not succeed." +function Select-Installer { + if (Get-Command uv -ErrorAction SilentlyContinue) { + return "uv" } - - if (Get-Command scoop -ErrorAction SilentlyContinue) { - Write-Info "Installing uv with scoop..." - & scoop install uv - if ($LASTEXITCODE -eq 0 -and $?) { - Refresh-ProcessPath - if (Get-Command uv -ErrorAction SilentlyContinue) { - return $true - } - } - Write-Warn "scoop install for uv did not succeed." + if (Get-Command pipx -ErrorAction SilentlyContinue) { + return "pipx" } - - if (Get-Command choco -ErrorAction SilentlyContinue) { - Write-Info "Installing uv with chocolatey..." - & choco install uv -y - if ($LASTEXITCODE -eq 0 -and $?) { - Refresh-ProcessPath - if (Get-Command uv -ErrorAction SilentlyContinue) { - return $true - } - } - Write-Warn "chocolatey install for uv did not succeed." - } - - return $false -} - -function Select-Installer { - if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { - Write-Info "uv not found. Attempting package-manager install (winget, scoop, chocolatey)..." - if (-not (Install-UvFromPackageManager)) { - Fail "Failed to install uv automatically. Install uv manually from https://docs.astral.sh/uv/getting-started/installation/ and rerun." - } + if (Get-Command pip3 -ErrorAction SilentlyContinue) { + return "pip3" } - - if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { - Fail "uv installation completed but uv is not available in PATH. Restart PowerShell and try again." + if (Get-Command pip -ErrorAction SilentlyContinue) { + return "pip" } - return "uv" + Fail "No package installer found. Install uv manually from https://docs.astral.sh/uv/getting-started/installation/." } function Install-Pichu([string]$Installer) { Write-Info "Installing pichu with $Installer..." switch ($Installer) { "uv" { - & uv tool install --reinstall "pichu @ git+https://github.com/$repo.git" - return + & uv tool install "pichu @ git+https://github.com/$repo.git" + } + "pipx" { + & pipx install "git+https://github.com/$repo.git" + } + "pip3" { + & pip3 install --user "git+https://github.com/$repo.git" + } + "pip" { + & pip install --user "git+https://github.com/$repo.git" } default { Fail "Unsupported installer '$Installer'." } } + + if ($LASTEXITCODE -ne 0) { + Fail "Failed to install pichu with $Installer." + } } function Normalize-PathSegment([string]$PathValue) { From e4128bb977bd6c0ea0d8509b925cf74c6c304ec8 Mon Sep 17 00:00:00 2001 From: Tesfaye Yeabsira Mersha <122813658+yeabwang@users.noreply.github.com> Date: Sat, 21 Feb 2026 14:10:29 +0800 Subject: [PATCH 2/6] Update install.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- install.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install.ps1 b/install.ps1 index 23f882b..f203841 100644 --- a/install.ps1 +++ b/install.ps1 @@ -132,7 +132,7 @@ function Install-Pichu([string]$Installer) { Write-Info "Installing pichu with $Installer..." switch ($Installer) { "uv" { - & uv tool install "pichu @ git+https://github.com/$repo.git" + & uv tool install --force "pichu @ git+https://github.com/$repo.git" } "pipx" { & pipx install "git+https://github.com/$repo.git" From 0e7669d225dba81bea11bcc1a4d177b598e0a32c Mon Sep 17 00:00:00 2001 From: Tesfaye Yeabsira Mersha <122813658+yeabwang@users.noreply.github.com> Date: Sat, 21 Feb 2026 07:48:36 +0000 Subject: [PATCH 3/6] security fix --- install.cmd | 63 +++++-- install.ps1 | 491 +++++++++++++++++++++++++++------------------------- install.sh | 338 +++++++++++++++++++++--------------- 3 files changed, 506 insertions(+), 386 deletions(-) diff --git a/install.cmd b/install.cmd index 8947650..973f608 100644 --- a/install.cmd +++ b/install.cmd @@ -1,22 +1,63 @@ @echo off -setlocal EnableExtensions +:: +:: pichu installer bootstrap for Windows (CMD). +:: Delegates to install.ps1, either from a local copy or downloaded from GitHub. +:: +:: Configure via environment variables before running this script: +:: PICHU_ALIAS, PICHU_NO_MODIFY_PATH, PICHU_INSTALL_DIR, PICHU_VERSION +:: Or invoke install.ps1 directly from PowerShell to pass parameters. +:: +setlocal EnableExtensions DisableDelayedExpansion -set "SCRIPT_DIR=%~dp0" -set "LOCAL_SCRIPT=%SCRIPT_DIR%install.ps1" +:: ── UTF-8 code page ───────────────────────────────────────────────────────── +chcp 65001 > nul +:: ── Prefer a local copy of install.ps1 ────────────────────────────────────── +set "LOCAL_SCRIPT=%~dp0install.ps1" if exist "%LOCAL_SCRIPT%" ( - powershell -NoProfile -ExecutionPolicy Bypass -File "%LOCAL_SCRIPT%" %* + powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass ^ + -File "%LOCAL_SCRIPT%" exit /b %errorlevel% ) -set "REMOTE_SCRIPT=%TEMP%\pichu-install-%RANDOM%%RANDOM%.ps1" -curl -fsSL "https://raw.githubusercontent.com/yeabwang/pichu/main/install.ps1" -o "%REMOTE_SCRIPT%" +:: ── Download to a temporary file ────────────────────────────────────────────── +for /f "usebackq delims=" %%T in ( + `powershell.exe -NoProfile -NonInteractive -Command "[System.IO.Path]::GetTempFileName()"` +) do set "TEMP_BASE=%%T" + +if not defined TEMP_BASE ( + echo error: Failed to allocate a temporary file. >&2 + exit /b 1 +) + +del /f /q "%TEMP_BASE%" >nul 2>&1 +set "REMOTE_SCRIPT=%TEMP_BASE%.ps1" + +:: ── Download ───────────────────────────────────────────────────────────────── +curl.exe --proto "=https" --tlsv1.2 -fsSL --max-filesize 1048576 ^ + "https://raw.githubusercontent.com/yeabwang/pichu/main/install.ps1" ^ + -o "%REMOTE_SCRIPT%" + if errorlevel 1 ( - echo Failed to download install.ps1 from GitHub. + echo error: Failed to download install.ps1. >&2 + del /f /q "%REMOTE_SCRIPT%" > nul 2>&1 exit /b 1 ) -powershell -NoProfile -ExecutionPolicy Bypass -File "%REMOTE_SCRIPT%" %* -set "EXIT_CODE=%errorlevel%" -del /q "%REMOTE_SCRIPT%" >nul 2>&1 -exit /b %EXIT_CODE% +:: ── Verify downloaded file is non-empty ──────────────────────────────────────── +for %%A in ("%REMOTE_SCRIPT%") do if %%~zA EQU 0 ( + echo error: Downloaded install.ps1 is empty. >&2 + del /f /q "%REMOTE_SCRIPT%" > nul 2>&1 + exit /b 1 +) + +:: ── Execute ─────────────────────────────────────────────────────────────────── +powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass ^ + -File "%REMOTE_SCRIPT%" + +set "PS_EXIT=%errorlevel%" + +:: ── Cleanup ─────────────────────────────────────────────────────────────────── +del /f /q "%REMOTE_SCRIPT%" > nul 2>&1 + +exit /b %PS_EXIT% \ No newline at end of file diff --git a/install.ps1 b/install.ps1 index f203841..1961458 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,248 +1,257 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + pichu installer for Windows (PowerShell). + +.DESCRIPTION + Installs pichu using uv, pipx, pip3, or pip. + Optionally adds the install directory to the user PATH and configures + a PowerShell alias in the user's profile. + +.PARAMETER Alias + Override the alias name (default: pichu). + +.PARAMETER NoModifyPath + Do not modify the user PATH environment variable. + +.PARAMETER InstallDir + Override the install/bin directory hint. + +.PARAMETER Version + Git ref (tag, branch, or commit) to install (default: main). + +.PARAMETER Help + Show usage information. + +.EXAMPLE + .\install.ps1 +#> + +[CmdletBinding()] param( - [string]$Alias = $(if ([string]::IsNullOrWhiteSpace($env:PICHU_ALIAS)) { "pichu" } else { $env:PICHU_ALIAS }), - [switch]$NoModifyPath, - [string]$InstallDir = $(if ($env:PICHU_INSTALL_DIR) { $env:PICHU_INSTALL_DIR } else { "$env:USERPROFILE\.local\bin" }), - [switch]$Help + [string] $Alias = $(if ($env:PICHU_ALIAS) { $env:PICHU_ALIAS } else { 'pichu' }), + [switch] $NoModifyPath, + [string] $InstallDir = $(if ($env:PICHU_INSTALL_DIR) { $env:PICHU_INSTALL_DIR } else { "$env:USERPROFILE\.local\bin" }), + [string] $Version = $(if ($env:PICHU_VERSION) { $env:PICHU_VERSION } else { 'main' }), + [switch] $Help ) Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" +$ErrorActionPreference = 'Stop' +$ConfirmPreference = 'None' -if ($env:PICHU_NO_MODIFY_PATH -eq "1") { - $NoModifyPath = $true -} +# ── Enforce TLS 1.2+ ───────────────────────────────────────────────────────── +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -$repo = "yeabwang/pichu" -$pathTargetDir = $InstallDir -$profileFile = $null +# ── Honour env override for NoModifyPath ───────────────────────────────────── +if ($env:PICHU_NO_MODIFY_PATH -eq '1') { $NoModifyPath = $true } -$pathModified = $false +# ── Module-level state ──────────────────────────────────────────────────────── +$repo = 'yeabwang/pichu' +$pathTargetDir = $InstallDir # may be updated after install locates the binary +$profileFile = $null + +$pathModified = $false $pathAlreadyPresent = $false -$pathSkipped = $false -$pathError = $false +$pathSkipped = $false +$pathError = $false -$aliasAdded = $false +$aliasAdded = $false $aliasAlreadyPresent = $false -$aliasConflict = $false -$aliasError = $false +$aliasConflict = $false +$aliasError = $false + +# ── Helpers ─────────────────────────────────────────────────────────────────── +function Write-Info([string]$Message) { Write-Host "==> $Message" -ForegroundColor Cyan } +function Write-Warn([string]$Message) { Write-Warning $Message } +function Fail([string]$Message) { throw $Message } function Show-Usage { @( - "pichu installer", - "", - "Options:", - " -Alias Override alias name (default: pichu)", - " -NoModifyPath Do not modify user PATH", - " -InstallDir Override install/bin directory hint", - " -Help Show this help", - "", - "Environment:", - " PICHU_INSTALL_DIR Override install/bin directory hint", - " PICHU_ALIAS Alias name (default: pichu)", - " PICHU_NO_MODIFY_PATH=1 Skip PATH modification" + 'pichu installer', + '', + 'Options:', + ' -Alias Override alias name (default: pichu)', + ' -NoModifyPath Do not modify user PATH', + ' -InstallDir Override install/bin directory hint', + ' -Version Git ref to install (default: main)', + ' -Help Show this help', + '', + 'Environment:', + ' PICHU_INSTALL_DIR Override install/bin directory hint', + ' PICHU_ALIAS Alias name (default: pichu)', + ' PICHU_NO_MODIFY_PATH=1 Skip PATH modification', + ' PICHU_VERSION Git ref to install (default: main)', + '', + 'Security note:', + ' Download to disk before executing — never pipe remote scripts directly', + ' into PowerShell. Example:', + ' $tmp = New-TemporaryFile', + ' Invoke-WebRequest -Uri -OutFile $tmp.FullName -UseBasicParsing', + ' & $tmp.FullName', + ' Remove-Item $tmp.FullName -Force' ) | ForEach-Object { Write-Output $_ } } -function Write-Info([string]$Message) { - Write-Host "==> $Message" -ForegroundColor Cyan -} - -function Write-Warn([string]$Message) { - Write-Warning $Message -} - -function Fail([string]$Message) { - throw $Message -} - +# ── Alias validation ────────────────────────────────────────────────────────── function Test-AliasName([string]$AliasName) { - if ([string]::IsNullOrWhiteSpace($AliasName)) { - return + if ([string]::IsNullOrWhiteSpace($AliasName)) { return } + + if ($AliasName.StartsWith('-')) { + Fail "Invalid alias '$AliasName': must not start with '-'." } - if ($AliasName -notmatch "^[A-Za-z](?:[A-Za-z0-9_-]{0,30}[A-Za-z0-9_])?$") { - Fail "Invalid alias '$AliasName'. Use 1-32 chars: letters, numbers, _ or -, starting with a letter and not ending with '-'." + # 1–32 chars, starts with letter, ends with letter/digit, + # interior may include letters, digits, underscores, or hyphens. + if ($AliasName -notmatch '^[A-Za-z](?:[A-Za-z0-9_-]{0,30}[A-Za-z0-9_])?$') { + Fail "Invalid alias '$AliasName'. Use 1–32 chars: letters, numbers, _ or -, starting with a letter and not ending with '-'." } $reserved = @( - "alias", "break", "catch", "class", "continue", "data", "do", "dynamicparam", "else", "elseif", "end", - "exit", "filter", "finally", "for", "foreach", "from", "function", "hidden", "if", "in", "param", - "process", "return", "switch", "throw", "trap", "try", "until", "using", "var", "while" + 'alias','break','catch','class','continue','data','do','dynamicparam', + 'else','elseif','end','exit','filter','finally','for','foreach','from', + 'function','hidden','if','in','param','process','return','switch', + 'throw','trap','try','until','using','var','while' ) if ($reserved -contains $AliasName.ToLowerInvariant()) { Fail "Alias '$AliasName' is reserved by PowerShell." } } +# ── Binary location ─────────────────────────────────────────────────────────── function Get-CommandPath([string]$Name) { - $command = Get-Command $Name -ErrorAction SilentlyContinue - if ($null -eq $command) { - return $null - } - if ($command.PSObject.Properties.Match("Source").Count -gt 0 -and $command.Source) { - return $command.Source - } - if ($command.PSObject.Properties.Match("Path").Count -gt 0 -and $command.Path) { - return $command.Path - } - if ($command.PSObject.Properties.Match("Definition").Count -gt 0 -and (Test-Path -LiteralPath $command.Definition -PathType Leaf)) { - return $command.Definition + $cmd = Get-Command $Name -ErrorAction SilentlyContinue + if ($null -eq $cmd) { return $null } + + foreach ($prop in @('Source','Path','Definition')) { + $propObj = $cmd.PSObject.Properties[$prop] + $val = if ($propObj) { $propObj.Value } else { $null } + if ($val -and (Test-Path -LiteralPath $val -PathType Leaf)) { + return $val + } } return $null } +# ── Python version check ─────────────────────────────────────────────────────── function Get-PythonVersion { - $python = Get-Command python -ErrorAction SilentlyContinue - if ($python) { - return (& python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')") - } - - $py = Get-Command py -ErrorAction SilentlyContinue - if ($py) { - return (& py -3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')") + foreach ($exe in @('python','py')) { + $cmd = Get-Command $exe -ErrorAction SilentlyContinue + if (-not $cmd) { continue } + $pyArgs = if ($exe -eq 'py') { @('-3', '-c') } else { @('-c') } + $ver = & $exe @pyArgs "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>$null + if ($ver) { return $ver.Trim() } } - return $null } -function Refresh-ProcessPath { - $userPath = [Environment]::GetEnvironmentVariable("Path", "User") - $machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") - $env:Path = (@($userPath, $machinePath, $env:Path) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ";" -} - -function Select-Installer { - if (Get-Command uv -ErrorAction SilentlyContinue) { - return "uv" - } - if (Get-Command pipx -ErrorAction SilentlyContinue) { - return "pipx" - } - if (Get-Command pip3 -ErrorAction SilentlyContinue) { - return "pip3" - } - if (Get-Command pip -ErrorAction SilentlyContinue) { - return "pip" - } - - Fail "No package installer found. Install uv manually from https://docs.astral.sh/uv/getting-started/installation/." -} - -function Install-Pichu([string]$Installer) { - Write-Info "Installing pichu with $Installer..." - switch ($Installer) { - "uv" { - & uv tool install --force "pichu @ git+https://github.com/$repo.git" - } - "pipx" { - & pipx install "git+https://github.com/$repo.git" - } - "pip3" { - & pip3 install --user "git+https://github.com/$repo.git" - } - "pip" { - & pip install --user "git+https://github.com/$repo.git" - } - default { - Fail "Unsupported installer '$Installer'." - } - } - - if ($LASTEXITCODE -ne 0) { - Fail "Failed to install pichu with $Installer." - } -} - -function Normalize-PathSegment([string]$PathValue) { - return $PathValue.Trim().TrimEnd("\").ToLowerInvariant() +# ── PATH helpers ─────────────────────────────────────────────────────────────── +function Get-NormalisedSegment([string]$Seg) { + $Seg.Trim().TrimEnd('\').ToLowerInvariant() } function Split-PathSegments([string]$PathValue) { - if ([string]::IsNullOrWhiteSpace($PathValue)) { - return @() - } - return ($PathValue -split ";") | ForEach-Object { $_.Trim() } | Where-Object { $_ } + if ([string]::IsNullOrWhiteSpace($PathValue)) { return @() } + return $PathValue -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ } } function Test-PathContains([string]$PathValue, [string]$Candidate) { - $target = Normalize-PathSegment $Candidate - foreach ($segment in (Split-PathSegments $PathValue)) { - if ((Normalize-PathSegment $segment) -eq $target) { - return $true - } + $target = Get-NormalisedSegment $Candidate + foreach ($seg in (Split-PathSegments $PathValue)) { + if ((Get-NormalisedSegment $seg) -eq $target) { return $true } } return $false } -function Ensure-PathEntry([string]$Dir, [bool]$Skip) { - if ($Skip) { - $script:pathSkipped = $true - return - } +function Set-PathEntry([string]$Dir, [bool]$Skip) { + if ($Skip) { $script:pathSkipped = $true; return } - $userPath = [Environment]::GetEnvironmentVariable("Path", "User") - if (Test-PathContains $userPath $Dir) { - $script:pathAlreadyPresent = $true - return - } + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + if (Test-PathContains $userPath $Dir) { $script:pathAlreadyPresent = $true; return } try { $newPath = if ([string]::IsNullOrWhiteSpace($userPath)) { $Dir } else { "$Dir;$userPath" } - [Environment]::SetEnvironmentVariable("Path", $newPath, "User") + [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') + # Only update the process PATH after the registry write succeeded. $env:Path = "$Dir;$env:Path" $script:pathModified = $true } catch { $script:pathError = $true - Write-Warn "Failed to update user PATH automatically: $($_.Exception.Message)" + Write-Warn "Failed to update user PATH: $($_.Exception.Message)" } } -function Ensure-ProfileFile([string]$ProfilePath) { - $profileDirectory = Split-Path -Parent $ProfilePath - if (-not (Test-Path -LiteralPath $profileDirectory)) { - New-Item -ItemType Directory -Path $profileDirectory -Force | Out-Null +# ── Profile-file safety ──────────────────────────────────────────────────────── +function Assert-ProfileFileSafe([string]$ProfilePath) { + $parentDir = Split-Path -Parent $ProfilePath + + if (-not (Test-Path -LiteralPath $parentDir)) { + New-Item -ItemType Directory -LiteralPath $parentDir -Force | Out-Null + + try { + $acl = Get-Acl -LiteralPath $parentDir + $acl.SetAccessRuleProtection($true, $false) + $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( + [System.Security.Principal.WindowsIdentity]::GetCurrent().Name, + 'FullControl', + 'ContainerInherit,ObjectInherit', + 'None', + 'Allow' + ) + $acl.AddAccessRule($rule) + Set-Acl -LiteralPath $parentDir -AclObject $acl -ErrorAction SilentlyContinue + } catch { + # Non-fatal; warn and continue. + Write-Warn "Could not harden directory permissions on $parentDir" + } } + if (-not (Test-Path -LiteralPath $ProfilePath)) { - New-Item -ItemType File -Path $ProfilePath -Force | Out-Null + New-Item -ItemType File -LiteralPath $ProfilePath -Force | Out-Null + return } -} -function Ensure-AliasEntry([string]$AliasName) { - if ([string]::IsNullOrWhiteSpace($AliasName)) { - return + $fileInfo = Get-Item -LiteralPath $ProfilePath -Force -ErrorAction SilentlyContinue + if ($fileInfo -and ($fileInfo.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + Fail "Profile file '$ProfilePath' is a symbolic link or junction — refusing to write." } +} + +# ── Alias entry management ──────────────────────────────────────────────────── +function Set-AliasEntry([string]$AliasName) { + if ([string]::IsNullOrWhiteSpace($AliasName)) { return } $existingAlias = Get-Alias -Name $AliasName -ErrorAction SilentlyContinue if ($existingAlias) { - if ($existingAlias.Definition -eq "pichu") { - $script:aliasAlreadyPresent = $true - return + if ($existingAlias.Definition -ieq 'pichu') { + $script:aliasAlreadyPresent = $true; return } - $script:aliasConflict = $true - return + $script:aliasConflict = $true; return } $script:profileFile = $PROFILE.CurrentUserAllHosts - try { - Ensure-ProfileFile $script:profileFile - } + + try { Assert-ProfileFileSafe $script:profileFile } catch { $script:aliasError = $true - Write-Warn "Failed to prepare profile file '$script:profileFile': $($_.Exception.Message)" + Write-Warn "Cannot write profile '$script:profileFile': $($_.Exception.Message)" return } - $content = Get-Content -Path $script:profileFile -Raw + # Read the file with an explicit encoding to avoid BOM/codepage issues. + $content = Get-Content -LiteralPath $script:profileFile -Raw -Encoding UTF8 -ErrorAction SilentlyContinue + if ($null -eq $content) { $content = '' } + $escaped = [Regex]::Escape($AliasName) + $exactPatterns = @( "(?im)^\s*Set-Alias\s+-Name\s+$escaped\s+-Value\s+pichu(?:\s|$)", "(?im)^\s*Set-Alias\s+$escaped\s+pichu(?:\s|$)", "(?im)^\s*New-Alias\s+-Name\s+$escaped\s+-Value\s+pichu(?:\s|$)" ) - - foreach ($pattern in $exactPatterns) { - if ($content -match $pattern) { + foreach ($p in $exactPatterns) { + if ($content -match $p) { $script:aliasAlreadyPresent = $true Set-Alias -Name $AliasName -Value pichu -ErrorAction SilentlyContinue return @@ -255,110 +264,128 @@ function Ensure-AliasEntry([string]$AliasName) { "(?im)^\s*New-Alias\s+-Name\s+$escaped\b", "(?im)^\s*function\s+$escaped\b" ) - - foreach ($pattern in $conflictPatterns) { - if ($content -match $pattern) { - $script:aliasConflict = $true - return - } + foreach ($p in $conflictPatterns) { + if ($content -match $p) { $script:aliasConflict = $true; return } } - $block = @' + # Append the alias block with explicit UTF-8 encoding (no BOM). + $safeAlias = $AliasName -replace "'", "''" + $block = @" + # >>> pichu alias >>> -Set-Alias -Name {0} -Value pichu +Set-Alias -Name '$safeAlias' -Value pichu # <<< pichu alias <<< -'@ -f $AliasName - Add-Content -Path $script:profileFile -Value "`n$block`n" + +"@ + + $fi = Get-Item -LiteralPath $script:profileFile -Force -ErrorAction SilentlyContinue + if ($fi -and ($fi.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + $script:aliasError = $true + Write-Warn "Profile '$script:profileFile' is a symlink — refusing to write." + return + } + Add-Content -LiteralPath $script:profileFile -Value $block -Encoding UTF8 -NoNewline:$false + Set-Alias -Name $AliasName -Value pichu -ErrorAction SilentlyContinue $script:aliasAdded = $true } -function Write-Summary { - Write-Output "" - - if ($pathModified) { - Write-Output "Path environment variable modified; restart your shell to use the new value." - } - elseif ($pathAlreadyPresent) { - Write-Output "Path already contains install directory." - } - elseif ($pathSkipped) { - Write-Output "Path modification skipped (--no-modify-path)." - } - else { - Write-Output "Path was not modified automatically." +# ── Package installation ─────────────────────────────────────────────────────── +function Select-Installer { + foreach ($tool in @('uv','pipx','pip3','pip')) { + if (Get-Command $tool -ErrorAction SilentlyContinue) { return $tool } } + Fail 'No package installer found. Install uv from https://docs.astral.sh/uv/getting-started/installation/' +} - if (-not [string]::IsNullOrWhiteSpace($Alias)) { - if ($aliasAdded) { - Write-Output "Command line alias added: `"$Alias`"" - } - elseif ($aliasAlreadyPresent) { - Write-Output "Alias `"$Alias`" already configured." - } - elseif ($aliasConflict) { - Write-Output "Alias `"$Alias`" already exists with a different definition." - } - else { - Write-Output "Alias `"$Alias`" was not configured automatically." - } - } +function Install-Pichu([string]$Installer) { + Write-Info "Installing pichu with $Installer…" + $gitUrl = "git+https://github.com/$repo.git@$Version" + $pkgRef = "pichu @ $gitUrl" - if ((Get-Command pichu -ErrorAction SilentlyContinue) -or $pathModified) { - Write-Output "Successfully installed. You can run `"pichu`" from anywhere." + switch ($Installer) { + 'uv' { & uv tool install --force "$pkgRef" } + 'pipx' { & pipx install "$gitUrl" } + 'pip3' { & pip3 install --user "$gitUrl" } + 'pip' { & pip install --user "$gitUrl" } + default { Fail "Unsupported installer '$Installer'." } } - else { - Write-Output "Successfully installed, but 'pichu' is not currently on PATH in this shell." + + if ($LASTEXITCODE -ne 0) { Fail "Failed to install pichu with $Installer (exit $LASTEXITCODE)." } +} + +# ── Summary ──────────────────────────────────────────────────────────────────── +function Write-Summary { + Write-Output '' + + if ($pathModified) { Write-Output "PATH updated — restart your shell to pick up the change." } + elseif ($pathAlreadyPresent) { Write-Output "PATH already contains the install directory." } + elseif ($pathSkipped) { Write-Output "PATH modification skipped (-NoModifyPath)." } + else { Write-Output "PATH was not modified automatically." } + + if (-not [string]::IsNullOrWhiteSpace($Alias)) { + if ($aliasAdded) { Write-Output "Alias '$Alias' added to $script:profileFile." } + elseif ($aliasAlreadyPresent) { Write-Output "Alias '$Alias' already configured." } + elseif ($aliasConflict) { Write-Output "Alias '$Alias' already exists with a different definition — not modified." } + else { Write-Output "Alias '$Alias' was not configured automatically." } } - if (-not (Get-Command pichu -ErrorAction SilentlyContinue)) { - Write-Output "" - Write-Output "Manual PATH command (PowerShell):" + if ((Get-CommandPath 'pichu') -or $pathModified) { + Write-Output "Successfully installed. Run 'pichu' from anywhere." + } else { + Write-Output "Installed, but 'pichu' is not yet on PATH in this shell." + Write-Output "Manual PATH command:" Write-Output " `$env:Path = `"$pathTargetDir;`$env:Path`"" Write-Output "User PATH target:" Write-Output " $pathTargetDir" } } -if ($Help) { - Show-Usage - exit 0 -} +# ════════════════════════════════════════════════════════════════════════════ +if ($Help) { Show-Usage; exit 0 } Test-AliasName $Alias -$versionText = Get-PythonVersion -if ([string]::IsNullOrWhiteSpace($versionText)) { - Fail "Python 3.11+ is required. Install it first." +# Validate InstallDir is a rooted (absolute) path +if (-not [System.IO.Path]::IsPathRooted($InstallDir)) { + Fail "InstallDir must be an absolute path: '$InstallDir'" } -$versionParts = $versionText.Trim().Split(".") -if ($versionParts.Length -lt 2) { - Fail "Unable to parse Python version '$versionText'." +if ($Version -notmatch '^[A-Za-z0-9._/-]+$') { + Fail "Version contains invalid characters: '$Version'" } -$major = [int]$versionParts[0] -$minor = [int]$versionParts[1] -if ($major -lt 3 -or ($major -eq 3 -and $minor -lt 11)) { - Fail "Python 3.11+ is required (found $versionText)." +$verText = Get-PythonVersion +if ([string]::IsNullOrWhiteSpace($verText)) { + Fail 'Python 3.11+ is required. Install it first.' } -$installer = Select-Installer -if ([string]::IsNullOrWhiteSpace($installer)) { - Fail "No package installer found. Install uv manually from https://docs.astral.sh/uv/getting-started/installation/." +if ($verText -notmatch '^\d+\.\d+$') { + Fail "Unexpected Python version output: '$verText'" +} +$verParts = $verText.Split('.') + +$major = [int]$verParts[0] +$minor = [int]$verParts[1] +if ($major -lt 3 -or ($major -eq 3 -and $minor -lt 11)) { + Fail "Python 3.11+ required (found $verText)." } +$installer = Select-Installer Install-Pichu $installer -$pichuPath = Get-CommandPath "pichu" -if ($pichuPath) { - $pathTargetDir = Split-Path -Parent $pichuPath +$expectedBin = Join-Path $InstallDir 'pichu' +$expectedExe = Join-Path $InstallDir 'pichu.exe' +if ((Test-Path -LiteralPath $expectedBin -PathType Leaf) -or + (Test-Path -LiteralPath $expectedExe -PathType Leaf)) { + $pathTargetDir = $InstallDir +} else { + $pichuPath = Get-CommandPath 'pichu' + if ($pichuPath) { $pathTargetDir = Split-Path -Parent $pichuPath } } -Ensure-PathEntry -Dir $pathTargetDir -Skip:$NoModifyPath -Ensure-AliasEntry $Alias +Set-PathEntry -Dir $pathTargetDir -Skip:($NoModifyPath.IsPresent) +Set-AliasEntry $Alias Write-Summary -if ($pathError -or $aliasError) { - exit 1 -} +if ($pathError -or $aliasError) { exit 1 } \ No newline at end of file diff --git a/install.sh b/install.sh index e538381..087bb43 100644 --- a/install.sh +++ b/install.sh @@ -1,12 +1,29 @@ #!/usr/bin/env bash # pichu installer — https://github.com/yeabwang/pichu -# Usage: curl -fsSL https://raw.githubusercontent.com/yeabwang/pichu/main/install.sh | bash set -euo pipefail +set -o noclobber +umask 077 +export LC_ALL=C + +# Validate HOME before any path computations +case "${HOME:-}" in + /*) ;; + *) printf '\033[1;31merror:\033[0m HOME must be set to an absolute path.\n' >&2; exit 1 ;; +esac -REPO="yeabwang/pichu" -INSTALL_DIR="${PICHU_INSTALL_DIR:-$HOME/.local/bin}" +# ── Constants ──────────────────────────────────────────────────────────────── +readonly REPO="yeabwang/pichu" +readonly PICHU_VERSION="${PICHU_VERSION:-main}" +case "$PICHU_VERSION" in + *[!A-Za-z0-9._/-]*) printf '\033[1;31merror:\033[0m PICHU_VERSION contains invalid characters.\n' >&2; exit 1 ;; +esac +readonly INSTALL_DIR="${PICHU_INSTALL_DIR:-$HOME/.local/bin}" +case "$INSTALL_DIR" in + /*) ;; + *) printf '\033[1;31merror:\033[0m PICHU_INSTALL_DIR must be an absolute path.\n' >&2; exit 1 ;; +esac NO_MODIFY_PATH="${PICHU_NO_MODIFY_PATH:-0}" -REQUESTED_ALIAS="${PICHU_ALIAS:-pichu}" +REQUESTED_ALIAS="${PICHU_ALIAS:-pichu}" # may be overridden by --alias flag PATH_TARGET_DIR="$INSTALL_DIR" COMMAND_TARGET="pichu" @@ -23,9 +40,19 @@ ALIAS_ALREADY_PRESENT=0 ALIAS_CONFLICT=0 ALIAS_ERROR=0 -info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } -warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; } -error() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } +_PICHU_TMPFILES=() +_cleanup() { + local _f + for _f in "${_PICHU_TMPFILES[@]+"${_PICHU_TMPFILES[@]}"}"; do + rm -f "$_f" 2>/dev/null || true + done +} +trap _cleanup EXIT + +# ── Helpers ────────────────────────────────────────────────────────────────── +info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; } +error() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } usage() { cat <<'EOF' @@ -40,51 +67,54 @@ Environment: PICHU_INSTALL_DIR Override install/bin directory hint (default: ~/.local/bin) PICHU_ALIAS Alias name (default: pichu) PICHU_NO_MODIFY_PATH=1 Skip PATH modification -EOF -} + PICHU_VERSION Git ref to install (default: main) -contains_path_segment() { - case ":$PATH:" in - *":$1:"*) return 0 ;; - *) return 1 ;; - esac +Security note: + Download to a file first rather than piping to bash: + curl --proto '=https' --tlsv1.2 -fsSL -o install.sh && bash install.sh +EOF } +# ── Alias validation ───────────────────────────────────────────────────────── validate_alias() { - if [ -z "$REQUESTED_ALIAS" ]; then - return - fi + [ -z "$REQUESTED_ALIAS" ] && return 0 + + case "$REQUESTED_ALIAS" in + -*) error "Invalid alias '$REQUESTED_ALIAS': must not start with '-'." ;; + esac - if ! [[ "$REQUESTED_ALIAS" =~ ^[A-Za-z]([A-Za-z0-9_-]{0,30}[A-Za-z0-9_])?$ ]]; then - error "Invalid alias '$REQUESTED_ALIAS'. Use 1-32 chars: letters, numbers, _ or -, starting with a letter and not ending with '-'." + if ! printf '%s' "$REQUESTED_ALIAS" | \ + grep -Eq '^[A-Za-z][A-Za-z0-9_-]{0,30}[A-Za-z0-9_]$|^[A-Za-z]$'; then + error "Invalid alias '$REQUESTED_ALIAS'. Use 1–32 chars: letters, numbers, _ or -, starting with a letter, not ending with '-'." fi case "$REQUESTED_ALIAS" in - alias|bg|bind|break|builtin|cd|command|continue|eval|exec|exit|export|false|fc|fg|getopts|hash|help|history|jobs|kill|local|logout|printf|pwd|read|readonly|return|set|shift|source|test|times|trap|true|type|typeset|ulimit|umask|unalias|unset|wait|if|then|elif|else|fi|for|while|until|case|esac|select|do|done|in|function|time) - error "Alias '$REQUESTED_ALIAS' is reserved by the shell." + alias|bg|bind|break|builtin|cd|command|continue|eval|exec|exit|\ + export|false|fc|fg|getopts|hash|help|history|jobs|kill|local|\ + logout|printf|pwd|read|readonly|return|set|shift|source|test|\ + times|trap|true|type|typeset|ulimit|umask|unalias|unset|wait|\ + if|then|elif|else|fi|for|while|until|case|esac|select|do|done|\ + in|function|time) + error "Alias '$REQUESTED_ALIAS' is a reserved shell word." ;; esac } +regex_escape() { + printf '%s' "$1" | sed 's/[][\\.*^$+?{}()|-]/\\&/g' +} + +# ── Profile-file selection ─────────────────────────────────────────────────── select_profile_file() { - if [ -n "$PROFILE_FILE" ]; then - return 0 - fi + [ -n "$PROFILE_FILE" ] && return 0 - local shell_name + local shell_name xdg_config_home candidate shell_name="$(basename "${SHELL:-sh}")" - local xdg_config_home xdg_config_home="${XDG_CONFIG_HOME:-$HOME/.config}" case "$shell_name" in - fish) - SHELL_KIND="fish" - PROFILE_FILE="$HOME/.config/fish/config.fish" - ;; - zsh) - SHELL_KIND="zsh" - PROFILE_FILE="${ZDOTDIR:-$HOME}/.zshrc" - ;; + fish) SHELL_KIND="fish"; PROFILE_FILE="$HOME/.config/fish/config.fish" ;; + zsh) SHELL_KIND="zsh"; PROFILE_FILE="${ZDOTDIR:-$HOME}/.zshrc" ;; bash) SHELL_KIND="bash" if [ -f "$HOME/.bashrc" ] || [ ! -f "$HOME/.profile" ]; then @@ -95,42 +125,69 @@ select_profile_file() { PROFILE_FILE="$HOME/.profile" fi ;; - ash|sh) - SHELL_KIND="sh" - PROFILE_FILE="$HOME/.profile" - ;; + ash|sh) SHELL_KIND="sh"; PROFILE_FILE="$HOME/.profile" ;; *) SHELL_KIND="sh" - if [ -f "$xdg_config_home/bash/.bashrc" ]; then - PROFILE_FILE="$xdg_config_home/bash/.bashrc" - else - PROFILE_FILE="$HOME/.profile" - fi + candidate="$xdg_config_home/bash/.bashrc" + PROFILE_FILE="$([ -f "$candidate" ] && echo "$candidate" || echo "$HOME/.profile")" ;; esac - if [ -e "$PROFILE_FILE" ] && [ ! -w "$PROFILE_FILE" ]; then - warn "Profile is not writable: $PROFILE_FILE" + # ── Security: verify profile file is safe to write ─────────────────────── + local profile_dir + profile_dir="$(dirname "$PROFILE_FILE")" + if [ ! -d "$profile_dir" ]; then + mkdir -p -m 0700 "$profile_dir" || { warn "Cannot create $profile_dir"; return 1; } + fi + + # Refuse symlinks + if [ -L "$PROFILE_FILE" ]; then + warn "Profile path '$PROFILE_FILE' is a symlink — refusing to write to it." return 1 fi - mkdir -p "$(dirname "$PROFILE_FILE")" - touch "$PROFILE_FILE" + if [ ! -e "$PROFILE_FILE" ]; then + (set -o noclobber; > "$PROFILE_FILE") 2>/dev/null || { + warn "Cannot create profile '$PROFILE_FILE'"; return 1; + } + chmod 0600 "$PROFILE_FILE" + fi + + if [ ! -f "$PROFILE_FILE" ]; then + warn "Profile '$PROFILE_FILE' is not a regular file." + return 1 + fi + + if [ ! -w "$PROFILE_FILE" ]; then + warn "Profile '$PROFILE_FILE' is not writable." + return 1 + fi +} + +append_to_profile() { + if [ -L "$PROFILE_FILE" ]; then + warn "Refusing to write: '$PROFILE_FILE' is a symlink." + return 1 + fi + cat "$1" >> "$PROFILE_FILE" +} + +# ── PATH helpers ───────────────────────────────────────────────────────────── +contains_path_segment() { + case ":$PATH:" in *":$1:"*) return 0 ;; esac + return 1 } get_path_command() { - local escaped_path="${PATH_TARGET_DIR//\"/\\\"}" + local safe_dir + safe_dir="$(printf '%s' "$PATH_TARGET_DIR" | sed "s/'/'\\''/g")" if [ "$SHELL_KIND" = "fish" ]; then - printf 'fish_add_path "%s"' "$escaped_path" + printf "fish_add_path '%s'" "$safe_dir" else - printf 'export PATH="%s:$PATH"' "$escaped_path" + printf "export PATH='%s:\$PATH'" "$safe_dir" fi } -escape_single_quotes() { - printf "%s" "$1" | sed "s/'/'\\\\''/g" -} - ensure_path_entry() { if contains_path_segment "$PATH_TARGET_DIR"; then PATH_ALREADY_PRESENT=1 @@ -155,180 +212,177 @@ ensure_path_entry() { return fi + local tmp + tmp="$(mktemp "${PROFILE_FILE}.pichu.XXXXXXXX")" + _PICHU_TMPFILES+=("$tmp") { printf '\n' printf '# >>> pichu path >>>\n' printf '%s\n' "$path_cmd" printf '# <<< pichu path <<<\n' - } >>"$PROFILE_FILE" + } > "$tmp" + if ! append_to_profile "$tmp"; then PATH_ERROR=1; rm -f "$tmp"; return; fi + rm -f "$tmp" PATH_CHANGED=1 } +# ── Alias helpers ───────────────────────────────────────────────────────────── ensure_alias_entry() { - if [ -z "$REQUESTED_ALIAS" ]; then - return - fi + [ -z "$REQUESTED_ALIAS" ] && return if ! select_profile_file; then ALIAS_ERROR=1 return fi + local escaped_alias + escaped_alias="$(regex_escape "$REQUESTED_ALIAS")" + if [ "$SHELL_KIND" = "fish" ]; then - if grep -Fqs "# >>> pichu alias >>>" "$PROFILE_FILE" && grep -Fqs "function $REQUESTED_ALIAS" "$PROFILE_FILE"; then + if grep -Fqs "# >>> pichu alias >>>" "$PROFILE_FILE" && \ + grep -Eq "^[[:space:]]*function[[:space:]]+${escaped_alias}([[:space:]]|\$)" "$PROFILE_FILE"; then ALIAS_ALREADY_PRESENT=1 return fi - if grep -Eq "^[[:space:]]*function[[:space:]]+$REQUESTED_ALIAS([[:space:]]|\$)" "$PROFILE_FILE"; then + if grep -Eq "^[[:space:]]*function[[:space:]]+${escaped_alias}([[:space:]]|\$)" "$PROFILE_FILE"; then ALIAS_CONFLICT=1 return fi + + local tmp + tmp="$(mktemp "${PROFILE_FILE}.pichu.XXXXXXXX")" + _PICHU_TMPFILES+=("$tmp") { printf '\n' printf '# >>> pichu alias >>>\n' printf 'function %s\n' "$REQUESTED_ALIAS" - printf ' "%s" $argv\n' "$COMMAND_TARGET" + printf " '%s' \$argv\n" "$COMMAND_TARGET" printf 'end\n' printf '# <<< pichu alias <<<\n' - } >>"$PROFILE_FILE" + } > "$tmp" + if ! append_to_profile "$tmp"; then ALIAS_ERROR=1; rm -f "$tmp"; return; fi + rm -f "$tmp" ALIAS_ADDED=1 return fi - if grep -Eq "^[[:space:]]*alias[[:space:]]+$REQUESTED_ALIAS='([^']*/)?pichu'" "$PROFILE_FILE"; then + if grep -Eq "^[[:space:]]*alias[[:space:]]+${escaped_alias}='([^']*/)?pichu'" "$PROFILE_FILE"; then ALIAS_ALREADY_PRESENT=1 return fi - if grep -Eq "^[[:space:]]*alias[[:space:]]+$REQUESTED_ALIAS=" "$PROFILE_FILE"; then + # Conflict: alias name defined with a different value + if grep -Eq "^[[:space:]]*alias[[:space:]]+${escaped_alias}=" "$PROFILE_FILE"; then ALIAS_CONFLICT=1 return fi - local alias_target - alias_target="$(escape_single_quotes "$COMMAND_TARGET")" + local safe_target + safe_target="$(printf '%s' "$COMMAND_TARGET" | sed "s/'/'\\''/g")" + + local tmp + tmp="$(mktemp "${PROFILE_FILE}.pichu.XXXXXXXX")" + _PICHU_TMPFILES+=("$tmp") { printf '\n' printf '# >>> pichu alias >>>\n' - printf "alias %s='%s'\n" "$REQUESTED_ALIAS" "$alias_target" + printf "alias %s='%s'\n" "$REQUESTED_ALIAS" "$safe_target" printf '# <<< pichu alias <<<\n' - } >>"$PROFILE_FILE" + } > "$tmp" + if ! append_to_profile "$tmp"; then ALIAS_ERROR=1; rm -f "$tmp"; return; fi + rm -f "$tmp" ALIAS_ADDED=1 } +# ── Summary ────────────────────────────────────────────────────────────────── print_summary() { printf '\n' - if [ "$PATH_CHANGED" -eq 1 ]; then - printf 'Path environment variable modified; restart your shell to use the new value.\n' - elif [ "$PATH_ALREADY_PRESENT" -eq 1 ]; then - printf 'Path already contains install directory.\n' - elif [ "$PATH_SKIPPED" -eq 1 ]; then - printf 'Path modification skipped (--no-modify-path).\n' - else - printf 'Path was not modified automatically.\n' + if [ "$PATH_CHANGED" -eq 1 ]; then printf 'PATH modified in %s — restart your shell.\n' "$PROFILE_FILE" + elif [ "$PATH_ALREADY_PRESENT" -eq 1 ]; then printf 'PATH already contains install directory.\n' + elif [ "$PATH_SKIPPED" -eq 1 ]; then printf 'PATH modification skipped (--no-modify-path).\n' + else printf 'PATH was not modified automatically.\n' fi if [ -n "$REQUESTED_ALIAS" ]; then - if [ "$ALIAS_ADDED" -eq 1 ]; then - printf 'Command line alias added: "%s"\n' "$REQUESTED_ALIAS" - elif [ "$ALIAS_ALREADY_PRESENT" -eq 1 ]; then - printf 'Alias "%s" already configured.\n' "$REQUESTED_ALIAS" - elif [ "$ALIAS_CONFLICT" -eq 1 ]; then - printf 'Alias "%s" already exists with a different definition.\n' "$REQUESTED_ALIAS" - else - printf 'Alias "%s" was not configured automatically.\n' "$REQUESTED_ALIAS" + if [ "$ALIAS_ADDED" -eq 1 ]; then printf 'Alias "%s" added to %s.\n' "$REQUESTED_ALIAS" "$PROFILE_FILE" + elif [ "$ALIAS_ALREADY_PRESENT" -eq 1 ]; then printf 'Alias "%s" already configured.\n' "$REQUESTED_ALIAS" + elif [ "$ALIAS_CONFLICT" -eq 1 ]; then printf 'Alias "%s" already exists with a different definition — not modified.\n' "$REQUESTED_ALIAS" + else printf 'Alias "%s" was not configured automatically.\n' "$REQUESTED_ALIAS" fi fi if command -v pichu >/dev/null 2>&1 || [ "$PATH_CHANGED" -eq 1 ]; then - printf 'Successfully installed. You can run "pichu" from anywhere.\n' + printf 'Successfully installed. Run "pichu" from anywhere.\n' else - printf "Successfully installed, but 'pichu' is not currently on PATH in this shell.\n" - fi - - if ! command -v pichu >/dev/null 2>&1; then - if [ -z "$PROFILE_FILE" ]; then - select_profile_file || true - fi - printf '\n' - printf 'Manual PATH command:\n' + printf "Installed, but 'pichu' is not yet on PATH in this shell.\n" if [ "$SHELL_KIND" = "fish" ]; then - printf ' fish_add_path "%s"\n' "$PATH_TARGET_DIR" + printf ' Manual: fish_add_path "%s"\n' "$PATH_TARGET_DIR" else - printf ' export PATH="%s:$PATH"\n' "$PATH_TARGET_DIR" - fi - if [ -n "$PROFILE_FILE" ]; then - printf 'Profile file: %s\n' "$PROFILE_FILE" + printf ' Manual: export PATH="%s:$PATH"\n' "$PATH_TARGET_DIR" fi + [ -n "$PROFILE_FILE" ] && printf ' Profile: %s\n' "$PROFILE_FILE" fi } +# ── Argument parsing ───────────────────────────────────────────────────────── parse_args() { while [ "$#" -gt 0 ]; do case "$1" in --alias) shift - [ "$#" -gt 0 ] || error "Missing value for --alias" + [ "$#" -gt 0 ] || error "Missing value for --alias." REQUESTED_ALIAS="$1" ;; - --no-modify-path) - NO_MODIFY_PATH=1 - ;; - -h|--help) - usage - exit 0 - ;; - *) - error "Unknown option: $1" - ;; + --no-modify-path) NO_MODIFY_PATH=1 ;; + -h|--help) usage; exit 0 ;; + # Reject any unknown flag to avoid silent misuse + -*) error "Unknown option: $1" ;; + *) error "Unexpected argument: $1" ;; esac shift done } +# ════════════════════════════════════════════════════════════════════════════ parse_args "$@" validate_alias -# ── Check dependencies ────────────────────────────────────────── +# ── Python version check ───────────────────────────────────────────────────── command -v python3 >/dev/null 2>&1 || error "Python 3.11+ is required. Install it first." -PYTHON_VERSION=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')") -PYTHON_MAJOR=$(echo "$PYTHON_VERSION" | cut -d. -f1) -PYTHON_MINOR=$(echo "$PYTHON_VERSION" | cut -d. -f2) - -if [ "$PYTHON_MAJOR" -lt 3 ] || { [ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 11 ]; }; then - error "Python 3.11+ is required (found $PYTHON_VERSION)." +PYTHON_VERSION="$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")" +if ! printf '%s' "$PYTHON_VERSION" | grep -Eq '^[0-9]+\.[0-9]+$'; then + error "Unexpected Python version output: '$PYTHON_VERSION'" fi - -# ── Prefer uv, fall back to pip ───────────────────────────────── -if command -v uv >/dev/null 2>&1; then - INSTALLER="uv" -elif command -v pipx >/dev/null 2>&1; then - INSTALLER="pipx" -elif command -v pip3 >/dev/null 2>&1; then - INSTALLER="pip3" -elif command -v pip >/dev/null 2>&1; then - INSTALLER="pip" -else - error "No package installer found. Install uv (recommended): curl -LsSf https://astral.sh/uv/install.sh | sh" +PYTHON_MAJOR="${PYTHON_VERSION%%.*}" +PYTHON_MINOR="${PYTHON_VERSION##*.}" + +{ [ "$PYTHON_MAJOR" -gt 3 ] || { [ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -ge 11 ]; }; } || \ + error "Python 3.11+ required (found $PYTHON_VERSION)." + +# ── Select installer ───────────────────────────────────────────────────────── +if command -v uv >/dev/null 2>&1; then INSTALLER="uv" +elif command -v pipx >/dev/null 2>&1; then INSTALLER="pipx" +elif command -v pip3 >/dev/null 2>&1; then INSTALLER="pip3" +elif command -v pip >/dev/null 2>&1; then INSTALLER="pip" +else error "No package installer found. Install uv: https://docs.astral.sh/uv/getting-started/installation/" fi -info "Installing pichu with $INSTALLER..." +info "Installing pichu with $INSTALLER…" +# ── Install ────────────────────────────────────────────────────────────────── case "$INSTALLER" in - uv) - uv tool install "pichu @ git+https://github.com/${REPO}.git" - ;; - pipx) - pipx install "git+https://github.com/${REPO}.git" - ;; - pip3|pip) - "$INSTALLER" install --user "git+https://github.com/${REPO}.git" - ;; + uv) uv tool install --force "pichu @ git+https://github.com/${REPO}.git@${PICHU_VERSION}" ;; + pipx) pipx install "git+https://github.com/${REPO}.git@${PICHU_VERSION}" ;; + pip3|pip) "$INSTALLER" install --user "git+https://github.com/${REPO}.git@${PICHU_VERSION}" ;; esac -if command -v pichu >/dev/null 2>&1; then +# ── Locate installed binary ─────────────────────────────────────────────────── +if [ -x "$INSTALL_DIR/pichu" ]; then + COMMAND_TARGET="$INSTALL_DIR/pichu" + PATH_TARGET_DIR="$INSTALL_DIR" +elif command -v pichu >/dev/null 2>&1; then COMMAND_TARGET="$(command -v pichu)" PATH_TARGET_DIR="$(dirname "$COMMAND_TARGET")" else @@ -339,6 +393,4 @@ ensure_path_entry ensure_alias_entry print_summary -if [ "$PATH_ERROR" -eq 1 ] || [ "$ALIAS_ERROR" -eq 1 ]; then - exit 1 -fi +if [ "$PATH_ERROR" -eq 1 ] || [ "$ALIAS_ERROR" -eq 1 ]; then exit 1; fi \ No newline at end of file From da3e330b08a851700e9adce24f91d4dc95d97e12 Mon Sep 17 00:00:00 2001 From: Tesfaye Yeabsira Mersha <122813658+yeabwang@users.noreply.github.com> Date: Sat, 21 Feb 2026 07:51:06 +0000 Subject: [PATCH 4/6] doc update --- changelog.md | 5 +++++ pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index 50f409c..ac288c3 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.3] - 2026-02-21 + +### Security +- fixed one line installers security issues. + ## [0.1.2] - 2026-02-21 ### Added diff --git a/pyproject.toml b/pyproject.toml index 1af730a..dd3dbce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pichu" -version = "0.1.2" +version = "0.1.3" description = "⚡ pichu — code, compile, conquer" readme = "README.md" license = "Apache-2.0" From e6f3b6f81d13cd6c86efbdd72126d3d5c0e0f167 Mon Sep 17 00:00:00 2001 From: Tesfaye Yeabsira Mersha <122813658+yeabwang@users.noreply.github.com> Date: Sat, 21 Feb 2026 07:51:06 +0000 Subject: [PATCH 5/6] doc update --- install.cmd | 2 +- install.ps1 | 2 +- install.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/install.cmd b/install.cmd index 973f608..acc4b66 100644 --- a/install.cmd +++ b/install.cmd @@ -60,4 +60,4 @@ set "PS_EXIT=%errorlevel%" :: ── Cleanup ─────────────────────────────────────────────────────────────────── del /f /q "%REMOTE_SCRIPT%" > nul 2>&1 -exit /b %PS_EXIT% \ No newline at end of file +exit /b %PS_EXIT% diff --git a/install.ps1 b/install.ps1 index 1961458..9f27281 100644 --- a/install.ps1 +++ b/install.ps1 @@ -388,4 +388,4 @@ Set-PathEntry -Dir $pathTargetDir -Skip:($NoModifyPath.IsPresent) Set-AliasEntry $Alias Write-Summary -if ($pathError -or $aliasError) { exit 1 } \ No newline at end of file +if ($pathError -or $aliasError) { exit 1 } diff --git a/install.sh b/install.sh index 087bb43..9a5c07e 100644 --- a/install.sh +++ b/install.sh @@ -393,4 +393,4 @@ ensure_path_entry ensure_alias_entry print_summary -if [ "$PATH_ERROR" -eq 1 ] || [ "$ALIAS_ERROR" -eq 1 ]; then exit 1; fi \ No newline at end of file +if [ "$PATH_ERROR" -eq 1 ] || [ "$ALIAS_ERROR" -eq 1 ]; then exit 1; fi From cbf1e3d38dd555f8f176ad1fad23dc0c45e3717c Mon Sep 17 00:00:00 2001 From: Tesfaye Yeabsira Mersha <122813658+yeabwang@users.noreply.github.com> Date: Sat, 21 Feb 2026 08:02:19 +0000 Subject: [PATCH 6/6] update version --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index bebdd29..4f471a3 100644 --- a/uv.lock +++ b/uv.lock @@ -1606,7 +1606,7 @@ wheels = [ [[package]] name = "pichu" -version = "0.1.2" +version = "0.1.3" source = { editable = "." } dependencies = [ { name = "beautifulsoup4", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },