3

I discovered that within a PowerShell (.ps1) script I can display the command line that launched the script with:

Write-Information $MyInvocation.Line -InformationAction Continue

However, when I pass variables on my command line, these variables are not expanded when displaying them with the command above.

I need them to be expanded for convenience purposes as I need anybody to be able to copy paste the command line without having the defined variables.

I tried something like:

# Display command
$fullCommand = $MyInvocation.MyCommand.Name
$MyInvocation.BoundParameters.Keys | ForEach {
    $fullCommand += " -$($_) $($PSBoundParameters.Item($_))"
}
Write-Information $fullCommand -InformationAction Continue

This does not work as intended as the flags are not properly displayed.

A parameter block:

[CmdletBinding(DefaultParameterSetName="noUploadSet")]
param(
    [switch] $SkipGetList,
    [switch] $DisableHistory,
    [string] $RefVersion,
    [datetime] $ComputationDate,
    [string] $RefEnv,
    [string] $NewEnv,
    [Parameter(Mandatory=$true)][string] $Perimeter,
    [string] $ComputationType = "Test",
    [string] $WorkingDir,
    [string] $NewServiceName,
    [Parameter(ParameterSetName="noUploadSet")][switch] $DebugMode,
    [Parameter(ParameterSetName="uploadSet")][switch] $UploadNewService,
    [string] $ScenarioPath,
    [string] $User
)
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Sandoli
  • 63
  • 5

2 Answers2

5

This does not work as intended as the flags are not properly displayed.

If by "flags" you mean [switch] parameters, all you need is a : to tightly bind the explicit value to the parameter name:

$fullCommand += " -$($_):$($PSBoundParameters.Item($_))"

That won't solve all your problems though. The [datetime] values will likely default to a culture-specific format containing spaces, string values will need to be quoted (and escaped correctly), and [switch] parameter values need to be prepended with $ when dropped into a string:

