Thursday, 12 September 2013

Windows PowerShell for Developers #6: the Package Manager Console

I've been playing about with the PMC that comes with NuGet.  This is quite a handy tool as it exposes the root design time environment object to PowerShell commands as $dte.  I'm currently adapting the substantial script I wrote to prepare a solution for NuGet packaging so it runs directly from this console. One hope is that I can cut the code down by manipulating the solution and projects directly instead of hacking the underlying files. 

This has proved to be more difficult that I thought.  $dte doesn't support all the interfaces you'd expect.  You have to derive a Solution2 COM interface before you can manipulate the solution directly. This, for example is how you add a file at solution level:

$vsSolution = Get-Interface $dte.Solution ([EnvDTE80.Solution2])
$vsProject = $vsSolution.AddSolutionFolder("newFolder")
$projectItems = Get-Interface $vsProject.ProjectItems ([EnvDTE.ProjectItems])
$projectItems.AddFromFile("greenery.txt")

It's still nicer than having to parse the solution file all the same. More on this as the project develops.

Tuesday, 3 September 2013

Windows PowerShell for Developers #5: Instrumenting your Projects for NuGet

Just before I went on my yearly summer holiday, I talked about NuGet-related issues and how PowerShell can help to resolve some of these painlessly. Latterly, I covered the housekeeping that comes with creating NuGet packages from your project. I also covered how to run packaging scripts automatically from Visual Studio as a post-build step

Prepping - or 'instrumenting' -  your projects for NuGet can save you a lot of  aggro in the long term but can be an almighty pain to do.  There are several areas you have to cover for each project you want to automatically package:
  1. Create a nupack.ps1 file at the solution level which will package a given project
  2. Creating the post-build scripts which invoke the script above
  3. Managing dependencies between packages.
The first step is fairly rudimentary and I've shown how to do this.  Step 2 is a drudge, but is merely a case of cutting and pasting the relevant scripts.   Step 3 on the other hand is one of those jobs given to programmers who have been really evil and have been sent to the Ninth Circle of Hell for their sins. Imagine if you have a complex solution with a lot of dependencies between .NET projects, and you want to map these to package inter-dependencies?  The job can very quickly get out of hand, and you can very easily make mistakes, as I did.

So, being a resourceful (i.e. lazy) type, I decided that it was about time to bring PowerShell to my rescue yet again.  I wanted a script that would do the following to a complete multi-project Visual Studio solution:
  1. Create the nupack.ps1 file at the solution level
  2. Amend each project to call this file after the build has completed
  3. Run nuget spec  against every single project in the solution to create the packaging specification
  4. Interrogate each project file for dependencies and map these to package inter-dependencies.
Steps 3 and 4 can be made a bit simpler if we consistently name our packages  after their originating projects. If we open up a .nuspec file for a project (generated by nuget spec) in an XML editor then we see something like

  
    $id$
    $version$
    ...
    ...
    ...
    ...
    .../
    ...
    false
    to be supplied
    Summary of changes made in this release of the package.
    
    
    Tag1 Tag2
    
      
      
    
  The 

The bits in ellipses are where you would normally insert information about your package, but the <dependencies> tag allows you to specify the packages which must also be installed. Our job is to make sure that this file is properly constituted. If, while we're at it, we can get the other tags properly populated as well then that would be a bonus. So, let's write a PowerShell script to do just that!

The Script

The PowerShell script to do all this looks like:
#NuGetProjects
#Prepares a set of projects in a solution for NuGet processing
#Generates a nuspec.ps1 file in the solution's root folder
#then modifies each VBproj file to run this as part of its postbuild event
#Also generates .nuspec files for each project and  updates the dependencies for each of these files

param(
 [string]$solutionDir = '.'
 )
