├── .gitignore ├── .vscode └── launch.json ├── Build.ps1 ├── LICENSE.md ├── README.md └── Source ├── Environment.psd1 ├── Environment.psm1 ├── Private ├── LoadSpecialFolder.ps1 └── _globals.ps1 └── Public ├── Add-Path.ps1 ├── Get-SpecialFolder.ps1 ├── Select-UniquePath.ps1 ├── Set-AliasToFirst.ps1 ├── Set-EnvironmentVariable.ps1 └── Trace-Message.ps1 /.gitignore: -------------------------------------------------------------------------------- 1 | # ignore packages (this is for nuget stuff) 2 | /packages/* 3 | # ignore output (because we put stuff there) 4 | /output/* 5 | # Ignore version number folders (these are our intermediate "output" directories for testing) 6 | /[0-9]*/ -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "PowerShell", 6 | "type": "PowerShell", 7 | "request": "launch", 8 | "program": "${file}", 9 | "args": [], 10 | "cwd": "${file}" 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /Build.ps1: -------------------------------------------------------------------------------- 1 | #requires -Version "4.0" -Module PackageManagement, Configuration, Pester 2 | [CmdletBinding()] 3 | param( 4 | # The step(s) to run. Defaults to "Clean", "Update", "Build", "Test", "Package" 5 | # You may also "Publish" 6 | # It's also acceptable to skip the "Clean" and particularly "Update" steps 7 | [ValidateSet("Clean", "Update", "Build", "Test", "Package", "Publish")] 8 | [string[]]$Step = @("Clean", "Update", "Build", "Test"), 9 | 10 | # The path to the module to build. Defaults to the folder this script is in. 11 | [Alias("PSPath")] 12 | [string]$Path = $PSScriptRoot, 13 | 14 | [string]$ModuleName = $(Split-Path $Path -Leaf), 15 | 16 | # The target framework for .net (for packages), with fallback versions 17 | # The default supports PS3: "net40","net35","net20","net45" 18 | # To only support PS4, use: "net45","net40","net35","net20" 19 | # To support PS2, you use: "net35","net20" 20 | [string[]]$TargetFramework = @("net40","net35","net20","net45"), 21 | 22 | # The revision number (pulled from the environment in AppVeyor) 23 | [Nullable[int]]$RevisionNumber = ${Env:APPVEYOR_BUILD_NUMBER}, 24 | 25 | [ValidateNotNullOrEmpty()] 26 | [String]$CodeCovToken = ${ENV:CODECOV_TOKEN}, 27 | 28 | # The default language is your current UICulture 29 | [Globalization.CultureInfo]$DefaultLanguage = $((Get-Culture).Name) 30 | ) 31 | 32 | $Script:TraceVerboseTimer = New-Object System.Diagnostics.Stopwatch 33 | $Script:TraceVerboseTimer.Start() 34 | 35 | 36 | $ErrorActionPreference = "Stop" 37 | Set-StrictMode -Version Latest 38 | 39 | function init { 40 | #.Synopsis 41 | # The init step always has to run. 42 | # Calculate your paths and so-on here. 43 | [CmdletBinding()] 44 | param() 45 | 46 | # Calculate Paths 47 | # The output path is just a temporary output and logging location 48 | $Script:OutputPath = Join-Path $Path output 49 | $null = mkdir $OutputPath -Force 50 | 51 | # We expect the source for the module in a subdirectory called one of three things: 52 | $Script:SourcePath = "src", "source", ${ModuleName} | ForEach { Join-Path $Path $_ -Resolve -ErrorAction Ignore } | Select -First 1 53 | if(!$SourcePath) { 54 | Write-Warning "This Build script expects a 'Source' or '$ModuleName' folder to be alongside it." 55 | throw "Can't find module source folder." 56 | } 57 | 58 | $Script:ManifestPath = Join-Path $SourcePath "${ModuleName}.psd1" -Resolve -ErrorAction Ignore 59 | if(!$ManifestPath) { 60 | Write-Warning "This Build script expects a '${ModuleName}.psd1' in the '$SourcePath' folder." 61 | throw "Can't find module source files" 62 | } 63 | $Script:TestPath = "Tests", "Specs" | ForEach { Join-Path $Path $_ -Resolve -ErrorAction Ignore } | Select -First 1 64 | if(!$TestPath) { 65 | Write-Warning "This Build script expects a 'Tests' or 'Specs' folder to contain tests." 66 | } 67 | # Calculate Version here, because we need it for the release path 68 | [Version]$Script:Version = Get-Metadata $ManifestPath -PropertyName ModuleVersion 69 | 70 | # If the RevisionNumber is specified as ZERO, this is a release build ... 71 | # If the RevisionNumber is not specified, this is a dev box build 72 | # If the RevisionNumber is specified, we assume this is a CI build 73 | if($Script:RevisionNumber -ge 0) { 74 | # For CI builds we don't increment the build number 75 | $Script:Build = if($Version.Build -le 0) { 0 } else { $Version.Build } 76 | } else { 77 | # For dev builds, assume we're working on the NEXT release 78 | $Script:Build = if($Version.Build -le 0) { 1 } else { $Version.Build + 1} 79 | } 80 | 81 | if([string]::IsNullOrEmpty($RevisionNumber) -or $RevisionNumber -eq 0) { 82 | $Script:Version = New-Object Version $Version.Major, $Version.Minor, $Build 83 | } else { 84 | $Script:Version = New-Object Version $Version.Major, $Version.Minor, $Build, $RevisionNumber 85 | } 86 | 87 | # The release path is where the final module goes 88 | $Script:ReleasePath = Join-Path $Path $Version 89 | $Script:ReleaseManifest = Join-Path $ReleasePath "${ModuleName}.psd1" 90 | 91 | } 92 | 93 | function clean { 94 | #.Synopsis 95 | # Clean output and old log 96 | [CmdletBinding()] 97 | param( 98 | # Also clean packages 99 | [Switch]$Packages 100 | ) 101 | 102 | Trace-Message "OUTPUT Release Path: $ReleasePath" 103 | if(Test-Path $ReleasePath) { 104 | Trace-Message " Clean up old build" 105 | Trace-Message "DELETE $ReleasePath\" 106 | Remove-Item $ReleasePath -Recurse -Force 107 | } 108 | if(Test-Path $Path\packages) { 109 | Trace-Message "DELETE $Path\packages" 110 | # force reinstall by cleaning the old ones 111 | Remove-Item $Path\packages\ -Recurse -Force 112 | } 113 | if(Test-Path $Path\packages\build.log) { 114 | Trace-Message "DELETE $OutputPath\build.log" 115 | Remove-Item $OutputPath\build.log -Recurse -Force 116 | } 117 | 118 | } 119 | 120 | function update { 121 | #.Synopsis 122 | # Nuget restore and git submodule update 123 | #.Description 124 | # This works like nuget package restore, but using PackageManagement 125 | # The benefit of using PackageManagement is that you can support any provider and any source 126 | # However, currently only the nuget providers supports a -Destination 127 | # So for most cases, you could use nuget restore instead: 128 | # nuget restore $(Join-Path $Path packages.config) -PackagesDirectory "$Path\packages" -ExcludeVersion -PackageSaveMode nuspec 129 | [CmdletBinding()] 130 | param( 131 | # Force reinstall 132 | [switch]$Force=$($Step -contains "Clean"), 133 | 134 | # Remove packages first 135 | [switch]$Clean 136 | ) 137 | $ErrorActionPreference = "Stop" 138 | Set-StrictMode -Version Latest 139 | Trace-Message "UPDATE $ModuleName in $Path" 140 | 141 | if(Test-Path (Join-Path $Path packages.config)) { 142 | if(!($Name = Get-PackageSource | ? Location -eq 'https://www.nuget.org/api/v2' | % Name)) { 143 | Write-Warning "Adding NuGet package source" 144 | $Name = Register-PackageSource NuGet -Location 'https://www.nuget.org/api/v2' -ForceBootstrap -ProviderName NuGet | % Name 145 | } 146 | 147 | if($Force -and (Test-Path $Path\packages)) { 148 | # force reinstall by cleaning the old ones 149 | remove-item $Path\packages\ -Recurse -Force 150 | } 151 | $null = mkdir $Path\packages\ -Force 152 | 153 | # Remember, as of now, only nuget actually supports the -Destination flag 154 | foreach($Package in ([xml](gc .\packages.config)).packages.package) { 155 | Trace-Message "Installing $($Package.id) v$($Package.version) from $($Package.Source)" 156 | $install = Install-Package -Name $Package.id -RequiredVersion $Package.version -Source $Package.Source -Destination $Path\packages -Force:$Force -ErrorVariable failure 157 | if($failure) { 158 | throw "Failed to install $($package.id), see errors above." 159 | } 160 | } 161 | } 162 | 163 | # we also check for git submodules... 164 | git submodule update --init --recursive 165 | } 166 | 167 | function build { 168 | [CmdletBinding()] 169 | param() 170 | Trace-Message "BUILDING: $ModuleName from $Path" 171 | # Copy NuGet dependencies 172 | $PackagesConfig = (Join-Path $Path packages.config) 173 | if(Test-Path $PackagesConfig) { 174 | Trace-Message " Copying Packages" 175 | foreach($Package in ([xml](Get-Content $PackagesConfig)).packages.package) { 176 | $LibPath = "$ReleasePath\lib" 177 | $folder = Join-Path $Path "packages\$($Package.id)*" 178 | 179 | # The git NativeBinaries are special -- we need to copy all the "windows" binaries: 180 | if($Package.id -eq "LibGit2Sharp.NativeBinaries") { 181 | $targets = Join-Path $folder 'libgit2\windows' 182 | $LibPath = Join-Path $LibPath "NativeBinaries" 183 | } else { 184 | # Check for each TargetFramework, in order of preference, fall back to using the lib folder 185 | $targets = ($TargetFramework -replace '^','lib\') + 'lib' | ForEach-Object { Join-Path $folder $_ } 186 | } 187 | 188 | $PackageSource = Get-Item $targets -ErrorAction SilentlyContinue | Select -First 1 -Expand FullName 189 | if(!$PackageSource) { 190 | throw "Could not find a lib folder for $($Package.id) from package. You may need to run Setup.ps1" 191 | } 192 | 193 | Trace-Message "robocopy $PackageSource $LibPath /E /NP /LOG+:'$OutputPath\build.log' /R:2 /W:15" 194 | $null = robocopy $PackageSource $LibPath /E /NP /LOG+:"$OutputPath\build.log" /R:2 /W:15 195 | if($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne 1 -and $LASTEXITCODE -ne 3) { 196 | throw "Failed to copy Package $($Package.id) (${LASTEXITCODE}), see build.log for details" 197 | } 198 | } 199 | } 200 | 201 | 202 | ## Copy PowerShell source Files (support for my new Public|Private folders, and the old simple copy way) 203 | # if the Source folder has "Public" and optionally "Private" in it, then the psm1 must be assembled: 204 | if(Test-Path (Join-Path $SourcePath Public) -Type Container){ 205 | Trace-Message " Collating Module Source" 206 | $RootModule = Get-Metadata -Path $ManifestPath -PropertyName RootModule -ErrorAction SilentlyContinue 207 | if(!$RootModule) { 208 | $RootModule = Get-Metadata -Path $ManifestPath -PropertyName ModuleToProcess -ErrorAction SilentlyContinue 209 | if(!$RootModule) { 210 | $RootModule = "${ModuleName}.psm1" 211 | } 212 | } 213 | $null = mkdir $ReleasePath -Force 214 | $ReleaseModule = Join-Path $ReleasePath ${RootModule} 215 | $FunctionsToExport = Join-Path $SourcePath Public\*.ps1 -Resolve | % { [System.IO.Path]::GetFileNameWithoutExtension($_) } 216 | Trace-Message " Setting content for $ReleaseModule from $FunctionsToExport" 217 | Set-Content $ReleaseModule (( 218 | (Get-Content (Join-Path $SourcePath Private\*.ps1) -Raw) + 219 | (Get-Content (Join-Path $SourcePath Public\*.ps1) -Raw)) -join "`r`n`r`n`r`n") -Encoding UTF8 220 | 221 | # If there are any folders that aren't Public, Private, Tests, or Specs ... 222 | if($OtherFolders = Get-ChildItem $SourcePath -Directory -Exclude Public, Private, Tests, Specs) { 223 | # Then we need to copy everything in them 224 | Copy-Item $OtherFolders -Recurse -Destination $ReleasePath 225 | } 226 | 227 | # Finally, we need to copy any files in the Source directory 228 | Get-ChildItem $SourcePath -File | 229 | Where Name -ne $RootModule | 230 | Copy-Item -Destination $ReleasePath 231 | 232 | Update-Manifest $ReleaseManifest -Property FunctionsToExport -Value $FunctionsToExport 233 | } else { 234 | # Legacy modules just have "stuff" in the source folder and we need to copy all of it 235 | Trace-Message " Copying Module Source" 236 | Trace-Message "COPY $SourcePath\" 237 | $null = robocopy $SourcePath\ $ReleasePath /E /NP /LOG+:"$OutputPath\build.log" /R:2 /W:15 238 | if($LASTEXITCODE -ne 3 -AND $LASTEXITCODE -ne 1) { 239 | throw "Failed to copy Module (${LASTEXITCODE}), see build.log for details" 240 | } 241 | } 242 | 243 | # Copy the readme file as an about_ help 244 | $ReadMe = Join-Path $Path Readme.md 245 | if(Test-Path $ReadMe -PathType Leaf) { 246 | $LanguagePath = Join-Path $ReleasePath $DefaultLanguage 247 | $null = mkdir $LanguagePath -Force 248 | $about_module = Join-Path $LanguagePath "about_${ModuleName}.help.txt" 249 | if(!(Test-Path $about_module)) { 250 | Trace-Message "Turn readme into about_module" 251 | Copy-Item -LiteralPath $ReadMe -Destination $about_module 252 | } 253 | } 254 | 255 | ## Update the PSD1 Version: 256 | Trace-Message " Update Module Version" 257 | Push-Location $ReleasePath 258 | $FileList = Get-ChildItem -Recurse -File | Resolve-Path -Relative 259 | Update-Metadata -Path $ReleaseManifest -PropertyName 'ModuleVersion' -Value $Version 260 | Update-Metadata -Path $ReleaseManifest -PropertyName 'FileList' -Value $FileList 261 | Pop-Location 262 | (Get-Module $ReleaseManifest -ListAvailable | Out-String -stream) -join "`n" | Trace-Message 263 | } 264 | 265 | function test { 266 | [CmdletBinding()] 267 | param( 268 | [Switch]$Quiet, 269 | 270 | [Switch]$ShowWip, 271 | 272 | [int]$FailLimit=${Env:ACCEPTABLE_FAILURE}, 273 | 274 | [ValidateNotNullOrEmpty()] 275 | [String]$JobID = ${Env:APPVEYOR_JOB_ID} 276 | ) 277 | 278 | if(!$TestPath) { 279 | Write-Warning "No tests folder found. Invoking Pester in root: $Path" 280 | $TestPath = $Path 281 | } 282 | 283 | Trace-Message "TESTING: $ModuleName with $TestPath" 284 | 285 | Trace-Message "TESTING $ModuleName v$Version" -Verbose:(!$Quiet) 286 | Remove-Module $ModuleName -ErrorAction SilentlyContinue 287 | 288 | $Options = @{ 289 | OutputFormat = "NUnitXml" 290 | OutputFile = (Join-Path $OutputPath TestResults.xml) 291 | } 292 | if($Quiet) { $Options.Quiet = $Quiet } 293 | if(!$ShowWip){ $Options.ExcludeTag = @("wip") } 294 | 295 | Set-Content "$TestPath\.Do.Not.COMMIT.This.Steps.ps1" "Import-Module $ReleasePath\${ModuleName}.psd1 -Force" 296 | 297 | # Show the commands they would have to run to get these results: 298 | Write-Host $(prompt) -NoNewLine 299 | Write-Host Import-Module $ReleasePath\${ModuleName}.psd1 -Force 300 | Write-Host $(prompt) -NoNewLine 301 | 302 | # TODO: Update dependency to Pester 4.0 and use just Invoke-Pester 303 | if(Get-Command Invoke-Gherkin -ErrorAction SilentlyContinue) { 304 | Write-Host Invoke-Gherkin -Path $TestPath -CodeCoverage "$ReleasePath\*.psm1" -PassThru @Options 305 | $TestResults = Invoke-Gherkin -Path $TestPath -CodeCoverage "$ReleasePath\*.psm1" -PassThru @Options 306 | } 307 | 308 | # Write-Host Invoke-Pester -Path $TestPath -CodeCoverage "$ReleasePath\*.psm1" -PassThru @Options 309 | # $TestResults = Invoke-Pester -Path $TestPath -CodeCoverage "$ReleasePath\*.psm1" -PassThru @Options 310 | 311 | Remove-Module $ModuleName -ErrorAction SilentlyContinue 312 | Remove-Item "$TestPath\.Do.Not.COMMIT.This.Steps.ps1" 313 | 314 | $script:failedTestsCount = 0 315 | $script:passedTestsCount = 0 316 | foreach($result in $TestResults) 317 | { 318 | if($result -and $result.CodeCoverage.NumberOfCommandsAnalyzed -gt 0) 319 | { 320 | $script:failedTestsCount += $result.FailedCount 321 | $script:passedTestsCount += $result.PassedCount 322 | $CodeCoverageTitle = 'Code Coverage {0:F1}%' -f (100 * ($result.CodeCoverage.NumberOfCommandsExecuted / $result.CodeCoverage.NumberOfCommandsAnalyzed)) 323 | 324 | # TODO: this file mapping does not account for the new Public|Private module source (and I don't know how to make it do so) 325 | # Map file paths, e.g.: \1.0 back to \src 326 | for($i=0; $i -lt $TestResults.CodeCoverage.HitCommands.Count; $i++) { 327 | $TestResults.CodeCoverage.HitCommands[$i].File = $TestResults.CodeCoverage.HitCommands[$i].File.Replace($ReleasePath, $SourcePath) 328 | } 329 | for($i=0; $i -lt $TestResults.CodeCoverage.MissedCommands.Count; $i++) { 330 | $TestResults.CodeCoverage.MissedCommands[$i].File = $TestResults.CodeCoverage.MissedCommands[$i].File.Replace($ReleasePath, $SourcePath) 331 | } 332 | 333 | if($result.CodeCoverage.MissedCommands.Count -gt 0) { 334 | $result.CodeCoverage.MissedCommands | 335 | ConvertTo-Html -Title $CodeCoverageTitle | 336 | Out-File (Join-Path $OutputPath "CodeCoverage-${Version}.html") 337 | } 338 | if(${CodeCovToken}) 339 | { 340 | # TODO: https://github.com/PoshCode/PSGit/blob/dev/test/Send-CodeCov.ps1 341 | Trace-Message "Sending CI Code-Coverage Results" -Verbose:(!$Quiet) 342 | $response = &"$TestPath\Send-CodeCov" -CodeCoverage $result.CodeCoverage -RepositoryRoot $Path -OutputPath $OutputPath -Token ${CodeCovToken} 343 | Trace-Message $response.message -Verbose:(!$Quiet) 344 | } 345 | } 346 | } 347 | 348 | # If we're on AppVeyor .... 349 | if(Get-Command Add-AppveyorCompilationMessage -ErrorAction SilentlyContinue) { 350 | Add-AppveyorCompilationMessage -Message ("{0} of {1} tests passed" -f @($TestResults.PassedScenarios).Count, (@($TestResults.PassedScenarios).Count + @($TestResults.FailedScenarios).Count)) -Category $(if(@($TestResults.FailedScenarios).Count -gt 0) { "Warning" } else { "Information"}) 351 | Add-AppveyorCompilationMessage -Message ("{0:P} of code covered by tests" -f ($TestResults.CodeCoverage.NumberOfCommandsExecuted / $TestResults.CodeCoverage.NumberOfCommandsAnalyzed)) -Category $(if($TestResults.CodeCoverage.NumberOfCommandsExecuted -lt $TestResults.CodeCoverage.NumberOfCommandsAnalyzed) { "Warning" } else { "Information"}) 352 | } 353 | 354 | if(${JobID}) { 355 | if(Test-Path $Options.OutputFile) { 356 | Trace-Message "Sending Test Results to AppVeyor backend" -Verbose:(!$Quiet) 357 | $wc = New-Object 'System.Net.WebClient' 358 | $response = $wc.UploadFile("https://ci.appveyor.com/api/testresults/nunit/${JobID}", $Options.OutputFile) 359 | if($response) { 360 | Trace-Message ([System.Text.Encoding]::ASCII.GetString($response)) -Verbose:(!$Quiet) 361 | } 362 | } else { 363 | Write-Warning "Couldn't find Test Output: $($Options.OutputFile)" 364 | } 365 | } 366 | 367 | if($FailedTestsCount -gt $FailLimit) { 368 | $exception = New-Object AggregateException "Failed Scenarios:`n`t`t'$($TestResults.FailedScenarios.Name -join "'`n`t`t'")'" 369 | $errorRecord = New-Object System.Management.Automation.ErrorRecord $exception, "FailedScenarios", "LimitsExceeded", $Results 370 | $PSCmdlet.ThrowTerminatingError($errorRecord) 371 | } 372 | } 373 | 374 | function package { 375 | [CmdletBinding()] 376 | param() 377 | 378 | Trace-Message "robocopy '$ReleasePath' '${OutputPath}\${ModuleName}' /MIR /NP " 379 | $null = robocopy $ReleasePath "${OutputPath}\${ModuleName}" /MIR /NP /LOG+:"$OutputPath\build.log" 380 | 381 | $zipFile = Join-Path $OutputPath "${ModuleName}-${Version}.zip" 382 | Add-Type -assemblyname System.IO.Compression.FileSystem 383 | Remove-Item $zipFile -ErrorAction SilentlyContinue 384 | Trace-Message "ZIP $zipFile" 385 | [System.IO.Compression.ZipFile]::CreateFromDirectory((Join-Path $OutputPath $ModuleName), $zipFile) 386 | 387 | # You can add other artifacts here 388 | ls $OutputPath -File 389 | } 390 | 391 | function Trace-Message { 392 | [CmdletBinding()] 393 | param( 394 | [Parameter(Mandatory=$true, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] 395 | [string]$Message, 396 | 397 | [switch]$AsWarning, 398 | 399 | [switch]$ResetTimer, 400 | 401 | [switch]$KillTimer, 402 | 403 | [Diagnostics.Stopwatch]$Stopwatch 404 | ) 405 | begin { 406 | if($Stopwatch) { 407 | $Script:TraceTimer = $Stopwatch 408 | $Script:TraceTimer.Start() 409 | } 410 | if(!(Test-Path Variable:Script:TraceTimer)) { 411 | $Script:TraceTimer = New-Object System.Diagnostics.Stopwatch 412 | $Script:TraceTimer.Start() 413 | } 414 | if($ResetTimer) 415 | { 416 | $Script:TraceTimer.Restart() 417 | } 418 | } 419 | 420 | process { 421 | $Script = Split-Path $MyInvocation.ScriptName -Leaf 422 | $Command = (Get-PSCallStack)[1].Command 423 | if($Script -ne $Command) { 424 | $Message = "{0} - at {1} Line {2} ({4}) | {3}" -f $Message, $Script, $MyInvocation.ScriptLineNumber, $TraceTimer.Elapsed, $Command 425 | } else { 426 | $Message = "{0} - at {1} Line {2} | {3}" -f $Message, $Script, $MyInvocation.ScriptLineNumber, $TraceTimer.Elapsed 427 | } 428 | 429 | if($AsWarning) { 430 | Write-Warning $Message 431 | } else { 432 | Write-Verbose $Message 433 | } 434 | } 435 | 436 | end { 437 | if($KillTimer) { 438 | $Script:TraceTimer.Stop() 439 | $Script:TraceTimer = $null 440 | } 441 | } 442 | } 443 | 444 | # First call to Trace-Message, pass in our TraceTimer to make sure we time EVERYTHING. 445 | Trace-Message "BUILDING: $ModuleName in $Path" -Stopwatch $TraceVerboseTimer 446 | 447 | Push-Location $Path 448 | 449 | init 450 | 451 | foreach($s in $step){ 452 | Trace-Message "Invoking Step: $s" 453 | &$s 454 | } 455 | 456 | Pop-Location 457 | Trace-Message "FINISHED: $ModuleName in $Path" -KillTimer -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2018 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | The Environment PowerShell module is a module for dealing with Paths and Environment variables. 2 | 3 | * `Set-EnvironmentVariable` allows you to set an Environment variable _permanently_ at the Machine or User level, or temporarily at the process level. 4 | * `Select-UniquePath` allows you to de-dupe an array of path strings 5 | * `Add-Path` uses the first two to add folders to path variables like `$Env:PSModulePath` or `$Env:PATH` without duplication 6 | * `Set-AliasToFirst` searches a list of paths to find the first instance of an app and create an alias pointed to it (allowing you to avoid adding folders to the environment Path variable for a single application). 7 | * `Get-SpecialFolder` helps Windows users find special folders (like the user's desktop) 8 | * `Trace-Message` writes verbose (or debug or warning) messages with timestamps for script timing 9 | 10 | ```posh 11 | Install-Module Environment 12 | ``` 13 | 14 | -------------------------------------------------------------------------------- /Source/Environment.psd1: -------------------------------------------------------------------------------- 1 | @{ 2 | RootModule = 'Environment.psm1' 3 | ModuleVersion = '1.1.0' 4 | GUID = 'fa42d62c-3f2a-426e-bb36-e1c6be2ff2e1' 5 | Author = 'Joel Bennett' 6 | CompanyName = 'HuddledMasses.org' 7 | Copyright = '(c) 2016,2018 Joel Bennett. All rights reserved.' 8 | Description = 'Provides Trace-Message, and functions for working with Environment and Path variables' 9 | # For best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. 10 | FunctionsToExport = '*' 11 | FileList = @() 12 | PrivateData = @{ 13 | # PowerShellGet module 14 | PSData = @{ 15 | # Tags for PowerShellGallery 16 | Tags = @('Environment','Path','Trace','Message') 17 | ReleaseNotes = 'Fixed Select-UniquePath to avoid problems with paths in hidden folders' 18 | 19 | # URIs for PowerShellGallery 20 | LicenseUri = 'https://github.com/Jaykul/Environment/blob/master/LICENSE.md' 21 | ProjectUri = 'https://github.com/Jaykul/Environment' 22 | } # End of PSData hashtable 23 | } # End of PrivateData hashtable 24 | } 25 | 26 | -------------------------------------------------------------------------------- /Source/Environment.psm1: -------------------------------------------------------------------------------- 1 | foreach($private in Join-Path $PSScriptRoot Private\*.ps1 -Resolve -ErrorAction SilentlyContinue) { 2 | . $private 3 | } 4 | foreach($public in Join-Path $PSScriptRoot Private\*.ps1 -Resolve -ErrorAction SilentlyContinue) { 5 | . $public 6 | } 7 | -------------------------------------------------------------------------------- /Source/Private/LoadSpecialFolder.ps1: -------------------------------------------------------------------------------- 1 | function LoadSpecialFolders { 2 | [CmdletBinding()]param() 3 | Write-Information "LoadSpecialFolders" -Tags "Trace", "Enter" 4 | 5 | $Script:SpecialFolders = [Ordered]@{} 6 | 7 | if("System.Environment+SpecialFolder" -as [type]) { 8 | foreach($name in [System.Environment+SpecialFolder].GetFields("Public,Static") | Sort-Object Name) { 9 | $Script:SpecialFolders.($name.Name) = [int][System.Environment+SpecialFolder]$name.Name 10 | 11 | if($Name.Name.StartsWith("My")) { 12 | $Script:SpecialFolders.($name.Name.Substring(2)) = [int][System.Environment+SpecialFolder]$name.Name 13 | } 14 | } 15 | } else { 16 | Write-Warning "SpecialFolder Enumeration not found, you're on your own." 17 | } 18 | $Script:SpecialFolders.CommonModules = Join-Path $Env:ProgramFiles "WindowsPowerShell\Modules" 19 | $Script:SpecialFolders.CommonProfile = (Split-Path $Profile.AllUsersAllHosts) 20 | $Script:SpecialFolders.Modules = Join-Path (Split-Path $Profile.CurrentUserAllHosts) "Modules" 21 | $Script:SpecialFolders.Profile = (Split-Path $Profile.CurrentUserAllHosts) 22 | $Script:SpecialFolders.PSHome = $PSHome 23 | $Script:SpecialFolders.SystemModules = Join-Path (Split-Path $Profile.AllUsersAllHosts) "Modules" 24 | 25 | Write-Information "LoadSpecialFolders" -Tags "Trace", "Exit" 26 | } 27 | -------------------------------------------------------------------------------- /Source/Private/_globals.ps1: -------------------------------------------------------------------------------- 1 | # if you're running "elevated" or sudo, we want to know that: 2 | try { 3 | if(-not ($IsLinux -or $IsOSX)) { 4 | $global:PSProcessElevated = [Security.Principal.WindowsIdentity]::GetCurrent().Owner.IsWellKnown("BuiltInAdministratorsSid") 5 | } else { 6 | $global:PSProcessElevated = 0 -eq (id -u) 7 | } 8 | } catch {} 9 | $Script:SpecialFolders = [Ordered]@{} 10 | $OFS = [IO.Path]::PathSeparator 11 | -------------------------------------------------------------------------------- /Source/Public/Add-Path.ps1: -------------------------------------------------------------------------------- 1 | 2 | function Add-Path { 3 | #.Synopsis 4 | # Add a folder to a path environment variable 5 | #.Description 6 | # Gets the existing content of the path variable, splits it with the PathSeparator, 7 | # adds the specified paths, and then joins them and re-sets the EnvironmentVariable 8 | [CmdletBinding()] 9 | param( 10 | [Parameter(Position=0, Mandatory=$True)] 11 | [String]$Name, 12 | 13 | [Parameter(Position=1)] 14 | [String[]]$Append = @(), 15 | 16 | [String[]]$Prepend = @(), 17 | 18 | [System.EnvironmentVariableTarget] 19 | $Scope="User", 20 | 21 | [Char] 22 | $Separator = [System.IO.Path]::PathSeparator 23 | ) 24 | Write-Information "Add-Path $Name $Append | $Prepend" -Tags "Trace", "Enter" 25 | 26 | # Make the new thing as an array so we don't get duplicates 27 | $Path = @($Prepend -split "$Separator" | %{ $_.TrimEnd("\/") } | ?{ $_ }) 28 | Write-Information ([Environment]::GetEnvironmentVariable($Name, $Scope)) -Tags "Debug", "Before", "Env:${Scope}:${Name}" 29 | $Path += $OldPath = @([Environment]::GetEnvironmentVariable($Name, $Scope) -split "$Separator" | %{ $_.TrimEnd("\/") }| ?{ $_ }) 30 | $Path += @($Append -split "$Separator" | %{ $_.TrimEnd("\/") }| ?{ $_ }) 31 | 32 | # Dedup path 33 | # If the path actually exists, use the actual case of the folder 34 | $Path = $(foreach($Folder in $Path) { 35 | if(Test-Path $Folder) { 36 | Get-Item ($Folder -replace '(? 27 | [CmdletBinding(DefaultParameterSetName="VerboseOutput")] 28 | param( 29 | # The message to write, or a scriptblock, which, when evaluated, will output a message to write 30 | [Parameter(Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,ParameterSetName="VerboseOutput",Position=0)] 31 | [Parameter(Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,ParameterSetName="WarningOutput",Position=0)] 32 | [Parameter(Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,ParameterSetName="DebugOutput",Position=0)] 33 | [PSObject]$Message, 34 | 35 | # When set, output to the warning stream instead of verbose 36 | [Parameter(Mandatory=$true,ParameterSetName="WarningOutput")] 37 | [Alias("AsWarning")] 38 | [switch]$WarningOutput, 39 | 40 | # When set, output to the debug stream instead of verbose 41 | [Parameter(Mandatory=$true,ParameterSetName="DebugOutput")] 42 | [Alias("AsDebug")] 43 | [switch]$DebugOutput, 44 | 45 | # Reset the timer to time the next block from zero 46 | [switch]$ResetTimer, 47 | 48 | # Clear out the timer completely after this output 49 | # When you explicitly pass a Stopwatch, you can pass this flag (only once) to stop and remove it 50 | [switch]$KillTimer, 51 | 52 | # A custom string overrides the automatic formatting which changes depending on how long the duration is 53 | [string]$ElapsedFormat, 54 | 55 | # If set, show the time since last Trace-Message 56 | [switch]$ShowStepTime, 57 | 58 | # Supports passing in an existing Stopwatch (running or not) 59 | [Diagnostics.Stopwatch]$Stopwatch 60 | ) 61 | begin { 62 | if($Stopwatch) { 63 | ${Script:Trace Message Timer} = $Stopwatch 64 | ${Script:Trace Message Timer}.Start() 65 | } 66 | if(-not ${Trace Message Timer}) { 67 | ${global:Trace Message Timer} = New-Object System.Diagnostics.Stopwatch 68 | ${global:Trace Message Timer}.Start() 69 | 70 | # When no timer is provided... 71 | # Assume the timer is for "run" and 72 | # Clean up automatically at the next prompt 73 | $PreTraceTimerPrompt = $function:prompt 74 | 75 | $function:prompt = { 76 | if(${global:Trace Message Timer}) { 77 | ${global:Trace Message Timer}.Stop() 78 | Remove-Variable "Trace Message Timer" -Scope global -ErrorAction SilentlyContinue 79 | } 80 | & $PreTraceTimerPrompt 81 | ${function:global:prompt} = $PreTraceTimerPrompt 82 | }.GetNewClosure() 83 | } 84 | 85 | $Script:LastElapsed = $Script:Elapsed 86 | $Script:Elapsed = ${Trace Message Timer}.Elapsed.Duration() 87 | 88 | if($ResetTimer -or -not ${Trace Message Timer}.IsRunning) 89 | { 90 | ${Trace Message Timer}.Restart() 91 | } 92 | 93 | # Note this requires a host with RawUi 94 | $w = $Host.UI.RawUi.BufferSize.Width 95 | } 96 | 97 | process { 98 | if(($WarningOutput -and $WarningPreference -eq "SilentlyContinue") -or 99 | ($DebugOutput -and $DebugPreference -eq "SilentlyContinue") -or 100 | ($PSCmdlet.ParameterSetName -eq "VerboseOutput" -and $VerbosePreference -eq "SilentlyContinue")) { return } 101 | 102 | [string]$Message = if($Message -is [scriptblock]) { 103 | ($Message.InvokeReturnAsIs(@()) | Out-String -Stream) -join "`n" 104 | } else { "$Message" } 105 | 106 | $Message = $Message.Trim() 107 | 108 | $Location = if($MyInvocation.ScriptName) { 109 | $Name = Split-Path $MyInvocation.ScriptName -Leaf 110 | "${Name}:" + "$($MyInvocation.ScriptLineNumber)".PadRight(4) 111 | } else { "" } 112 | 113 | $Tail = $(if($ElapsedFormat) { 114 | "{0:$ElapsedFormat}" -f $Elapsed 115 | } 116 | elseif($Elapsed.TotalHours -ge 1.0) { 117 | "{0:h\:mm\:ss\.ffff}" -f $Elapsed 118 | } 119 | elseif($Elapsed.TotaMinutes -ge 1.0) { 120 | "{0:mm\m\ ss\.ffff\s}" -f $Elapsed 121 | } 122 | else { 123 | "{0:ss\.ffff\s}" -f $Elapsed 124 | }).PadLeft(12) 125 | 126 | $Tail = $Location + $Tail 127 | 128 | # "WARNING: ".Length = 10 129 | $Length = ($Message.Length + 10 + $Tail.Length) 130 | # Twenty-five is a minimum 15 character message... 131 | $PaddedLength = if($Length -gt $w -and $w -gt (25 + $Tail.Length)) { 132 | [string[]]$words = -split $message 133 | $short = 10 # "VERBOSE: ".Length 134 | $count = 0 # Word count so far 135 | $lines = 0 136 | do { 137 | do { 138 | $short += 1 + $words[$count++].Length 139 | } while (($words.Count -gt $count) -and ($short + $words[$count].Length) -lt $w) 140 | $Lines++ 141 | if(($Message.Length + $Tail.Length) -gt ($w * $lines)) { 142 | $short = 0 143 | } 144 | } while($short -eq 0) 145 | $Message.Length + ($w - $short) - $Tail.Length 146 | } else { 147 | $w - 10 - $Tail.Length 148 | } 149 | 150 | $Message = "$Message ".PadRight($PaddedLength, "$([char]8331)") + $Tail 151 | 152 | if($WarningOutput) { 153 | Write-Warning $Message 154 | } elseif($DebugOutput) { 155 | Write-Debug $Message 156 | } else { 157 | Write-Verbose $Message 158 | } 159 | } 160 | 161 | end { 162 | if($KillTimer -and ${Trace Message Timer}) { 163 | ${Trace Message Timer}.Stop() 164 | Remove-Variable "Trace Message Timer" -Scope Script -ErrorAction Ignore 165 | Remove-Variable "Trace Message Timer" -Scope Global -ErrorAction Ignore 166 | } 167 | } 168 | } --------------------------------------------------------------------------------