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.

9 comments:

  1. there are some errors above...
    The Before attribute on the SetProperty Id="InvokeTestPS1" node is incorrect - it should be Before="InvokeTestPS1".
    Also, the value references a property called [POWERSHELL.EXE] but it should be [POWERSHELLEXE] as defined above in the registry search.

    ReplyDelete
  2. @derek: Absolutely right! I've updated the post with your corrections. Thanks!

    ReplyDelete
  3. I would be grateful if you would be able to advise me on this issue. I came across a weird issue with Wix executing powershell script that has interaction with command line to execute VSDBCMD.exe. The script runs fine in isolated mode but when fired via the compiled msi installer the installation completes successfully and log file created with some logging text but the command line step for VSDBCMD.exe is skipped and ignored.
    Line Sample ###
    & $Tool /a:Deploy "`"/cs: Server=$SERVERNAME;User ID=$DBUSERNAME;Password=$DBPASSWORD;`"" /dsp:Sql /dd+ /model:$DbSchema /p:TargetDatabase=$DATABASENAME /manifest:$DeployManifest 2>&1 | write-output

    ReplyDelete
    Replies
    1. OHarbe, I would recommend that you call VSDBCMD.exe directly instead of wrapping up the command in a powershell script. If I had to make a guess there's some quoting problem or perhaps you are making an assumption that holds true when you are executing in "isolated mode" but not when executing in the compiled msi. If you are still set on using a powershell script, then I would recommend that you write out the command line you are trying to execute somewhere so that you can check that it looks as expected.

      Delete
  4. I realize this is quite old but I am trying to get something similar working and I am not seeing the definition for the [#Invoke_Test.ps1] reference.

    ReplyDelete
    Replies
    1. @Greg: The [#Invoke_Test.ps1] reference is in error. It should be [#InvokeTestPS1]. I've update the post with the correction.

      Delete
  5. I just want to sincerely thank you for writing this. It worked like a charm!

    ReplyDelete
  6. This is exactly what I need ! But I'm a newbie on wix :( can someone post the wix project so I can adapt it ! Thanks

    ReplyDelete
  7. Instead returning the $error.count I recommend to work with $LASTEXITCODE - this has the advantage that you can return gracefully from the script even though the error count is unequal 0

    ReplyDelete