Tuesday, May 20, 2014

Using MSBuild and PowerShell to import Files into a Project Visual Studio 2013, Umbraco

Recently I was trying to setup a Visual Studio Project to import Views, Css, Images, etc into an Umbraco Project I've been working on.

Here's the basic setup,
  1. An empty .Net 4.5 Web Project
    1. Add the UmbracoCMS nuget Package
  2. A 2nd Empty Web Project
    1. Add the Umbraco Core Binaries Package

The general idea is to develop a plugin for Umbraco in the 2nd project, but have all of it's content included via an Import Project into the Umbraco Project.  When it's done and packaged, this is how it will be incorporated into any Umbraco Project.

In the hunt to do this, I found an example over at http://blog.samstephens.co.nz/2010-10-18/msbuild-including-extra-files-multiple-builds/ by Sam Stephens.

However, I was unable to get it to work in Visual Studio 2013.  So I thought, it would be nice if I could just pass this off to a power shell script.  Which led me to another article by Jason Lee, http://www.asp.net/web-forms/tutorials/deployment/advanced-enterprise-web-deployment/running-windows-powershell-scripts-from-msbuild-project-files.

So taking the two example's and putting them together, I ended up with the following;

First Step...
  • Create 2 Powershell Scripts in the 2nd Project
    • Note, you can use the Visual Studio Extension Click Here to add powershell intellisense to visual studio
  • PostBuild.ps1
  • PreBuild.ps1
PreBuild.ps1 Contents

// Comment
 Param(
 [Parameter(Mandatory=$True,Position=1)][string]$sourceDirectory,
 [Parameter(Mandatory=$True,Position=2)][string]$destinationDirectory
)

 Write-Host "Before Build!!"
 Write-Host "Source: $sourceDirectory"
 Write-Host "Destination: $destinationDirectory"
 #Do anything else you want to do Pre Build here


PostBuild1.ps1 Contents

 Param(
 [Parameter(Mandatory=$True,Position=1)][string]$sourceDirectory,
 [Parameter(Mandatory=$True,Position=2)][string]$destinationDirectory
)
#CONFIG
$exclude = @('*.cs') #files to exclude go here
$folderNoMatch = [string[]]"\\views\\somefoldertoexclude\\?"#,"More Elements Here","And Another Element","And Another","Etc" #folders to exclude go here
#ENDCONFIG

