Wednesday, March 9, 2011

Executing PowerShell Scripts Silently as Custom Actions with WiX

I use the Windows Installer XML toolset (WiX) on occasion to build setup packages for Windows. There are also times when the Windows Installer and WiX do not provide all the tools I need out of the box. Custom actions are a way to perform activities that are not provided by the Windows Installer. WiX has a number of standard custom actions to perform such activities. Still, there are times when I'll need to write my own custom action.

The Windows Installer can execute a bunch of different custom action types, including VBScript and JScript, but  it cannot execute PowerShell scripts natively. We are writing more and more management functions as PowerShell scripts and executing some of them at install time is often convenient or required. Executing a PowerShell script should be as easy as executing an EXE custom action as we can execute powershell.exe specifying the appropriate command line arguments to execute our PowerShell script.

Here's a very simple PowerShell script to execute as an example:

# Invoke-Test.ps1            
$FilePath = "${env:UserProfile}\powershell.msi.test.log"            
Get-Process | Out-File -Encoding ASCII -FilePath $FilePath

We'll call this script Invoke-Test.ps1 and we'll have our MSI package install the script. The Invoke-Test.ps1 script gets the running processes on the system and writes them to a file in the user's profile directory. I had to manually specify a File/@Id for Invoke-Test.ps1 because hyphen (-) is not a valid id character in Windows Installer.

<DirectoryRef Id="INSTALLDIR">
  <Component Guid="*">
    <File Id="InvokeTestPS1" Source="Invoke-Test.ps1" />
  </Component>
</DirectoryRef>

First, we need to locate powershell.exe on the target system. We'll also include a launch condition to disallow installation if PowerShell is not installed on the target system.

<Property Id="POWERSHELLEXE">
  <RegistrySearch Id="POWERSHELLEXE"
                  Type="raw"
                  Root="HKLM"
                  Key="SOFTWARE\Microsoft\PowerShell\1\ShellIds\Microsoft.PowerShell"
                  Name="Path" />
</Property>
<Condition Message="This application requires Windows PowerShell.">
  <![CDATA[Installed OR POWERSHELLEXE]]>
</Condition>

Now we'll use the CAQuietExec standard custom action provided by WiX to execute our PowerShell script. We'll schedule the custom action after InstallFiles so that the PowerShell script is available.

<SetProperty Id="InvokeTestPS1"
             Before="InvokeTestPS1"
             Sequence="execute"
             Value ="&quot;[POWERSHELLEXE]&quot; -Version 2.0 -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command &quot;&amp; '[#InvokeTestPS1]' ; exit $$($Error.Count)&quot;" />
<CustomAction Id="InvokeTestPS1"
              BinaryKey="WixCA"
              DllEntry="CAQuietExec"
              Execute="deferred"
              Return="check"
              Impersonate="yes" />
<InstallExecuteSequence>
  <Custom Action="InvokeTestPS1" After="InstallFiles">
    <![CDATA[NOT Installed]]>
  </Custom>
</InstallExecuteSequence>

The SetProperty/@Value defines the command line we want to execute silently. See PowerShell.exe Console Help for an explanation of the command line arguments we are passing powershell.exe. The command we execute executes the Invoke-Test.ps1 script identified by it's File/@Id and then we explicitly exit with the number of errors encountered during script execution allowing the installer to fail if the script fails during execution.

There is one major problem with this custom action. The installer package appears to hang part way through the install. Process Explorer tells us that msiexec.exe spawned powershell.exe but nothing else is happening; almost like PowerShell is waiting for something to occur before it can continue. This was a huge show stopper for me for a very long time. I couldn't figure out what the problem was. I found I could execute PowerShell.exe using a type-50 custom action but that results in a console window popping up during installation. Even worse, any output generated by my script was not included in the MSI log file if one was requested. The CAQuietExec standard custom action automatically writes any output to the MSI log file. What a bummer!!

But... a few days ago I found a Connect bug that appeared related to what I was experiencing, PowerShell.exe can hang if STDIN is redirected. After all, if CAQuietExec redirects standard output, then it probably redirects standard input too! Thankfully, the Connect bug has a workaround that tells use to use an undocumented value (None) for the -InputFormat parameter. Let's update our command line!


