HackF5.ProfileAlias.psm1

New-Variable GeneratedProfileAliasModuleName -Visibility Private -Option Constant `
    -Value "HackF5.ProfileAlias.Generated"

New-Variable ProfileAliasJsonName -Visibility Private -Option Constant `
    -Value "HackF5.ProfileAlias.json"

New-Variable ProfileAliasDataDirectory -Visibility Private -Option Constant `
    -Value (Join-Path -ChildPath "pshw\alias" `
        -Path ([System.Environment]::GetFolderPath(
        [System.Environment+SpecialFolder]::LocalApplicationData,
        [System.Environment+SpecialFolderOption]::Create)))

New-Variable DefaultProfileAliasGroup -Visibility Private -Option Constant -Value "default"

$script:ProfileAliasGroup = "default"

function Get-ProfileAliasDataDirectory {
    param (
        [Parameter()] [String] $Group
    )

    $Group = -not [string]::IsNullOrWhiteSpace($Group) ? $Group : $ProfileAliasGroup
    $path = Join-Path -Path $ProfileAliasDataDirectory -ChildPath $Group
    
    return New-Item -ItemType Directory -Force -Path  $path -Confirm:$false
}

function Get-ProfileAliasGroup {
    <#
        .SYNOPSIS
            Gets the current profile alias group.
 
        .NOTES
            Groups exist mainly for testing, so you probably don't need to worry about this.
    #>

    return $script:ProfileAliasGroup
}

function Set-ProfileAliasGroup {
    <#
        .SYNOPSIS
            Sets the current profile alias group.
 
        .PARAMETER Group
            The name of the group to set. When not set reverts to the default group.
 
        .NOTES
            Groups exist mainly for testing, so you probably don't need to worry about this.
    #>

    param (
        [Parameter()] [String] $Group
    )

    $value = -not [string]::IsNullOrWhiteSpace($Group) ? $Group : $DefaultProfileAliasGroup
    $script:ProfileAliasGroup = $value

    Update-ProfileAliasModule
}

function Remove-ProfileAliasGroup {
    <#
        .SYNOPSIS
            Removes a profile alias group.
 
        .PARAMETER Group
            The name of the group to remove.
 
        .NOTES
            Groups exist mainly for testing, so you probably don't need to worry about this.
    #>

    param (
        [Parameter(Mandatory=$true)] [String] $Group
    )

    if ($Group -eq $DefaultProfileAliasGroup)
    {
        Write-Error "You cannot remove the default group."
        return
    }

    if ($script:ProfileAliasGroup -eq $Group)
    {
        Write-Information "Reverting to default profile alias group."
        $null = Set-ProfileAliasGroup $DefaultProfileAliasGroup
    }

    $path = Get-ProfileAliasDataDirectory $Group
    if (Test-Path $path)
    {
        $null = Remove-Item -Force $path -Recurse 
    }
}

function Get-ProfileAliasJsonPath {
    return Join-Path -Path (Get-ProfileAliasDataDirectory) -ChildPath $ProfileAliasJsonName
}