#Convert the parameter inputs into DirectoryInfo objects "Note: DirectoryInfo is not standardized on whether a folder ends with \ or without.  It is based on whatever the input path is.  In this case with \.  How when enumerating directories they come back without the \ as such, I use TrimEnd("\") all over the place in here.
$sourceDI = [System.IO.DirectoryInfo]$sourceDirectory
$destinationDI = [System.IO.DirectoryInfo]$destinationDirectory
#this is the line that get's the ChildItems of the folder whos contents we are copying.  -Exclude is used here on the file wildcards, but does not work on Directories, so don't use Directories here.
$itemsToCopy = Get-ChildItem $sourceDirectory -Recurse -Exclude @('*.cs')
foreach ($item in $itemsToCopy){  
 $processItem = $true
 #process the folder matches, if it matches one set $processItem to false which skips the item
 foreach ($match in $folderNoMatch ){
   if ($item.FullName -match $match){
  #skip this folder
  $processItem = $false
   }
 }  
 if ($processItem){
  $subPath = $item.FullName.Substring($sourceDI.FullName.Length)
  $destination = Join-Path $destinationDirectory $subPath
  if ($item -is [System.IO.DirectoryInfo]){
   $itemDI = [System.IO.DirectoryInfo]$item
   $sourcePath = $sourceDI.FullName.TrimEnd("\")
   $sourceLength = $sourcePath.Length
   #If the item being copied is the folder itself, we need the destination to be the item's parent folder name, so this folder gets created in the right place
   $destination = Join-Path $destinationDI.FullName.TrimEnd("\") $itemDI.Parent.FullName.TrimEnd("\").Substring($sourceLength)
  }
  Copy-Item -Path $item.FullName -Destination $destination -Force}
 }
Write-Host [PROJECT: IMPORT COMPLETE]

Now you need to add a .Targets file to the 2nd Project


<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="11.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <Target Name="XYZProjectPath">
    <CreateProperty Value="$([System.IO.Path]::GetFullPath('..\XYZ.Project'))">
      <Output TaskParameter="Value" PropertyName="XYZProjectPath"/>
    </CreateProperty>
  </Target>  
  <Target Name="BeforeBuild" DependsOnTargets="XYZProjectPath">
    <PropertyGroup>
      <PowerShellExe Condition="'$(PowerShellExe)'==''">%WINDIR%\System32\WindowsPowerShell\v1.0\powershell.exe</PowerShellExe>
      <ScriptLocation>$(XYZProjectPath)\PreBuild.ps1</ScriptLocation>
    </PropertyGroup>
    <Exec Command="$(PowerShellExe) -NonInteractive -executionpolicy Unrestricted -command &quot;&amp; { &amp;&apos;$(ScriptLocation)&apos; &apos;$(XYZProjectPath)\UmbracoImport&apos; &apos;$(ProjectDir)&apos;} &quot;" />
  </Target>
  <Target Name="AfterBuild" DependsOnTargets="XYZProjectPath">
    <PropertyGroup>
      <PowerShellExe Condition="'$(PowerShellExe)'==''">%WINDIR%\System32\WindowsPowerShell\v1.0\powershell.exe</PowerShellExe>
      <ScriptLocation>$(XYZProjectPath)\PostBuild.ps1</ScriptLocation>
    </PropertyGroup>
    <Exec Command="$(PowerShellExe) -NonInteractive -executionpolicy Unrestricted -command &quot;&amp; { &amp;&apos;$(ScriptLocation)&apos; &apos;$(XYZProjectPath)\UmbracoImport&apos; &apos;$(ProjectDir)&apos;} &quot;" />
  </Target>
</Project>

So what have we done here in the targets file?

I created a Variable called XYZProjectPath. This variable can be used to calculathe the path to the project that is being copied from. I set it as a target's file that the other targets depend on.

Second, I created two Target's, one for BeforeBuild and one for AfterBuild. In each Target I created a node for PowerShellExe. This is where the scripts are called. ScriptLocation is set to the XYZProjectPath\PreBuild.ps1 and PostBuild.ps1 in the 2nd Target.

The interesting part is the Exec Command. I am using the -command command line argument of powershell.exe to send the commands to the powershell exe. As such they have to be encoded in a script block using &amp; and &apos; for & and '. What we do here is tell powershell.exe to run the Script and pass it $(XYZProject)\UmbracoImport for the source parameter and $(ProjectDir) for the destination.
$ProjectDir will be set to the directory of the project that is importing this .targets file.

UmbracoImport is the name of the folder in my project where all of my UmbracoContent is stored that I want copied into the Umbraco root folder (because it runs in IIS) and is managed in a visual studio project because I have it source controlled with SubVersion.

Now to use this .Targets file, simply unload the First project and edit it's project file, go down near the
bottom and add a line for <Import Project="..\XYZ.Project\XYZ.Project.targets" /> Assuming both of the projects are in the same folder.

When you do a fresh build after words, you should see similar output in the output window.

3> Before Build!! 3> Source: D:\XYZ\SourceTrunk\SomeProject\UmbracoImport 3> Destination: D:\XYZ\SourceTrunk\UmbracoHost\ 3> UmbracoHost -> D:\XYZ\SomeProject\UmbracoHost\bin\UmbracoHost.dll 3> After Build!! 3> Source: D:\XYZ\SourceTrunk\SomeProject\UmbracoImport 3> Destination: D:\XYZ\SourceTrunk\UmbracoHost\

All of the Content in UmbracoImport is now in the root of UmbracoHost! And because we are dealing with powershell here, there really is no limit to what you can do.

Eventually I'll write some code to merge web.configs so I can have config entries etc that get merged into connection strings and other areas in the destination web.config, and add a mechanism for ignoring existing files.

Edits: Change the Copy code in the PostBuild.ps1 script to use Get-ChildItem instead of Get-Item. (5/20/2014)

2 comments:

  1. Also, one more trick I've been using lately, is to use the MKLink command to create a fake directory in umbraco. This is why I have excluded a directory.

    I didn't like having to build every time I update a view in Project 2. So I used MKLink /D to link a views folder in umbraco to views in project 2.

    ReplyDelete
  2. Another Edit:

    The copy-item and get-childitems with -Exclude just flat out doesn't work. You can't use -Exclude when doing a -Recurse. As such I've switched to looping through the results of Get-ChildItems and manually matching each result against the ecluded folders.

    Seems to be working properly now.

    ReplyDelete