#transfer the info from the VB project file to the nuspec file where we can
#mangle it to our heart's content
Function transferprojectinfo
{
 param (
  [System.IO.FileInfo]$projectFile,
  [xml]$nuspecxml
  )
 #register the MSBuild namespace so we can search the project file
 $ns = @{e='http://schemas.microsoft.com/developer/msbuild/2003'}
 #load up the project XML
 $xml = New-Object System.Xml.XmlDocument
 $xml.Load($projectFile.FullName)
 [string]$infoFileName = ""
 $descNode = (select-xml -namespace $ns -xml $xml -XPath '//e:AssemblyName').node
 $nuspecXML.package.metadata.description = $descNode.InnerText
 
 Push-Location $projectFile.Directory
 if (Test-Path (Join-Path $projectFile.Directory "\AssemblyInfo.vb"))
 {
  $infoFileName = (join-path $projectFile.Directory  "AssemblyInfo.vb")
 }
 else
 {
  if (Test-Path (Join-Path $projectFile.Directory  "\My Project\AssemblyInfo.vb"))
  {
   $infoFileName = (Join-Path $projectFile.Directory  "\My Project\AssemblyInfo.vb")
  }
 }
 
 if ($infoFileName -ne "")
 {
  Write-Output "AssemblyInfo.VB location ='$infoFileName'"
  [string]$assemblyDesc =( Get-Content $infoFileName|Select-String -Pattern '\<assembly: assemblydescription="" desc="">.*)"\)\> ' |%{$_.matches}|%{$_.groups["desc"].value})
  [string]$assemblyTitle = ( Get-Content $infoFileName|Select-String -Pattern '\<assembly: assemblytitle="" desc="">.*)"\)\> ' |%{$_.matches}|%{$_.groups["desc"].value})
  [string]$assemblyCompany = ( Get-Content $infoFileName|Select-String -Pattern '\<assembly: assemblycompany="" desc="">.*)"\)\> ' |%{$_.matches}|%{$_.groups["desc"].value})
  [string]$assemblyVersion = ( Get-Content $infoFileName|Select-String -Pattern '\<assembly: assemblyversion="" desc="">.*)"\)\> ' |%{$_.matches}|%{$_.groups["desc"].value})
  if($assemblyDesc -ne '')
  {
   $nuspecxml.package.metadata.description = $assemblyDesc
  }
  else
  {
   $nuspecxml.package.metadata.description = "to be supplied"
  }
  $nuspecxml.package.metadata.title= $assemblyTitle
  $nuspecxml.package.metadata.copyright = $assemblyCompany
  
  
 }
 #now do the dependencies
 $dependencyNodes = (select-xml -namespace $ns -xml $xml -XPath '/e:Project/e:ItemGroup/e:ProjectReference/e:Name')
 $nuspecxml.dependencies.RemoveElement
 $depRoot = $nuspecxml.CreateElement("dependencies")
 foreach($depNode in $dependencyNodes)
 {
   $newDep =  $nuspecXML.CreateElement("dependency")
   $newDep.SetAttribute("id",$depNode.Node.InnerText)
   $newDep.SetAttribute("version", "1.0.0.0")
   $depRoot.AppendChild($newDep)
 }
 $nuspecxml.package.metadata.AppendChild($depRoot)
 Pop-Location 
}

Function makespec
{
 
 param([string]$dirName)
 
 Write-Output "Processing project directory $dirName"
 pushd $dirName

 $projects = get-childitem -Filter *.vbproj

 foreach ($proj in $projects)
 {
  Write-Output "Processing directory $proj"
  $dirName = $proj.DirectoryName
  $nuspecFilename = (Join-Path $dirname *.nuspec)
  $nuspecFiles = (Get-ChildItem $nuspecFilename)
  foreach($nuspecFile in $nuspecFiles)
  {
   if(!(test-path $nuspecFile))
   {
    nuget spec -f $proj
   }
   $nuspecxml = [xml](Get-Content $nuspecFile)
   $metadata = $nuspecxml.package.metadata
   $metadata.authors = 'authorname'
   $metadata.owners = 'authorname'
   $metadata.authors = 'companyname'
   $metadata.licenseUrl = 'http://servername/sitename'
   $metadata.projectUrl = ''http://servername/sitename'
   $metadata.iconUrl = ''http://servername/sitename/image.gif'
   transferprojectinfo $proj $nuspecxml
   $nuspecxml.Save($nuspecFile.FullName)
   Write-Output "Fixed up $($nuspecFile.Fullname)"
  }
 }
 popd
}


#constant definitions
[string]$repositoryLocation = "\\servername\packagesharename"

#this is the main processing part of the script
Write-Output '+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++'