function Save-ProfileAlias {
    param (
        [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [Array] $Aliases
    )

    ConvertTo-Json $Aliases -AsArray -Depth 8 | Set-Content (Get-ProfileAliasJsonPath) -Force -Confirm:$false
    Update-ProfileAliasModule
}

function Get-ProfileAliasModulePath {
    return Join-Path -Path (Get-ProfileAliasDataDirectory) -ChildPath "$GeneratedProfileAliasModuleName.psm1"
}

function Get-CommandFunctionBody {
    param (
        [Parameter(Mandatory=$true)] [string] $Command
    )

    $pattern = '#{1}\{(\d+)\}'

    $maxIndex = -1
    foreach ($m in [regex]::Matches($Command, $pattern)) {
        $index = [System.Convert]::ToInt32($m.Groups[1].Value)
        if ($index -gt $maxIndex) {
            $maxIndex = $index
        }
    }
    
    $maxIndex += 1

    $builder = New-Object System.Text.StringBuilder('$argsproxy = $args');
    $null = $builder.AppendLine();
    $body = $Command -replace $pattern, '$argsproxy[$1]' -replace '#{1}\{\*\}', '$argsproxy' -replace '#{1}\{:\*\}', "`$argsproxy[$maxIndex..10000]"
    $null = $builder.AppendLine($body);
    return $builder.ToString().Trim();
}

function Update-ProfileAliasModule {
    $moduleBuilder = New-Object System.Text.StringBuilder("# auto-generated by HackF5.ProfileAlias")
    $onRemoveBuilder = New-Object System.Text.StringBuilder("`$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {")

    foreach ($alias in Get-ProfileAlias) {   
        $null = $moduleBuilder.AppendLine();
        $null = $moduleBuilder.AppendLine();

        if ($alias.Bash) {
            $functionName = "Publish-ProfileAliasGenerated_$($alias.name)"
            $null = $moduleBuilder.AppendLine("function $functionName {");
            $null = $moduleBuilder.AppendLine($alias.body);
            $null = $moduleBuilder.AppendLine("}");
            $null = $moduleBuilder.AppendLine("Set-Alias -Name $($alias.name) -Value $functionName -Scope Global -Option ReadOnly -Force");
        }
        else {
            $null = $moduleBuilder.AppendLine("Set-Alias -Name $($alias.name) -Value $($alias.command) -Scope Global -Option ReadOnly -Force");
        }

        $null = $onRemoveBuilder.AppendLine();
        $null = $onRemoveBuilder.AppendLine("Remove-Alias -Name $($alias.name) -Scope Global -Force -ErrorAction SilentlyContinue");
    }

    $null = $onRemoveBuilder.AppendLine("}");
    $null = $moduleBuilder.AppendLine();
    $null = $moduleBuilder.AppendLine();
    $null = $moduleBuilder.Append($onRemoveBuilder);

    $modulePath = Get-ProfileAliasModulePath
    $null = Set-Content -Path $modulePath -Value $moduleBuilder.ToString() -Force -Confirm:$false
    $null = Remove-Module $GeneratedProfileAliasModuleName -Force -ErrorAction SilentlyContinue
    $null = Import-Module $modulePath -Global -Force
}

function Set-ProfileAlias {
    <#
        .SYNOPSIS
            Sets an alias that is loaded as part of your profile.
 
        .PARAMETER Name
            The name of the alias
 
        .PARAMETER Command
            The command to alias.
             
            This can either be any valid input to a standard PowerShell alias.
 
            Or it can be a string containing an executable object along with a predefined
            set of arguments.
 
            In the case of bash style aliases you need to be able to inject arguments into
            the command to be executed, this is done using the following syntax:
 
            - #{N} injects the Nth argument: $args[N]. Note that N is an array index so starts at 0.
            - #{*} injects all of the arguments: $args.
            - #{:*} injects all of the remaining arguments: $args[M..10000] where M is one greater
              than the maximal argument of the form #{N}.
 
        .PARAMETER Bash
            This is a bash style alias.
 
        .PARAMETER Force
            Any existing alias with the same name will be overwritten.
 
        .PARAMETER Confirm
            Prompts you for confirmation before setting the alias.
 
        .PARAMETER Verbose
            Displays detailed output.
 
        .EXAMPLE
            Set-ProfileAlias -Name setp -Command Set-ProfileAlias
 
            Create an alias to the Set-ProfileAlias method so that you can now
            use `setp -Name foo -Command Get-Item`. But seriously don't do this.
 
            A better use is to create an alias for an executable that is not currently
            on your path and where you don't want to add the entire directory. There is
            a registry hack that supposedly purports to do this, but good luck with
            that.
 
            Set-ProfileAlias -Name laws -Bash -Command 'docker run --network mynet --rm -it -v $env:userprofile\.aws\localstack:/root/.aws amazon/aws-cli --endpoint-url=http://localstack:4566 #{*}'
 
            Creates the alias `laws` that allows you run the dockerized aws-cli against your
            dockerized localstack (yes, this is the posterboy for why PowerShell needs bash style aliases).
 
            So you can execute: laws sns list-topics
            And you will list all of the sns topics in your local stack.
 
        .LINK
            https://github.com/hackf5/powershell-profile-alias
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Mandatory = $true)] [String] $Name,
        [Parameter(Mandatory = $true)] [String] $Command,
        [switch] $Bash,
        [switch] $Force
    )

    $systemAlias = Get-Alias | Where-Object { $_.Name -eq $Name }
    if (($null -ne $systemAlias) -and (-not $Force)) {
        Write-Error "Alias '$Name' already exists, to overwrite use the -Force flag."
        return
    }

    $body = $Bash ? (Get-CommandFunctionBody $Command) : [string]::Empty

    $aliases = @(Get-ProfileAlias | Where-Object { $_.name -ne $Name })

    $alias = [PSCustomObject]@{ name = $Name; command = $Command; body= $body; bash = $Bash.IsPresent }
    $aliases += $alias

    if ($PSCmdlet.ShouldProcess($jsonPath , "Set profile alias $Name in ")) {
        Save-ProfileAlias $aliases
        Write-Verbose "Set profile alias $Name to $Command"
        return $alias
    }
}

