Jax.psm1
|
Set-StrictMode -Version Latest $script:JaxLauncher = Join-Path $PSScriptRoot 'jax.ps1' $script:JaxShortcutLauncher = Join-Path $PSScriptRoot 'jxs.ps1' function Get-JaxUpdateStatus { [CmdletBinding()] param( [TimeSpan] $CacheTtl = [TimeSpan]::FromHours(24), [ValidateRange(1, 30)] [int] $TimeoutSec = 2, [string] $CachePath = (Join-Path $HOME '.jax/update-check.json'), [switch] $Force ) $loadedModule = Get-Module -Name Jax | Where-Object { $_.ModuleBase -eq $PSScriptRoot } | Select-Object -First 1 $currentVersion = if ($null -ne $loadedModule) { [version]$loadedModule.Version } else { [version](Import-PowerShellDataFile -LiteralPath (Join-Path $PSScriptRoot 'Jax.psd1')).ModuleVersion } $cachePath = [IO.Path]::GetFullPath($CachePath) $cache = $null if (Test-Path -LiteralPath $cachePath -PathType Leaf) { try { $cache = Get-Content -LiteralPath $cachePath -Raw | ConvertFrom-Json -ErrorAction Stop } catch { $cache = $null } } $now = [DateTime]::UtcNow $checkedAt = [DateTime]::MinValue if ($null -ne $cache -and $cache.PSObject.Properties.Match('CheckedAtUtc').Count -gt 0) { [DateTime]::TryParse( [string]$cache.CheckedAtUtc, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AdjustToUniversal, [ref]$checkedAt ) | Out-Null } $cacheIsFresh = -not $Force -and $checkedAt -ne [DateTime]::MinValue -and ($now - $checkedAt.ToUniversalTime()) -lt $CacheTtl $latestVersionText = if ($null -ne $cache -and $cache.PSObject.Properties.Match('LatestVersion').Count -gt 0) { [string]$cache.LatestVersion } else { $null } $status = if ($null -ne $cache -and $cache.PSObject.Properties.Match('Status').Count -gt 0) { [string]$cache.Status } else { 'Unknown' } $source = 'cache' if (-not $cacheIsFresh) { $source = 'gallery' $status = 'Unavailable' try { $uri = "https://www.powershellgallery.com/api/v2/FindPackagesById()?id='Jax'&%24filter=IsLatestVersion" $response = Invoke-WebRequest -Uri $uri -TimeoutSec $TimeoutSec -ErrorAction Stop $match = [regex]::Match( [string]$response.Content, '<d:Version>(?<version>[^<]+)</d:Version>', [Text.RegularExpressions.RegexOptions]::CultureInvariant ) if (-not $match.Success) { throw 'The PowerShell Gallery response did not contain a Jax version.' } $latestVersionText = $match.Groups['version'].Value [version]$latestVersionText | Out-Null $status = 'Available' } catch { # An offline workstation is normal. Cache the failed attempt so # repository startup does not retry or pause on every shell. } $cacheRecord = [ordered]@{ CheckedAtUtc = $now.ToString('O') LatestVersion = $latestVersionText Status = $status } try { $cacheDirectory = Split-Path -Parent $cachePath New-Item -ItemType Directory -Path $cacheDirectory -Force -ErrorAction Stop | Out-Null $cacheRecord | ConvertTo-Json | Set-Content -LiteralPath $cachePath -Encoding utf8 -ErrorAction Stop } catch { # Update checks must never prevent Jax or repository startup. } $checkedAt = $now } $latestVersion = $null if (-not [string]::IsNullOrWhiteSpace($latestVersionText)) { try { $latestVersion = [version]$latestVersionText } catch { $latestVersion = $null } } [pscustomobject]@{ CurrentVersion = $currentVersion LatestVersion = $latestVersion UpdateAvailable = $null -ne $latestVersion -and $latestVersion -gt $currentVersion Status = $status CheckedAtUtc = if ($checkedAt -eq [DateTime]::MinValue) { $null } else { $checkedAt.ToUniversalTime() } Source = $source } } function Install-JaxShellIntegration { [CmdletBinding(SupportsShouldProcess)] param( [ValidateSet('powershell', 'zsh', 'bash')] [string[]] $Shell, [string] $InstallRoot = (Join-Path $HOME '.jax/shell'), # CurrentUserCurrentHost loads after the all-hosts profile, so later # profile commands cannot silently replace the jax/jx aliases. [string] $PowerShellProfilePath = $PROFILE.CurrentUserCurrentHost, [string] $ZshProfilePath = (Join-Path $HOME '.zshrc'), [string] $BashProfilePath = (Join-Path $HOME '.bashrc') ) if ($null -eq $Shell -or $Shell.Count -eq 0) { $Shell = @('powershell') $detectedShell = [IO.Path]::GetFileName([string]$env:SHELL) if (-not $IsWindows -and $detectedShell -in @('zsh', 'bash')) { $Shell += $detectedShell } } $unixShells = @($Shell | Where-Object { $_ -in @('zsh', 'bash') }) if ($IsWindows -and $unixShells.Count -gt 0) { throw 'Jax zsh/bash integration is supported on macOS and Linux. Use -Shell powershell on Windows.' } $sourceRoot = Join-Path $PSScriptRoot 'shell' $requiredFiles = @('Jax.ShellLauncher.ps1', 'Jax.ShellCompletion.ps1', 'jax.zsh', 'jax.bash') if ($unixShells.Count -gt 0) { foreach ($file in $requiredFiles) { if (-not (Test-Path -LiteralPath (Join-Path $sourceRoot $file) -PathType Leaf)) { throw "Jax shell integration file is missing: $file" } } $resolvedInstallRoot = [IO.Path]::GetFullPath($InstallRoot) if ($resolvedInstallRoot -in @( [IO.Path]::GetFullPath([IO.Path]::GetPathRoot($resolvedInstallRoot)), [IO.Path]::GetFullPath($HOME) )) { throw "Refusing unsafe shell integration root: $resolvedInstallRoot" } if ($PSCmdlet.ShouldProcess($resolvedInstallRoot, 'Install Jax zsh/bash integration')) { New-Item -ItemType Directory -Path $resolvedInstallRoot -Force | Out-Null foreach ($file in $requiredFiles) { Copy-Item -LiteralPath (Join-Path $sourceRoot $file) ` -Destination (Join-Path $resolvedInstallRoot $file) -Force } } } $profiles = @{ powershell = [IO.Path]::GetFullPath($PowerShellProfilePath) zsh = [IO.Path]::GetFullPath($ZshProfilePath) bash = [IO.Path]::GetFullPath($BashProfilePath) } foreach ($shellName in @($Shell | Sort-Object -Unique)) { $profilePath = $profiles[$shellName] $beginMarker = '# >>> jax CLI >>>' $endMarker = '# <<< jax CLI <<<' $block = if ($shellName -eq 'powershell') { @' # >>> jax CLI >>> $jaxProfileModulePath = Get-Module -ListAvailable Jax | Sort-Object Version -Descending | Select-Object -First 1 -ExpandProperty Path if ([string]::IsNullOrWhiteSpace($jaxProfileModulePath)) { $jaxSourceInstall = Join-Path $HOME '.jax/module/Jax.psd1' if (Test-Path -LiteralPath $jaxSourceInstall -PathType Leaf) { $jaxProfileModulePath = $jaxSourceInstall } } if (-not [string]::IsNullOrWhiteSpace($jaxProfileModulePath)) { Import-Module $jaxProfileModulePath -Global -Force -DisableNameChecking } # <<< jax CLI <<< '@ } else { $integrationPath = Join-Path $resolvedInstallRoot "jax.$shellName" $quotedIntegrationPath = "'" + $integrationPath.Replace("'", "'`"`"'") + "'" @" $beginMarker [ -f $quotedIntegrationPath ] && source $quotedIntegrationPath $endMarker "@ } $existing = if (Test-Path -LiteralPath $profilePath -PathType Leaf) { Get-Content -LiteralPath $profilePath -Raw } else { '' } $pattern = '(?ms)^' + [regex]::Escape($beginMarker) + '.*?^' + [regex]::Escape($endMarker) + '\r?\n?' $withoutOldBlock = [regex]::Replace($existing, $pattern, '').TrimEnd() $profileContent = if ([string]::IsNullOrWhiteSpace($withoutOldBlock)) { $block } else { "$withoutOldBlock`n`n$block" } if ($PSCmdlet.ShouldProcess($profilePath, "Register Jax $shellName integration")) { $profileDirectory = Split-Path -Parent $profilePath New-Item -ItemType Directory -Path $profileDirectory -Force | Out-Null Set-Content -LiteralPath $profilePath -Value $profileContent -Encoding utf8 } Write-Host "Jax $shellName integration registered in $profilePath" -ForegroundColor Green } Write-Host 'Open a new shell, or dot-source the updated profile now.' -ForegroundColor DarkGray } function Invoke-Jax { & $script:JaxLauncher @args } function Invoke-JaxShortcut { & $script:JaxLauncher -Shortcut @args } function Register-JaxCompletion { $completionModule = Join-Path $PSScriptRoot 'Jax.Autocomplete.psm1' # Completion registrations must live in the caller's global session state. # Importing as a nested module works only accidentally in sessions where # Jax completion was already registered. Import-Module $completionModule -Global -Force -DisableNameChecking -ErrorAction Stop } # Point the interactive aliases at the scripts so PowerShell can see their # static and dynamic parameters. A function wrapper hides parameters such as # -env/-e from TabExpansion2 and makes it fall back to filesystem completion. Set-Alias -Name jax -Value $script:JaxLauncher # Point the short alias at `jax` instead of directly at the script. PowerShell # then resolves completion through the canonical command name as well as # forwarding execution to the same launcher. Set-Alias -Name jx -Value jax Set-Alias -Name jxs -Value $script:JaxShortcutLauncher Export-ModuleMember -Function Get-JaxUpdateStatus, Install-JaxShellIntegration, Invoke-Jax, Invoke-JaxShortcut, Register-JaxCompletion -Alias jax, jx, jxs # A Gallery installation should be ready after PowerShell auto-loads the # module; it must not require a profile-only registration step. Register-JaxCompletion |