function Get-InvocationQuote
{
  param([System.Management.Automation.InvocationInfo]$Invocation)

  $cmdText = $Invocation.InvocationName
  foreach($param in $Invocation.BoundParameters.GetEnumerator()){
    $name = $param.Key
    $value = switch($param.Value){
      {$_ -is [string]} {
        # Quote and escape all string values as single-quoted literals
        "'{0}'" -f [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($_)
      }

      {$_ -is [datetime]} {
        # Quote datetime using a culture-independent format
        "'{0}'" -f $_.ToString('o')
      }

      {$_ -is [bool] -or $_ -is [switch]} {
        # Map booleans to their respective automatic variables
        '${0}' -f "$_"
      }

      {$_ -is [enum] -or $_.GetType().IsPrimitive} {
        # Leave numerals
        $_
      }

      default {
        throw "Unable to quote '{0}' of type '{1}'" -f [System.Management.Automation.LanguagePrimitives]::ConvertTypeNameToPSTypeName($_.GetType.FullName)
        return
      }
    }

    $cmdText += " -${name}:${value}"
  }

  return $cmdText
}

Now we have a nice self-contained utility that can transform $MyInvocation for commands with simple parameters into an executable quote, we just need a command to test with:

function Test-Quoting {
  param(
    [int]$Number,
    [string]$String,
    [switch]$Flag,
    [datetime]$Date
  )

  Get-InvocationQuote $MyInvocation
}

And we get a result like:

PS ~> Test-Quoting -Number 123 -String "this will'need escaping" -Date 1/1/1970 -Flag
Test-Quoting -Number:123 -String:'this will''need escaping' -Date:'1970-01-01T00:00:00.0000000' -Flag:$True

To test that the quoted command indeed reproduces the original call arguments, let's test that successive quoting generates repeat results:

PS ~> $quote = Test-Quoting -Number 123 -String "this will'need escaping" -Date 1/1/1970 -Flag
PS ~> $quoteEval = $quote |Invoke-Expression
PS ~> $quote -eq $quoteEval
True
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Mathias R. Jessen
  • 157,619
  • 12
  • 148
  • 206
  • Nicely done. Note that you only get a culture-_sensitive_ representation of values such as `[datetime]` if you use `-f`; if you use PowerShell's string interpolation or implicit string contexts such as a `-join` operation, the representations are culture-_invariant_. – mklement0 Dec 31 '20 at 16:29
  • Is it a convention to use the same casing style as for [cmdlets](https://en.wikipedia.org/wiki/Windows_PowerShell#Cmdlets) (here "Test-Quoting") (not a rhetorical question)? – Peter Mortensen Jan 08 '21 at 21:04
  • @PeterMortensen **Yes**, I'd say so - while the docs and development guidelines only lists it as a basic requirement *specifically for cmdlets*, the PascalCased `Verb-Noun` style is universally applicable to _commands_ at large, and the practice is widely accepted/adopted. – Mathias R. Jessen Jan 08 '21 at 21:47
5

To complement Mathias R. Jessen's helpful answer with a solution that:

  • supports array-typed arguments too
  • employs syntax that is more familiar, by:
    • quoting arguments only if necessary
    • using : to separate parameter names and values only when necessary, namely only for [switch]-type parameters to which $false was passed.
      • representing -Switch parameters, whose implied value is $true, as just -Switch rather than as -Switch:$true

Note:

  • If a value is encountered that will likely not work if the resulting string is re-invoked - such as a [hashtable] or [pscustomobject] instance - a warning is emitted.

  • [datetime] and [datetimeoffset] instances are represented by their culture-invariant string representations, because PowerShell, in string operations other than -f, uses the invariant culture for formatting (see this answer for background). Therefore, the representation should work irrespective of what the current culture is.

    • However, these representations are limited to a granularity of seconds (e.g., 12/31/2020 10:57:35); if you need to preserve sub-second values as such, use .ToString('o') stringification, as shown in Mathias' answer; this may also be preferable to avoid the month-first format of the default stringification (you could also pick a shorter custom format as an alternative to 'o', the round-trip formatting).

    • As an aside: If a compiled cmdlet (rather than code written in PowerShell) is invoked with a string representation of a date, the parsing is unexpectedly culture-sensitive - unfortunately, this inconsistency will not be fixed due to backward-compatibility concerns - see GitHub issue #6989.

function Get-Foo {

  [CmdletBinding()]
  param(
      [switch] $SkipGetList,
      [switch] $DisableHistory,
      [datetime] $ComputationDate,
      [string] $RefVersion,
      [string] $WorkingDir,
      [int[]] $Indices
  )

  # Get this function's invocation as a command line 
  # with literal (expanded) values.
  '{0} {1}' -f `
    $MyInvocation.InvocationName, # the function's own name, as invoked
    ($(foreach ($bp in $PSBoundParameters.GetEnumerator()) { # argument list
      $valRep =
        if ($bp.Value -is [switch]) { # switch parameter
          if ($bp.Value) { $sep = '' } # switch parameter name by itself is enough
          else { $sep = ':'; '$false' } # `-switch:$false` required
        }
        else { # Other data types, possibly *arrays* of values.
          $sep = ' '
          foreach ($val in $bp.Value) {
            if ($val -is [bool]) { # a Boolean parameter (rare)
              ('$false', '$true')[$val] # Booleans must be represented this way.
            } else { # all other types: stringify in a culture-invariant manner.
              if (-not ($val.GetType().IsPrimitive -or $val.GetType() -in [string], [datetime], [datetimeoffset], [decimal], [bigint])) {
                Write-Warning "Argument of type [$($val.GetType().FullName)] will likely not round-trip correctly; stringifies to: $val"
              }
              # Single-quote the (stringified) value only if necessary
              # (if it contains argument-mode metacharacters).
              if ($val -match '[ $''"`,;(){}|&<>@#]') { "'{0}'" -f ($val -replace "'", "''") }
              else { "$val" }
            }
          }
        }
      # Synthesize the parameter-value representation.
      '-{0}{1}{2}' -f $bp.Key, $sep, ($valRep -join ', ')
    }) -join ' ') # join all parameter-value representations with spaces

}

# Sample call:
Get-Foo `
  -SkipGetList `
  -DisableHistory:$false `
  -RefVersion 1.0b `
  -WorkingDir "C:\dir A\files'20" `
  -ComputationDate (Get-Date) `
  -Indices (1..3)

The above yields the following as a single-line string (annotated and broken into multiple lines only here, for readability):

Get-Foo `
  -SkipGetList `  # switch syntax was preserved
  -DisableHistory:$false # negated switch (rare) was preserved
  -RefVersion 1.0b `  # string parameter NOT quoted, because not needed
  -WorkingDir 'C:\dir A\files''20' ` # quoting needed, ' escaped as ''
  -ComputationDate '12/31/2020 10:40:50' ` # culture-invariant date string
  -Indices 1, 2, 3  # array preserved
mklement0
  • 382,024
  • 64
  • 607
  • 775