function Remove-ProfileAlias {
    <#
        .SYNOPSIS
            Removes a profile alias.
 
        .PARAMETER Name
            The name of the profile alias to remove.
 
        .EXAMPLE
            Remove-ProfileAlias -Name alias1
 
        .NOTES
            If no profile alias with this name exists then an error is raised.
 
        .LINK
            https://github.com/hackf5/powershell-profile-alias
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName, ValueFromPipeline)] [String] $Name
    )

    if ($null -eq (Get-Alias | Where-Object { $_.Name -eq $Name })) {
        Write-Error "Alias '$Name' does not exist."
        return
    }

    $aliases = Get-ProfileAlias
    if ($null -eq ($aliases | Where-Object { $_.name -eq $Name })) {
        Write-Error "Alias '$Name' is not a profile alias. Use Remove-Alias instead."
        return
    }

    $aliases = @($aliases | Where-Object { $_.name -ne $Name })

    if ($PSCmdlet.ShouldProcess($jsonPath, "Remove profile alias $Name from ")) {
        Save-ProfileAlias $aliases
        Write-Verbose "Removed profile alias $Name"
    }
}

function Get-ProfileAlias {
    <#
        .SYNOPSIS
            Lists all of the profile aliases that are currently active.
 
        .OUTPUTS
            An array of { name, command, bash } where
                - name: the name of the alias
                - command: the command that is executed when the alias is invoked
                - bash: a value indicating whether this is a bash style alias
 
        .EXAMPLE
            Get-ProfileAlias
                Gets all currently registered aliases
 
            Get-ProfileAlias | Where-Object {$_.name -eq "alias1"}
                Gets the alias with name "alias1" or returns null if no such alias exists.
 
        .NOTES
            The profile alias module can be used for easily registering persistent PowerShell
            style aliases, or for registering persistent bash style aliases.
 
            They are persistent in the sense that they are loaded as part of your profile,
            so are available between sessions.
 
            A bash style alias is one that takes an arbitrary string as it's alias which is
            then executed, along with any additional arguments, when the alias is invoked.
            These are identified by bash=true.
 
            A PowerShell style alias, by comparison, is a rather limited beast that essentially
            allows a command, executable, etc... to be referred to by another name. They provide
            some utility, but are rather limited.
 
        .LINK
            https://github.com/hackf5/powershell-profile-alias
    #>

    
    $path = Get-ProfileAliasJsonPath
    $aliases = (Test-Path -Path $path) ? (Get-Content $path | ConvertFrom-Json) : @()
    return $aliases
}

function Get-ProfileAliasRegisterCommand {
    $builder = New-Object System.Text.StringBuilder("")
    $null = $builder.AppendLine("# region profile alias initialize")
    $null = $builder.AppendLine("Import-Module -Name HackF5.ProfileAlias -Force -Global -ErrorAction SilentlyContinue")
    $null = $builder.AppendLine("# end region")

    return $builder.ToString().Trim()
}