Write-Output "Preparing to process projects in directory tree $($solutionDir) "
$buildEventText = 'Powershell.exe $(SolutionDir)nupack.ps1 -projectdir $(ProjectDir) -config $(ConfigurationName) -outdir $(OutDir)'

 
$ErrorActionPreference = "Stop"
Write-Output "Setting Context to $($solutionDir)"
pushd $solutionDir
#extract the leaf folder of the directory
$slnFolder = (join-path $repositoryLocation (Get-Item $solutionDir).Name)
#create the nuget target directory if it doesn't exist
if(!(Test-Path $slnFolder))
{
 mkdir $slnFolder
}
#now generate the post-build script :  note use of the substitution variables
Write-Output "generating nupack.ps1 file"
echo '
#PowerShell script invoked 
param(
 [string]$projectdir,
 [string]$config="Debug",
 [string]$outDir
)
if($config -eq "Release")
{
 write-output "Project Directory = $projectdir"
 pushd $projectdir
 write-output "Deleting the packages..."
 del $projectdir\*.nupkg
 write-output "Repackaging..."
 nuget.exe pack -Properties OutDir=$outDir
 popd' > nupack.ps1
 "xcopy /Y /C `"`$projectdir*.nupkg`" `"$slnFolder`""  >> nupack.ps1
'}' >> nupack.ps1


$projects = get-childitem -Filter *.vbproj -Recurse
foreach ($proj in $projects)
{
 #first, generate the nuget files
 #this assumes that we have nuget in the path
 Write-Output "Preparing to process projects in directory tree '$($proj.DirectoryName)' "
 pushd $proj.DirectoryName
 nuget spec -f
 Write-Output "Processing project $($proj.FullName)"
    $ns = @{e='http://schemas.microsoft.com/developer/msbuild/2003'}
    #$xml = [System.Xml.XmlDocument](get-content $proj.FullName);
 $xml = New-Object System.Xml.XmlDocument
 $xml.Load($proj.FullName)
    $node = (select-xml -namespace $ns -xml $xml -XPath '//e:PostBuildEvent').node

    $outputType =(select-xml -namespace $ns -xml $xml -XPath '//e:OutputType').node.InnerText
 
 if ($outputType -eq 'Library')
    {
  Write-Output "Making the  specification for $($proj.DirectoryName)"
  
  makespec $proj.DirectoryName
        if ($node -ne $null)
  #found that there's a PostBuildEvent node already
        {
   $node.InnerText = $buildEventText
        }
        else
        {
             #create the node
    $projectNode = (select-xml -namespace $ns -xml $xml -XPath '/e:Project').node
    $pgElement = $xml.CreateElement('PropertyGroup', 'http://schemas.microsoft.com/developer/msbuild/2003')
    $pbeElement = $xml.CreateElement('PostBuildEvent', 'http://schemas.microsoft.com/developer/msbuild/2003')
    $pbeElement.InnerText = $buildEventText
    $pgElement.AppendChild($pbeElement)
    $projectNode.AppendChild($pgElement)
        }
  Set-ItemProperty $proj -name IsReadOnly -value $false
  $xml.Save($proj.FullName)
  Write-Output "Updated $proj.FullName"
 
    }
}
Write-Output "Finished processing projects in $solutionDir"
popd
It's a hell of a lot simpler than it looks. You simply copy it to the solution directory and run it, or run it from where it currently resides but passing the solutions folder name as a parameter. It traverses the solutions folder structure and amends every single .NET project according to the four steps above. It also creates the nupack.ps1 script at the solution level. The line of code

$buildEventText = 'Powershell.exe $(SolutionDir)nupack.ps1 -projectdir $(ProjectDir) -config $(ConfigurationName) -outdir $(OutDir)'
specifies the command that will be run by the post-build event. This is inserted directly into the project file's XML.

Getting at the information in the project file

Manipulating a VB.NET (or C#) project file using PowerShell is a bit more involved than one normally comes across.

The XML resides in its own custom namespace, so the usual PowerShell shortcut of $variable.tagname can't be used. Instead we have to resort to declaring a namespace in an associative array and then using this in our path expressions:


 $ns = @{e='http://schemas.microsoft.com/developer/msbuild/2003'}
    #$xml = [System.Xml.XmlDocument](get-content $proj.FullName);
 $xml = New-Object System.Xml.XmlDocument
 $xml.Load($proj.FullName)
    $node = (select-xml -namespace $ns -xml $xml -XPath '//e:PostBuildEvent').node
You need to change the line
#constant definitions
[string]$repositoryLocation = "\\servername\packagesharename"
to point to the location where the packages will be stored as well.

This script is probably too verbose and a real PowerShell wizard might have pulled off the same trick in half the number of lines. Still, running this will instrument your entire solution to get it to package all its projects and copy them to the specified shared once you've built. You use it at your own risk: make a copy of your solution in a new folder structure before you let this loose on it.

I look forward to suggestions as to how this script might be improved.