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.