PSubShell.ps1

#Requires -PSEdition Core

<#PSScriptInfo
 
.VERSION 0.3.0
 
.GUID dbd31207-825d-4cdc-8e52-7c575e0ca5d9
 
.AUTHOR William E. Kempf
 
.COMPANYNAME
 
.COPYRIGHT Copyright (c) 2023 William E. Kempf. All rights reserved.
 
.TAGS InvokeBuild shell dependencies
 
.LICENSEURI https://github.com/wekempf/PSubShell/blob/main/LICENSE
 
.PROJECTURI https://github.com/wekempf/PSubShell/
 
.ICONURI
 
.EXTERNALMODULEDEPENDENCIES
 
.REQUIREDSCRIPTS
 
.EXTERNALSCRIPTDEPENDENCIES
 
.RELEASENOTES
 
 
.PRIVATEDATA
 
#>
 



<#

.DESCRIPTION
Creates a sub shell configured to use locally installed scripts, modules and packages.

.SYNOPSIS
Creates a sub shell configured to use locally installed scripts, modules and packages.
#>
 
[CmdletBinding(DefaultParameterSetName = 'EnterShell')]
Param(
    # Executes the specified commands (and any parameters) as though they were
    # typed at the PowerShell command prompt, and then exits, unless the -NoExit
    # parameter is specified.
    [Parameter(ParameterSetName = 'EnterShell', Position = 0)]
    [string]$Command,

    # A hash table of parameters to pass to the command specified by the -Command
    # parameter. This allows for parameter splatting.
    [Parameter(ParameterSetName = 'EnterShell')]
    [hashtable]$Parameters = @{},

    # Does not load the PowerShell profiles.
    [Parameter(ParameterSetName = 'EnterShell')]
    [switch]$NoProfile,

    # Does not exit the shell after running startup commands.
    [Parameter(ParameterSetName = 'EnterShell')]
    [switch]$NoExit,

    # Hides the banner text at startup of interactive sessions.
    [Parameter(ParameterSetName = 'EnterShell')]
    [switch]$NoLogo,

    # Initializes a directory for use with PSubShell.
    [Parameter(ParameterSetName = 'Initialize', Mandatory)]
    [switch]$Initialize,

    # Includes the PSubShell.ps1 script in the initialized directory.
    [Parameter(ParameterSetName = 'Initialize')]
    [switch]$Isolated,

    # Includes an InvokeBuild build.ps1 script in the initialized directory.
    [Parameter(ParameterSetName = 'Initialize')]
    [switch]$InvokeBuild,

    # Applies the configured PSubShell settings to the current shell. This is
    # done automatically when entering the subshell, but can be used to apply
    # changes to the configuration without entering the subshell.
    [Parameter(ParameterSetName = 'Apply', Mandatory)]
    [switch]$Apply,

    # Installs the specified PSResource in the subshell.
    [Parameter(ParameterSetName = 'InstallResource', Mandatory)]
    [string]$InstallResource,

    # The version of the PSResource to install. A range can be specified.
    [Parameter(ParameterSetName = 'InstallResource')]
    [string]$Version,

    # Allow prerelease versions of the PSResource to be installed.
    [Parameter(ParameterSetName = 'InstallResource')]
    [switch]$Prerelease,

    # The repository to use when installing the PSResource.
    [Parameter(ParameterSetName = 'InstallResource')]
    [string]$Repository,

    # Removes the specified PSResource from the subshell.
    [Parameter(ParameterSetName = 'RemoveResource')]
    [string]$RemoveResource,

    # Updates the lockfile with the latest versions of all installed PSResources.
    [Parameter(ParameterSetName = 'Update', Mandatory)]
    [switch]$Update,

    # Adds the specified path to the PATH environment variable of the subshell.
    # The path can be relative to the current directory, or fully qualified, though
    # it's not recommended to use fully qualified paths if the subshell is to be
    # distributed, including committed to version control systems.
    [Parameter(ParameterSetName = 'AddPath', Mandatory)]
    [string[]]$AddPath,

    # Adds the specified path to the PSModulePath environment variable of the
    # subshell. The path can be relative to the current directory, or fully
    # qualified, though it's not recommended to use fully qualified paths if the
    # subshell is to be distributed, including committed to version control
    # systems.
    [Parameter(ParameterSetName = 'AddModulePath', Mandatory)]
    [string[]]$AddModulePath,

    # Adds the specified variable to the subshell. The variable can be a
    # PowerShell variable, or an environment variable. If an environment variable
    # is specified, it must be prefixed with 'env:'.
    [Parameter(ParameterSetName = 'AddVariable')]
    [string]$AddVariable,

    # The value of the variable to add.
    [Parameter(ParameterSetName = 'AddVariable', Position = 1)]
    [string]$Value,

    [Parameter(ParameterSetName = 'DefaultRepository', Mandatory)]
    [string]$DefaultRepository
)