<SetProperty Id="InvokeTestPS1"
             Before="InvokeTestPS1"
             Sequence="execute"
             Value ="&quot;[POWERSHELLEXE]&quot; -Version 2.0 -NoProfile -NonInteractive -InputFormat None -ExecutionPolicy Bypass -Command &quot;&amp; '[#InvokeTestPS1]' ; exit $$($Error.Count)&quot;" />


Success!! The custom action now executes to completion silently and we get all of its standard output into the log file were we can look for error messages if the installation fails.

Tuesday, February 22, 2011

Executing Native Commands in PowerShell

I love PowerShell! I've been scripting in PowerShell almost exclusively for almost three years now. PowerShell's scripting environment is extremely powerful, exposing .NET, COM, WMI, as well as native commands to the scripter.

I try to answer questions on the PowerShellCommunity forums as often as I can. One fairly common question is how do we execute native commands. Of course, posters don't ask the question in that fashion. Typically, they are trying to execute a command and they're trying to use Invoke-Item, Invoke-Expression, Invoke-Command, or some other facility to execute the command. What they are doing doesn't work and they need help. I almost always give the same advice, call the command directly.

Usually the root cause of the problem is that the command they are trying to execute contains spaces. To execute a command in a BATCH script with spaces you simply wrap the command in double quotes.

"C:\Program Files\7-Zip\7z.exe" l C:\temp\7za920.zip

This doesn't work in PowerShell causing many people to try using Invoke-Command, Invoke-Item, Invoke-Expression, start, cmd, and even [System.Diagnostics.Process] to try to execute their command. All these methods can be used to accomplish the goal but they add additional complexity that we don't need.

PowerShell knows how to execute native commands directly. We can see this when we attempt to execute the tasklist native command.

PS C:\> tasklist /fi "imagename eq explorer.exe" /fo list

Image Name:   explorer.exe
PID:          4012
Session Name: Console
Session#:     0
Mem Usage:    28,248 K
PS C:\>

So what happens when the command to execute is wrapped in quotes? PowerShell interprets the command as a string literal. A string literal is an expression in PowerShell. String literals can be combined in to larger expressions with the use of operators.

PS C:\> "red brick" -match "red"
True
PS C:\>

In that expression -match is the operator linking two separate string literals into a larger expression. The native command example above has a string literal followed by another, implicit, string literal resulting in a parse error.

PS C:\> "C:\Program Files\7-Zip\7z.exe" l C:\temp\7za920.zip
Unexpected token 'l' in expression or statement.
At line:1 char:34
+ "C:\Program Files\7-Zip\7z.exe" l <<<<  C:\temp\7za920.zip
    + CategoryInfo          : ParserError: (l:String) [], ParentContainsErrorRecordException
    + FullyQualifiedErrorId : UnexpectedToken
 
PS C:\>

The PowerShell designers knew that users would need to execute commands like this so they added a special operator to interpret string literals as commands, the call operator (&). See Get-Help about_operators for details. Executing our command using the call operator is simple.

PS C:\> & "C:\Program Files\7-Zip\7z.exe" l C:\temp\7za920.zip

7-Zip 4.65  Copyright (c) 1999-2009 Igor Pavlov  2009-02-03

Listing archive: C:\temp\7za920.zip


   Date      Time    Attr         Size   Compressed  Name
------------------- ----- ------------ ------------  ------------------------
2010-11-18 08:08:04 ....A        91020        83925  7-zip.chm
2010-11-18 08:27:33 ....A       587776       299189  7za.exe
2010-03-13 00:06:33 ....A         1162          544  license.txt
2010-11-18 08:09:09 ....A         1254          644  readme.txt
------------------- ----- ------------ ------------  ------------------------
                                681212       384302  4 files, 0 folders
PS C:\>

The call operator is needed whenever the command to execute, and only the command, is represented by an expression. We could calculate the path to the 7z.exe native command using the ProgramFiles environment variable and execute the command in a single command.

PS C:\> & $(Join-Path -Path $env:ProgramFiles -ChildPath 7-Zip\7z.exe) l C:\temp\7za920.zip

We could have assigned the result of Join-Path to a variable and used the call operator to execute the command represented by the variable too.

PS C:\> $7z = Join-Path -Path $env:ProgramFiles -ChildPath 7-Zip\7z.exe
PS C:\> & $7z l C:\temp\7za920.zip

Always remember KISS, even with PowerShell.