chocolatey / choco

Chocolatey - the package manager for Windows
https://chocolatey.org
Other
10.25k stars 899 forks source link

Enable use of param() blocks in Chocolatey* Scripts #3344

Open JPRuskin opened 11 months ago

JPRuskin commented 11 months ago

Checklist

Is Your Feature Request Related To A Problem? Please describe.

As a PowerShell developer, it would be great if we could support the commonly-used param-block usage seen in functions and scripts.

param(
    # The path to install to
    [string]$InstallDir = "C:\Python$($env:ChocolateyPackageVersion -replace "^(\d+\.\d+).*", "`$1")"
)
$ErrorActionPreference = 'Stop'
$toolsDir   = Split-Path $MyInvocation.MyCommand.Definition -Parent

<# ...Do install things... #>

When writing a PowerShell script, I expect this to work - but there is no nice way to pass arguments into Chocolatey to run scripts like this.

Instead, we have folk using something like this:

$pp = Get-PackageParameters
$twoPartVersion = $Env:ChocolateyPackageVersion -replace "^(\d+\.\d+).*", "`$1"
$defaultFolder = '{0}\Python{1}' -f $Env:SystemDrive, ($twoPartVersion -replace '\.')
if ( $pp.InstallDir ) {
  $installDir = $pp.InstallDir
  if ($installDir.StartsWith("'") -or $installDir.StartsWith('"')) { $installDir = $installDir -replace '^.|.$' }
  mkdir -force $installDir -ea 0 | out-null
}
else {
  $installDir = $defaultFolder
}

And there are many different ways people can write this handling logic.

Describe The Solution. Why is it needed?

Get-PackageParameters exists, but everyone has to reimplement the handling for parameters in their package, which is less simple than it might be. If we had a first-class handling for param-blocks (or some similar modern method), we could advise folk to handle parameters in a specific way, which would allow us to do potentially interesting things.

For instance, by enabling package maintainers to use param blocks:

It could also ease testing of install scripts, though I am not convinced that is particularly useful at this point for various reasons.

Additional Context

I had a play with adding a very basic transformation attribute to parameters within a chocolateyInstall.ps1 script.

class PackageParameterAttribute : System.Management.Automation.ArgumentTransformationAttribute {
    [string]$TargetParameter

    static [string] GetPackageParameterValue ($Name) {
        $PP = Get-PackageParameters
        if ($PP.ContainsKey($Name)) {
            return $PP[$Name]
        } else {
            return $null
        }
    }

    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object]$inputData) {
        if ([PackageParameterAttribute]::GetPackageParameterValue($this.TargetParameter)) {
            return [PackageParameterAttribute]::GetPackageParameterValue($this.TargetParameter)
        } else {
            return $inputData
        }
    }

    # PackageParameterAttribute() {
    #     # Can't figure out a way to get the name of the parameter, so we currently have to specify one
    #     $this.TargetParameter = "Test"
    # }

    PackageParameterAttribute([string]$Target) {
        $this.TargetParameter = $Target
    }
}

This seems to work pretty nicely when added to the chocolateyInstaller helper functions, and handles using the default value in a script if a user doesn't pass a matching --package-parameter (whilst overriding it if they do).

As an example, by adding [PackageParameter("NameOfPackageParameter")] to a parameter, we can see the default, the help, and easily have a user provide input:

[CmdletBinding()]
param(
    # The path to extract the files to
    [PackageParameter("InstallPath")]
    $InstallPath = $(Split-Path $MyInvocation.MyCommand.Definition -Parent),

    # A message to output (completely arbitrary example)
    [PackageParameter("Message")]
    [ValidateNotNullOrEmpty()]
    $MessageOutput = "There was no additional message provided."
)

Write-Host "Installing package to '$($InstallPath)'"

Write-Host $MessageOutput

image

It has a few potential disadvantages:

Example of Add-Type equivalent:

Add-Type @'
using System.Management.Automation;

public sealed class PackageParameterAttribute : ArgumentTransformationAttribute {
    string _targetParameter;

    string _getPackageParameterValueScript {
        get {
            return string.Format(
                @"
                    $PP = Get-PackageParameters
                    if ($PP.ContainsKey('{0}')) {{
                        return $PP['{0}']
                    }} else {{
                        return $null
                    }}
                ",
                _targetParameter
            );
        }
    }

    // // This currently doesn't work, as we can't retrieve the ParameterName easily
    // public PackageParameterAttribute() {
    //   _targetParameter = '???'
    // }

    public PackageParameterAttribute(string packageParameterName) {
        _targetParameter = packageParameterName;
    }

    public override object Transform(EngineIntrinsics engineIntrinsics, object inputData) {
        var result = engineIntrinsics.InvokeCommand.InvokeScript(_getPackageParameterValueScript)[0];
        if (null != result) {
            return result;
        }
        return inputData;
    }
}
'@

An alternative to adding an attribute like this would be rewriting the chocolateyScriptRunner.ps1 to pass in parameters where parameters are found, but that would involve a fair bit of calculation (or a requirement for an ignored parameter with ValueFromRemainingArguments to swallow unwanted splatting on all supporting scripts, perhaps?) and I've quite enjoyed this method so far.

Related Issues

No response

JPRuskin commented 1 month ago

Now with an example branch using splatting in the chocolateyScriptRunner. Turns out it was far easier than I worried, and seems to work back to PowerShell v3 - but that method would be harder to create a compatibility extension for.