for ($path = Get-Location; $path; $path = Split-Path $path) {
    if ($Initialize -or (Test-Path (Join-Path $path '.psubshell.json'))) {
        $PSubShell = @{
            Path = $path
            ConfigFile = Join-Path $path '.psubshell.json'
            LockFile = Join-Path $path '.psubshell.lock.json'
        }
        $PSubShell.Config = (Get-Content $PSubShell.ConfigFile -ErrorAction SilentlyContinue |
                ConvertFrom-Json -AsHashtable) ?? @{ Resources = @{ } }
        $PSubShell.Locks = (Get-Content $PSubShell.LockFile -ErrorAction SilentlyContinue |
                ConvertFrom-Json -AsHashtable) ?? @{ }
        break
    }
}

if (-not $PSubShell) {
    Write-Error 'No PSubShell initialized.'
    return
}

switch ($PSCmdlet.ParameterSetName) {
    'Initialize' {
        if ((-not $PSBoundParameters.ContainsKey('Isolated')) -and $InvokeBuild) {
            $Isolated = $True
        }
        if ($ISolated) {
            Save-PSResource -Name PSubShell -Path . -IncludeXml -WarningAction SilentlyContinue
            Remove-Item PSubShell_InstalledScriptInfo.xml
        }
        if ($InvokeBuild) {
            $resource = Find-PSResource InvokeBuild -ErrorAction Stop
            $PSubShell.Config.Resources.InvokeBuild = @{ Type = $resource.Type.ToString() }
            $PSubShell.Locks.InvokeBuild = @{ Type = $resource.Type.ToString(); Version = $resource.Version.ToString() }
            if ($Isolated) {
                Set-Content -Path 'build.ps1' -Value @'
param(
    [Parameter(Position = 0)]
    [ValidateSet('?', '.')]
    [string[]]$Tasks = '.'
)

if ($MyInvocation.ScriptName -notlike '*Invoke-Build.ps1') {
    ./PSubShell.ps1 -NoProfile -Command "Invoke-Build $Tasks $PSCommandPath" -Parameters $PSBoundParameters
    return
}

task . { Write-Build Green 'Hello world!' }
'@

            }
            else {
                Set-Content -Path 'build.ps1' -Value @'
param(
    [Parameter(Position = 0)]
    [ValidateSet('?', '.')]
    [string[]]$Tasks = '.'
)

if ($MyInvocation.ScriptName -notlike '*Invoke-Build.ps1') {
    PSubShell -NoProfile -Command "Invoke-Build $Tasks $PSCommandPath" -Parameters $PSBoundParameters
    return
}

task . { Write-Build Green 'Hello world!' }
'@

            }
        }
        ConvertTo-Json $PSubShell.Config | Set-Content $PSubShell.ConfigFile
        ConvertTo-Json $PSubShell.Locks | Set-Content $PSubShell.LockFile
        return
    }
    'EnterShell' {
        if ($global:PSubShellInstance -eq $PSubShell.Path) {
            Write-Error 'Cannot reenter the same PSubShell.'
            return
        }

        $script = Join-Path ([System.IO.Path]::GetTempPath()) "tmp$((New-Guid) -replace '-','').ps1"
        try {
            Set-Content -Path $script -Value @"
`$global:PSubShellInstance = '$($PSubShell.Path)'
Set-Alias -Name PSubShell -Value $($MyInvocation.MyCommand.Path)
$PSCommandPath -Apply
$Command $($Parameters.GetEnumerator() | ForEach-Object { "$($_.Key) $($_.Value)" } | Join-String ' ')
"@

            #Get-Content $script
            Invoke-Expression "pwsh -Interactive $(((-not $Command) -or $NoExit) ? '-NoExit' : '') $($NoProfile ? '-NoProfile' : '') $($NoLogo ? '-NoLogo' : '') -File $script"
        }
        finally {
            Remove-Item -Path $script -Force -ErrorAction SilentlyContinue
        }
    }

    'InstallResource' {
        $parms = @{}
        foreach ($parm in $PSBoundParameters.Keys) {
            if ($parm -ne 'InstallResource') {
                if ($PSBoundParameters.$parm -is [switch]) {
                    $parms.Add($parm, [bool]$PSBoundParameters.$parm)
                }
                else {
                    $parms.Add($parm, $PSBoundParameters.$parm)
                }
            }
        }
        $resource = Find-PSResource -Name $InstallResource -ErrorAction SilentlyContinue @parms |
            Sort-Object -Property Version -Descending |
            Select-Object -First 1
        if (-not $resource) {
            Write-Error "Unable to find resource '$InstallResource'."
            return
        }
        $type = $resource.Type.ToString()
        if ((-not $type) -or ($type -eq 'None')) {
            $type = 'Package'
        }
        $PSubShell.Config.Resources.$InstallResource = @{
            Type = $type
        } + $parms
        $PSubShell.Locks.$InstallResource = @{
            Type = $type
            Version = $resource.Version.ToString()
        }
        ConvertTo-Json $PSubShell.Config | Set-Content $PSubShell.ConfigFile
        ConvertTo-Json $PSubShell.Locks | Set-Content $PSubShell.LockFile
    }

    'RemoveResource' {
        if ($PSubShell.Config.Resources.$RemoveResource) { $PSubShell.Config.Resources.Remove($RemoveResource) }
        if ($PSubShell.Locks.$RemoveResource) { $PSubShell.Locks.Remove($RemoveResource) }
        ConvertTo-Json $PSubShell.Config | Set-Content $PSubShell.ConfigFile
        ConvertTo-Json $PSubShell.Locks | Set-Content $PSubShell.LockFile
    }

    'Update' {
        foreach ($name in $PSubShell.Config.Resources.Keys) {
            $parms = @{ }
            foreach ($parm in $PSubShell.Config.Resources.$name.Keys) {
                if ($parm -ne 'Type') {
                    Write-Host $parm
                    $parms.Add($parm, $PSubShell.Config.Resources.$name.$parm)
                }
            }
            $resource = Find-PSResource -Name $name -ErrorAction SilentlyContinue @parms |
                Sort-Object -Property Version -Descending |
                Select-Object -First 1
            $PSubShell.Locks.$name = @{
                Type = $resource.Type.ToString() ?? 'Package'
                Version = $resource.Version.ToString()
            }
        }
        ConvertTo-Json $PSubShell.Locks | Set-Content $PSubShell.LockFile
    }

    'AddPath' {
        if (-not $PSubShell.Config.ContainsKey('Path')) {
            $PSubShell.Config.Path = @()
        }
        $fqpWarning = $false
        foreach ($path in @($AddPath)) {
            if ([System.IO.Path]::IsPathFullyQualified($path)) {
                Write-Warning "Adding fully qualified path '$path'."
                $fqpWarning = $true
            }
        }
        if ($fqpWarning) {
            Write-Warning 'Adding fully qualified paths is not recommended.'
        }
        $PSubShell.Config.Path += @($AddPath)
        ConvertTo-Json $PSubShell.Config | Set-Content $PSubShell.ConfigFile
    }

    'AddModulePath' {
        if (-not $PSubShell.Config.ContainsKey('ModulePath')) {
            $PSubShell.Config.ModulePath = @()
        }
        $fqpWarning = $false
        foreach ($path in @($AddModulePath)) {
            if ([System.IO.Path]::IsPathFullyQualified($path)) {
                Write-Warning "Adding fully qualified module path '$path'."
                $fqpWarning = $true
            }
        }
        if ($fqpWarning) {
            Write-Warning 'Adding fully qualified paths is not recommended.'
        }
        $PSubShell.Config.ModulePath += @($AddModulePath)
        ConvertTo-Json $PSubShell.Config | Set-Content $PSubShell.ConfigFile
    }

    'AddVariable' {
        if (-not $PSubShell.Config.ContainsKey('Variables')) {
            $PSubShell.Config.Variables = @{}
        }
        $PSubShell.Config.Variables.$AddVariable = $Value
        ConvertTo-Json $PSubShell.Config | Set-Content $PSubShell.ConfigFile
    }

    'DefaultRepository' {
        $PSubShell.Config.DefaultRepository = $DefaultRepository
        ConvertTo-Json $PSubShell.Config | Set-Content $PSubShell.ConfigFile
    }

    'Apply' {
        Write-Host 'Applying PSubShell...'
        $psubshellpath = Join-Path $PSubShell.Path '.psubshell'
        $cpath = @($PSubShell.Config.Path)
        [Array]::Reverse($cpath)
        foreach ($path in $cpath) {
            if (-not ([IO.Path]::IsPathRooted($path))) {
                $path = Join-Path $PSubShell.Path $path
            }
            $path = Join-Path $path '.'
            $path = [IO.Path]::GetFullPath($path)
            $existingPaths = $env:PATH -split [IO.Path]::PathSeparator
            if (-not ($existingPaths -contains $path)) {
                $env:PATH = $path + [IO.Path]::PathSeparator + $env:PATH
            }
        }
        $cpath = @($PSubShell.Config.ModulePath)
        [Array]::Reverse($cpath)
        foreach ($path in $cpath) {
            if (-not ([IO.Path]::IsPathRooted($path))) {
                $path = Join-Path $PSubShell.Path $path
            }
            $path = Join-Path $path '.'
            $path = [IO.Path]::GetFullPath($path)
            $existingPaths = $env:PSModulePath -split [IO.Path]::PathSeparator
            if (-not ($existingPaths -contains $path)) {
                $env:PSModulePath = $path + [IO.Path]::PathSeparator + $env:PSModulePath
            }
        }
        foreach ($key in $PSubShell.Config.Variables.Keys) {
            if ($key.StartsWith('env:', 'InvariantCultureIgnoreCase')) {
                Set-Item -Path $key -Value $PSubShell.Config.Variables.$key
            }
            else {
                Set-Variable -Name $key -Value $PSubShell.Config.Variables.$key -Scope Global
            }
        }
        foreach ($key in $PSubShell.Config.EnvironmentVariables.Keys) {
            Set-Item -Path "env:$key" -Value $PSubShell.Config.EnvironmentVariables.$key
        }
        foreach ($resource in $PSubShell.Locks.Keys) {
            New-Item -Path $psubshellpath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
            $Repository = $PSubShell.Config.Resources.$resource.Repository ?? $PSubShell.Config.DefaultRepository
            switch ($PSubShell.Locks.$resource.Type) {
                'Script' {
                    $resourcePath = Join-Path $psubshellpath "$resource.ps1"
                    $found = $false
                    if (Test-Path $resourcePath) {
                        $info = Get-PSScriptFileInfo -Path $resourcePath
                        if ($info.Version -eq $PSubShell.Locks.$resource.Version) {
                            $found = $true
                        }
                    }
                    if (-not $found) {
                        Remove-Item -Path $resourcePath -Force -ErrorAction SilentlyContinue
                        Save-PSResource -Name $resource -Version $PSubShell.Locks.$resource.Version `
                            -Path $psubshellpath -Repository:$Repository -IncludeXml -WarningAction SilentlyContinue
                    }
                    Set-Alias -Name $resource -Value $resourcePath -Scope Global
                    Write-Host "Set-Alias -Name $resource -Value $resourcePath"
                }
                'Module' {
                    $resourcePath = Join-Path $psubshellpath $resource -AdditionalChildPath $PSubShell.Locks.$resource.Version
                    if (-not (Test-Path $resourcePath)) {
                        Remove-Item -Path (Join-Path $psubshellpath $resource) -Force -ErrorAction SilentlyContinue
                        Save-PSResource -Name $resource -Version $PSubShell.Locks.$resource.Version `
                            -Path $psubshellpath -IncludeXml -WarningAction SilentlyContinue
                    }
                    Import-Module (Join-Path $psubshellpath $resource) -Force
                }
                'Package' {
                    $resourcePath = Join-Path $psubshellpath $resource -AdditionalChildPath $PSubShell.Locks.$resource.Version
                    if (-not (Test-Path $resourcePath)) {
                        Remove-Item -Path (Join-Path $psubshellpath $resource) -Force -ErrorAction SilentlyContinue
                        Save-PSResource -Name $resource -Version $PSubShell.Locks.$resource.Version `
                            -Path $psubshellpath -IncludeXml -WarningAction SilentlyContinue
                    }
                    $tools = Join-Path $resourcePath 'tools'
                    if (Test-Path $tools) {
                        $env:PATH = (@($tools) + (
                                $env:PATH -split [IO.Path]::PathSeparator |
                                    Where-Object { $_ -ne $tools }
                            )) -join [IO.Path]::PathSeparator
                    }
                }
            }
        }
    }
}