Install-Jax.ps1
|
[CmdletBinding()] param ( [string] $InstallRoot = (Join-Path $HOME '.jax/module'), [switch] $SkipProfile, [string] $ProfilePath = $PROFILE.CurrentUserCurrentHost, [string[]] $Shell ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $validShells = @('powershell', 'zsh', 'bash') $normalizedShells = @( foreach ($shellValue in @($Shell)) { foreach ($shellName in @([string]$shellValue -split ',')) { $normalizedShell = $shellName.Trim().ToLowerInvariant() if ([string]::IsNullOrWhiteSpace($normalizedShell)) { continue } if ($normalizedShell -notin $validShells) { throw "Unsupported Jax shell '$normalizedShell'. Expected one of: $($validShells -join ', ')." } $normalizedShell } } ) $Shell = @($normalizedShells | Select-Object -Unique) $sourceRoot = [IO.Path]::GetFullPath($PSScriptRoot) $installRootResolved = [IO.Path]::GetFullPath($InstallRoot) $pathRoot = [IO.Path]::GetFullPath([IO.Path]::GetPathRoot($installRootResolved)) $homeRoot = [IO.Path]::GetFullPath($HOME) $jaxHome = [IO.Path]::GetFullPath((Join-Path $HOME '.jax')) $protectedRoots = @($pathRoot, $homeRoot, $jaxHome) if ($protectedRoots -contains $installRootResolved) { throw "Refusing unsafe install root: $installRootResolved" } $sourcePrefix = $sourceRoot.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar $installPrefix = $installRootResolved.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar if ($sourcePrefix.StartsWith($installPrefix, [StringComparison]::OrdinalIgnoreCase)) { throw "Refusing to install over a parent of the source checkout: $installRootResolved" } if ($sourceRoot -eq $installRootResolved) { throw 'Run Install-Jax.ps1 from a source checkout, not from the installed module directory.' } if (-not $SkipProfile) { if ([string]::IsNullOrWhiteSpace($ProfilePath)) { throw 'PowerShell profile path cannot be empty unless -SkipProfile is supplied.' } $ProfilePath = [IO.Path]::GetFullPath($ProfilePath) if (Test-Path -LiteralPath $ProfilePath -PathType Container) { throw "PowerShell profile path points to a directory: $ProfilePath" } } $manifestPath = Join-Path $sourceRoot 'distribution-manifest.psd1' $manifest = Import-PowerShellDataFile -LiteralPath $manifestPath if ([string]::IsNullOrWhiteSpace([string]$manifest.Version)) { throw "Distribution manifest '$manifestPath' has no version." } $requiredYamlVersion = [version]'0.4.12' $yamlModule = Get-Module -ListAvailable -Name powershell-yaml -ErrorAction SilentlyContinue | Where-Object { $_.Version -eq $requiredYamlVersion } | Select-Object -First 1 if ($null -eq $yamlModule) { $installModuleCommand = Get-Command -Name Install-Module -ErrorAction SilentlyContinue if ($null -eq $installModuleCommand) { throw @" Jax requires powershell-yaml $requiredYamlVersion, but Install-Module is unavailable. Install that dependency from a trusted PowerShell repository, then run this installer again. "@ } Write-Host "Installing dependency powershell-yaml $requiredYamlVersion for CurrentUser..." -ForegroundColor Cyan Install-Module -Name powershell-yaml -Repository PSGallery -Scope CurrentUser ` -RequiredVersion $requiredYamlVersion -Force -AllowClobber -ErrorAction Stop } $sourceCommit = 'unknown' if (Get-Command -Name git -ErrorAction SilentlyContinue) { try { $resolvedCommit = & git -C $sourceRoot rev-parse HEAD 2>$null if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($resolvedCommit)) { $sourceCommit = $resolvedCommit.Trim() } } catch { } } $installParent = Split-Path -Parent $installRootResolved if (-not (Test-Path -LiteralPath $installParent -PathType Container)) { New-Item -ItemType Directory -Path $installParent -Force | Out-Null } if (Test-Path -LiteralPath $installRootResolved) { $requiredInstallMarkers = @('Jax.psd1', 'Jax.psm1', 'INSTALLATION.json') $missingInstallMarkers = @($requiredInstallMarkers | Where-Object { -not (Test-Path -LiteralPath (Join-Path $installRootResolved $_) -PathType Leaf) }) if ($missingInstallMarkers.Count -gt 0) { throw "Refusing to replace a directory that is not an installed Jax module: $installRootResolved" } } $stagingRoot = Join-Path $installParent ('.jax-staging-{0}' -f [guid]::NewGuid().ToString('N')) $backupRoot = Join-Path $installParent ('.jax-backup-{0}' -f [guid]::NewGuid().ToString('N')) function Test-JaxInstallPathWithin { param( [Parameter(Mandatory)] [string] $Parent, [Parameter(Mandatory)] [string] $Candidate ) $relative = [IO.Path]::GetRelativePath( [IO.Path]::GetFullPath($Parent), [IO.Path]::GetFullPath($Candidate) ) if ($relative -eq '.') { return $true } if ([IO.Path]::IsPathRooted($relative) -or $relative -eq '..') { return $false } return -not $relative.StartsWith("..$([IO.Path]::DirectorySeparatorChar)", [StringComparison]::Ordinal) } function Assert-JaxInstallSourceSafe { param([Parameter(Mandatory)] [string] $Path) $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop $items = @($item) if ($item.PSIsContainer) { $items += @(Get-ChildItem -LiteralPath $item.FullName -Force -Recurse -ErrorAction Stop) } $link = $items | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -or -not [string]::IsNullOrWhiteSpace([string]$_.LinkType) } | Select-Object -First 1 if ($null -ne $link) { $relative = [IO.Path]::GetRelativePath($sourceRoot, $link.FullName) throw "Distribution manifest entry contains a symbolic link or reparse point: $relative" } } function Copy-JaxManifestEntry { param ( [Parameter(Mandatory = $true)] [string] $Source, [Parameter(Mandatory = $true)] [string] $Destination ) $sourceResolved = [IO.Path]::GetFullPath($Source) $destinationResolved = [IO.Path]::GetFullPath($Destination) if (-not (Test-JaxInstallPathWithin -Parent $sourceRoot -Candidate $sourceResolved)) { throw "Distribution manifest entry escapes the Jax source root: $Source" } if (-not (Test-JaxInstallPathWithin -Parent $stagingRoot -Candidate $destinationResolved)) { throw "Distribution manifest destination escapes the install staging root: $Destination" } if (-not (Test-Path -LiteralPath $sourceResolved)) { throw "Distribution manifest entry is missing: $sourceResolved" } Assert-JaxInstallSourceSafe -Path $sourceResolved $destinationParent = Split-Path -Parent $destinationResolved if (-not (Test-Path -LiteralPath $destinationParent -PathType Container)) { New-Item -ItemType Directory -Path $destinationParent -Force | Out-Null } Copy-Item -LiteralPath $sourceResolved -Destination $destinationResolved -Recurse -Force } try { New-Item -ItemType Directory -Path $stagingRoot -Force | Out-Null foreach ($file in @($manifest.Files)) { Copy-JaxManifestEntry -Source (Join-Path $sourceRoot $file) -Destination (Join-Path $stagingRoot $file) } foreach ($directory in @($manifest.Directories)) { Copy-JaxManifestEntry -Source (Join-Path $sourceRoot $directory) -Destination (Join-Path $stagingRoot $directory) } foreach ($set in @($manifest.DirectorySets)) { $targetRoot = Join-Path $stagingRoot ([string]$set.Target) New-Item -ItemType Directory -Path $targetRoot -Force | Out-Null foreach ($entry in @($set.Entries)) { $relative = Join-Path ([string]$set.Target) ([string]$entry) Copy-JaxManifestEntry -Source (Join-Path $sourceRoot $relative) -Destination (Join-Path $stagingRoot $relative) } } # A published package carries Origin = 'Gallery' from Build-JaxPackage, and # installing it keeps that. A source checkout has no record, so the install is # Manual. Get-JaxInstallInfo reads this to decide whether `jax update` is # replacing a hand-built copy (and should say so) or refreshing a released one. $sourceOrigin = 'Manual' $sourceRecordPath = Join-Path $sourceRoot 'INSTALLATION.json' if (Test-Path -LiteralPath $sourceRecordPath -PathType Leaf) { try { $sourceRecord = Get-Content -LiteralPath $sourceRecordPath -Raw | ConvertFrom-Json $recordedOrigin = [string]$sourceRecord.Origin if ($recordedOrigin -in @('Gallery', 'Manual')) { $sourceOrigin = $recordedOrigin } } catch { # An unreadable record just means we fall back to Manual. } } [ordered]@{ Origin = $sourceOrigin Version = [string]$manifest.Version SourceCommit = $sourceCommit InstalledAtUtc = [DateTime]::UtcNow.ToString('O') } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $stagingRoot 'INSTALLATION.json') -Encoding utf8 Test-ModuleManifest -Path (Join-Path $stagingRoot 'Jax.psd1') -ErrorAction Stop | Out-Null if (Test-Path -LiteralPath $installRootResolved) { Move-Item -LiteralPath $installRootResolved -Destination $backupRoot } Move-Item -LiteralPath $stagingRoot -Destination $installRootResolved if (Test-Path -LiteralPath $backupRoot) { Remove-Item -LiteralPath $backupRoot -Recurse -Force } } catch { if (Test-Path -LiteralPath $stagingRoot) { Remove-Item -LiteralPath $stagingRoot -Recurse -Force } if ((Test-Path -LiteralPath $backupRoot) -and -not (Test-Path -LiteralPath $installRootResolved)) { Move-Item -LiteralPath $backupRoot -Destination $installRootResolved } throw } $installedManifest = Join-Path $installRootResolved 'Jax.psd1' Get-Module Jax | Remove-Module -Force -ErrorAction SilentlyContinue Import-Module $installedManifest -Global -Force -DisableNameChecking -ErrorAction Stop if (-not $SkipProfile) { $integrationParameters = @{ ModulePath = $installedManifest PowerShellProfilePath = $ProfilePath } if ($null -ne $Shell -and $Shell.Count -gt 0) { $integrationParameters.Shell = $Shell } Install-JaxShellIntegration @integrationParameters } Write-Host ("Jax {0} installed to {1}" -f $manifest.Version, $installRootResolved) -ForegroundColor Green $escapedInstalledManifest = $installedManifest.Replace("'", "''") $activateCommand = "Import-Module '$escapedInstalledManifest' -Global -Force -DisableNameChecking" if ($SkipProfile) { Write-Host "Import with: $activateCommand" -ForegroundColor DarkGray } else { Write-Host 'Jax is active now when this script was invoked directly in the current PowerShell.' -ForegroundColor DarkGray Write-Host 'New PowerShell and detected or platform-default zsh/bash sessions will load this source installation automatically.' -ForegroundColor DarkGray Write-Host 'If this installer was launched through a child `pwsh -File` process, activate its parent with:' -ForegroundColor DarkGray Write-Host " $activateCommand" -ForegroundColor DarkGray } |