function Register-ProfileAliasInProfile {
    <#
        .SYNOPSIS
            Registers the HackF5.ProfileAlias module into a PowerShell profile.
 
        .DESCRIPTION
            Your aliases are registered by this module when the module first loads, however
            PowerShell's auto-loading strategy is lazy, meaning that the module is not
            loaded until it is first used. Since you want your aliases always available
            then it is necessary to explicitly load the module as part of profile initialization.
 
            Invoking this command appends an explicit load request to your profile.
 
        .PARAMETER Path
            The path to your profile.
             
            When not set this defaults to the currently loaded profile, which is probably what
            you want.
 
        .PARAMETER Verbose
            Displays detailed output.
 
        .PARAMETER Confirm
            Prompts you for confirmation before registering the module in your profile.
 
        .EXAMPLE
            Register-ProfileAliasInProfile
                Adds an explicit load module request to your current profile.
 
        .NOTES
            This function is idempotent, so calling it multiple times will not result in multiple registrations.
 
        .LINK
            https://github.com/hackf5/powershell-profile-alias
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter()] [String] $Path
    )

    if ([string]::IsNullOrWhiteSpace($Path)) {
        $Path = $profile
    }

    $profileContent = (Test-Path $Path) ? (Get-Content -Path $Path -Raw) ?? [string]::Empty : [sring]::Empty
    $command = Get-ProfileAliasRegisterCommand
    if ($profileContent.Contains($command)) {
        Write-Verbose -Message "The HackF5.ProfileAlias module has already been registered in profile: $Path"
        return
    }

    $profileContent = $profileContent.TrimEnd()

    $builder = New-Object System.Text.StringBuilder($profileContent)
    if ($builder.Length -gt 0) {
        $null = $builder.AppendLine()
        $null = $builder.AppendLine()
    }

    $null = $builder.AppendLine($command)
    
    if ($PSCmdlet.ShouldProcess("$Path" , "Register HackF5.ProfileAlias in ")) {
        Set-Content -Path $Path -Value $builder.ToString().TrimEnd() -NoNewline -Confirm:$false
        Write-Verbose -Message "Registered HackF5.ProfileAlias in profile: $Path"
    }
}

function Unregister-ProfileAliasInProfile {
    <#
        .SYNOPSIS
            Unregisters the HackF5.ProfileAlias module from a PowerShell profile.
 
        .DESCRIPTION
            Invoking this command removes the code appended to your profile by Register-ProfileAliasInProfile.
 
        .PARAMETER Path
            The path to your profile.
             
            When not set this defaults to the currently loaded profile, which is probably what
            you want.
 
        .PARAMETER Verbose
            Displays detailed output.
 
        .PARAMETER Confirm
            Prompts you for confirmation before unregistering the module from your profile.
 
        .EXAMPLE
            Unregister-ProfileAliasInProfile
                Removes the code appended to your profile by Register-ProfileAliasInProfile.
 
        .NOTES
            This function is idempotent, so calling it multiple times will not result in multiple unregistrations.
 
        .LINK
            https://github.com/hackf5/powershell-profile-alias
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter()] [string] $Path
    )

    if ([string]::IsNullOrWhiteSpace($Path)) {
        $Path = $profile
    }

    if (-not (Test-Path $Path)) {
        Write-Verbose -Message "The profile $Path does not exists."
        return
    }

    $command = Get-ProfileAliasRegisterCommand
    $profileContent = (Get-Content -Path $Path -Raw).Replace($command, [string]::Empty).TrimEnd()

    if ($PSCmdlet.ShouldProcess("$Path" , "Register HackF5.ProfileAlias in ")) {
        Set-Content -Path $profile -Value  $profileContent -NoNewline -Confirm:$false
        Write-Verbose -Message "Unregistered HackF5.ProfileAlias in profile: $Path"
    }
}

$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {
    Remove-Module $GeneratedProfileAliasModuleName -Force -Confirm:$false -ErrorAction SilentlyContinue
}

Export-ModuleMember Register-ProfileAliasInProfile
Export-ModuleMember Unregister-ProfileAliasInProfile

Export-ModuleMember Get-ProfileAlias
Export-ModuleMember Set-ProfileAlias
Export-ModuleMember Remove-ProfileAlias

Export-ModuleMember Get-ProfileAliasGroup
Export-ModuleMember Set-ProfileAliasGroup
Export-ModuleMember Remove-ProfileAliasGroup

Update-ProfileAliasModule