PowerShellTutorial.psm1
$script:xmlTutorial = @"
<?xml version="1.0" encoding="utf-8"?> <LocalTutorialData> </LocalTutorialData> "@ $script:simpleTutorialData = @" @{{ "TutorialModules" = @( # Provide modules that the user can use in the tutorial session here {0} ) "TutorialCommands" = @( # Provide commands that the user can use in the tutorial session here {1} ) "TutorialData" = @( @{{ "instruction" = "provide your instruction here" "answers" = @( "Get-CorrectAnswer1" "Get-CorrectAnswer2" ); "hints" = @{{ # The key can be numbers or string. # If it is a number, the hint will be printed out after that many attempts. # If it is a string, the hint will be printed out if the users put in # that string as their answers. 1 = "first hint" 2 = "second hint" }} # this tutorial step will be mocked if both output and answers keys are present # this means the answer is not run at all "output"="This is what will be printed for the user" }}, @{{ "instruction" = "second step in tutorial"; "answers" = @( "Get-AnotherCorrectAnswer" ); "hints" = @{{ "Get-AnotherAnswer" = "Almost correct. Check your noun" }} "output"="This is what I want the user to see" }}, @{{ "instruction" = "second step in tutorial"; "hints" = @{{ "Get-AnotherAnswer" = "Almost correct. Check your noun" }} # if this key is supplied, then there should not be answers or output keys # this command will be run after the user's input to verify that the user # has the correct response "verification"="`$true" }} ) }} "@ $script:resumeTutorial = $false $script:xmlPath = Join-Path $([System.Environment]::GetFolderPath("LocalApplicationData")) PowerShellTutorial\Tutorial.xml function Get-Response ([string]$response) { $tokens = @() $parse = [System.Management.Automation.Language.Parser]::ParseInput($response, [ref]$tokens, [ref]$null); $result = "" for($i = 0; $i -lt $tokens.Length; $i += 1) { $result += $tokens[$i].Text $result += " " } return $result.Trim() } function Write-Answer ([string]$output) { if ($output -ne $null) { Write-Host "$output" } Write-Host -ForegroundColor Green "Correct!`n" } function Get-TutorialPromptOrAnswer([string[]]$prompt) { $index = 0 Write-Host -NoNewline -ForegroundColor Yellow $prompt[$index] $index += 1 $index %= $prompt.Length $instruction = "" $line = 0 while ($true) { $response = Read-Host if ([string]::IsNullOrWhiteSpace($response)) { break } $response = $response.Trim() if ($line -gt 0) { $instruction += "`n" } $instruction += $response Write-Host -NoNewline -ForegroundColor Yellow $prompt[$index] $index += 1 $index %= $prompt.Length $line += 1 } return $instruction } <# .Synopsis Stop a Tutorial session that you are in. The tutorial session can be restored later with Restore-Tutorial .EXAMPLE Stop-Tutorial #> function Stop-Tutorial { [CmdletBinding()] Param() Begin { } Process { $tutorialNode = Update-TutorialNode $script:DataPath $Global:TutorialIndex CleanUpTutorial } End { } } function CleanUpTutorial { # clean up the prompt Set-Content Function:\prompt $Global:OldPrompt -ErrorAction SilentlyContinue # make all commands visible again if ($Global:AllCommandsBeforeTutorial -ne $null) { $Global:AllCommandsBeforeTutorial | ForEach-Object {$_.Visibility = "Public"} } # Return the sessionstate to original setting foreach ($application in $Global:OldApplications) { $ExecutionContext.SessionState.Applications.Add($application) } foreach ($script in $Global:OldScripts) { $ExecutionContext.SessionState.Scripts.Add($script) } # Remove the proxy functions Remove-Item Function:\Out-Default -Force -ErrorAction SilentlyContinue Remove-Item Function:\Format-List -Force -ErrorAction SilentlyContinue Remove-Item Function:\Format-Table -Force -ErrorAction SilentlyContinue Remove-Item Function:\Format-Wide -Force -ErrorAction SilentlyContinue Remove-Item Function:\Format-Custom -Force -ErrorAction SilentlyContinue # Remove variables $VariablesToCleanUp = @("OldPrompt", "LastOutput", "TutorialAttempts", "TutorialAlmostCorrect", "TutorialIndex", "TutorialHint", "ResultFromAnswer", "TutorialBlocks", "OutputErrorToPipeLine", "Formatted", "TutorialPrompt", "OldApplications", "OldScripts", "TutorialVerification", "TutorialErrors") foreach ($variable in $VariablesToCleanUp) { if (Test-Path "Variable:\$variable") { Remove-Variable -Name $variable -Scope Global -ErrorAction SilentlyContinue } } $Error.Clear() } # Returns a string that represents the TutorialCommandsOrModule key value section of the dictionary in tutorial data file function CreateTutorialCommandOrModuleSection([string[]]$tutorialCommands) { $output = "" if ($null -ne $tutorialCommands -and $tutorialCommands.Count -gt 0) { foreach ($cmd in $tutorialCommands) { $output += "`t`t`"$cmd`"$newline" } } return $output } # Create a tutorial in a module # ModulePath is the path to the module # TutorialCommands is an optional parameter which is list of allowed commands function CreateTutorialInModule([string]$modulePath, [System.Management.Automation.PSCmdlet]$callerPScmdlet, [string[]]$tutorialModules, [string[]]$tutorialCommands) { $tutorialData = $script:simpleTutorialData -f (CreateTutorialCommandOrModuleSection $tutorialModules),(CreateTutorialCommandOrModuleSection $tutorialCommands) if ($Interactive) { $newline = [System.Environment]::NewLine $fileOutput = "@{$newline" if ($null -ne $tutorialModules -and $tutorialModules.Count -gt 0) { $fileOutput += "`t`"TutorialModules`" = @($newline" $fileOutput += CreateTutorialCommandOrModuleSection $tutorialModules $fileOutput += "`t)$newline" } if ($null -ne $tutorialCommands -and $tutorialCommands.Count -gt 0) { $fileOutput += "`t`"TutorialCommands`" = @($newline" $fileOutput += CreateTutorialCommandOrModuleSection $tutorialCommands $fileOutput += "`t)$newline" } $fileOutput += "`t`"TutorialData`" = @($newline" while ($true) { $indentation = "`t`t" Write-Host -ForegroundColor Cyan "$($newline)Write your instruction here. Input a new line to move on to verification" $instruction = Get-TutorialPromptOrAnswer "Instruction> " if ($instruction.IndexOf("`n") -gt 0) { $instruction = "@`"$newline$($instruction.Trim())$newline`"@" } else { $instruction = "`"$instruction`"" } Write-Host -ForegroundColor Cyan "$($newline)Write the command to verify your response here. Input a new line to move on to hints if you provide a command,$($newline)else you will be moved on to answers" $verifications = Get-TutorialPromptOrAnswer "Verification> " $verificationOutputs = "" if (-not [string]::IsNullOrWhiteSpace($verifications)) { $verifications = $verifications.Split("`n") foreach ($verification in $verifications) { $verification = $verification.Trim() if (-not [string]::IsNullOrWhiteSpace($verification)) { $verificationOutputs += $verification } # verification has length longer than 1, attach newline if ($verifications.Length -gt 1) { $verificationOutputs += $newline } } if ($verifications.IndexOf("`n") -gt 0) { $verificationOutputs = "@`"$newline$($verificationOutputs.Trim())$newline`"@" } else { $verificationOutputs = "`"$verificationOutputs`"" } $verificationsOutputs -replace '$true', '`$true' $verificationsOutputs -replace '$false', '`$false' $verificationsOutputs -replace '$null', '`$null' } $hasVerification = -not [string]::IsNullOrWhiteSpace($verificationOutputs) # only move to hint if there is no verification if (-not $hasVerification) { Write-Host -ForegroundColor Cyan "$($newline)Write your acceptable answers here. Input a new line to move on to hints" $answers = Get-TutorialPromptOrAnswer "Answers> " $answersOutput = "" if (-not [string]::IsNullOrWhiteSpace($answers)) { $answers = $answers.Split("`n") $answersOutput = "@($newline" foreach ($answer in $answers) { $answersOutput += "$indentation`t`"$($answer.Trim())`"$newline" } $answersOutput += "$indentation)$newline" } } Write-Host -ForegroundColor Cyan "$($newline)There are two parts of a hint: trigger and the hint itself.$newline"` "The trigger can be a number, which will correspond to the number of times a user will have to enter the response incorrectly for the hint to appear.$newline"` "The trigger can also be a string, which will correspond to the incorrect input that a user will have to enter for the hint to disappear.$newline"` "The hint itself correspond to the output.$newline"` "Input a new line to move on to output (if you did not supply verification)" $hints = Get-TutorialPromptOrAnswer "Trigger> ", "Hint> " $hints = $hints.Split("`n") $lengthOfHint = $hints.Length if (($lengthOfHint % 2) -eq 1) { $lengthOfHint = $lengthOfHint - 1 } $hintsOutput = "" if ($lengthOfHint -gt 0) { $hintsOutput = "@{$newline" # now we have even number of the inputs for ($i = 0; $i -lt $lengthOfHint/2; $i += 1) { $key = $hints[2*$i] $value = $hints[2*$i+1] if ([string]::IsNullOrWhiteSpace($key) -or [string]::IsNullOrWhiteSpace($value)) { continue; } [int]$keyNumber = $null if ([int32]::TryParse($key.Trim(), [ref]$keyNumber)) { $hintsOutput += "$indentation`t$keyNumber = `"$($value.Trim())`"$newline" } else { $hintsOutput += "$indentation`t`"$key`" = `"$($value.Trim())`"$newline" } } $hintsOutput += "$indentation}$newline" } if (-not $hasVerification) { Write-Host -ForegroundColor Cyan "$($newline)Write your output here, otherwise it will be the output of the first acceptable response$($newline). If you want to pipe the output from a command, type Run-Command: <Your Command>. Input a new line to move on to the next tutorial block" $outputs = Get-TutorialPromptOrAnswer "Output> " $outputsOutput = "" if (-not [string]::IsNullOrWhiteSpace($outputs)) { $outputs = $outputs.Split("`n") foreach ($output in $outputs) { $output = $output.Trim() if ($output.StartsWith("Run-Command:", "CurrentCultureIgnoreCase")) { $command = $output.Substring("Run-Command:".Length).Trim(); try { $output = Invoke-Expression $command | Out-String } catch { } } $outputsOutput += $output if ($outputs.Length -gt 1) { $outputsOutput += $newline } } if ($outputsOutput.IndexOf("`n") -gt 0) { $outputsOutput = "@`"$newline$($outputsOutput.Trim())$newline`"@" } else { $outputsOutput = "`"$outputsOutput`"" } } } $tutorialBlock = "`t,@{$newline" $tutorialBlock += "$indentation`"instruction`" = $instruction" $tutorialBlock += $newline if (-not [string]::IsNullOrWhiteSpace($hintsOutput)) { $tutorialBlock += "$indentation`"hints`" = $hintsOutput" $tutorialBlock += $newline } if (-not [string]::IsNullOrWhiteSpace($verificationOutputs)) { $tutorialBlock += "$indentation`"verification`" = $verificationOutputs" $tutorialBlock += $newline } if (-not [string]::IsNullOrWhiteSpace($answersOutput)) { $tutorialBlock += "$indentation`"answers`" = $answersOutput" $tutorialBlock += $newline } if (-not [string]::IsNullOrWhiteSpace($outputsOutput)) { $tutorialBlock += "$indentation`"output`" = $outputsOutput" } $tutorialBlock += $newline $tutorialBlock += "`t}$newline" $hintsOutput = $null $verificationOutputs = $null $answersOutput = $null $outputsOutput = $null $fileOutput += $tutorialBlock Write-Host -ForegroundColor Cyan "Tutorial block created." if ($PSCmdlet.ShouldContinue("Create more tutorial block?", "Confirm creating more tutorial block")) { continue; } else { break; } } $fileOutput += "`t)$newline}" $tutorialData = $fileOutput } $global:tutorialData = $tutorialData $global:modulePath = $modulePath $tutorialFolder = Join-Path $modulePath "Tutorial" if (-not [System.IO.Directory]::Exists($tutorialFolder)) { mkdir $tutorialFolder -ErrorAction Stop } [System.IO.File]::WriteAllText("$tutorialFolder\$Name.TutorialData.psd1", $tutorialData) } # Utility to throw an errorrecord function ThrowError { param ( [parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [System.Management.Automation.PSCmdlet] $CallerPSCmdlet, [parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [System.String] $ExceptionName, [parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [System.String] $ExceptionMessage, [System.Object] $ExceptionObject, [parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [System.String] $ErrorId, [parameter(Mandatory = $true)] [ValidateNotNull()] [System.Management.Automation.ErrorCategory] $ErrorCategory ) CleanUpTutorial $exception = New-Object $ExceptionName $ExceptionMessage; $errorRecord = New-Object System.Management.Automation.ErrorRecord $exception, $ErrorId, $ErrorCategory, $ExceptionObject $CallerPSCmdlet.ThrowTerminatingError($errorRecord) } <# .Synopsis Add a tutorial to an existing module. See New-Tutorial help for the structure of the TutorialData.psd1 file .EXAMPLE Add-Tutorial PackageManagement #> function Add-Tutorial { [CmdletBinding(SupportsShouldProcess)] Param ( # The name of the tutorial [Parameter(Mandatory=$true, Position=0)] [string] $Name, # if this is true then user will create the tutorial from the terminal [switch] $Interactive, # Allowed modules in the tutorial session [string[]] $TutorialModules, # Allowed commands in the tutorial session [string[]] $TutorialCommands ) Begin { try { Import-Module $Name $modulePath = Split-Path (Get-Module $Name).Path } catch { $exception = New-Object "System.ArgumentException" "Module $Name cannot be found"; $errorRecord = New-Object System.Management.Automation.ErrorRecord $exception "System.ArgumentException", InvalidArgument, $directory $PSCmdlet.ThrowTerminatingError($errorRecord) } } Process { CreateTutorialInModule $modulePath $PSCmdlet $TutorialModules $TutorialCommands } End { } } <# .Synopsis Generate a tutorial .DESCRIPTION To create a new tutorial, run New-Tutorial -Name <TutorialName> -Destination <Destination> If Destination is not supplied, the tutorial folder will be created in the current directory. The data file contains a hashtable with 3 keys: TutorialModules, TutorialCommands and TutorialData. The value of TutorialModules is an array of module names that are allowed in the tutorial. You can populate this array by providing a -TutorialModules <List of commands> parameters to either New-Tutorial or Add-Tutorial cmdlet. The value of TutorialCommands is an array of command names that are allowed in the tutorial. You can populate this array by providing a -TutorialCommands <List of commands> parameters to either New-Tutorial or Add-Tutorial cmdlet. The value of TutorialData is an array of hashtables, each of which corresponds to a step in the tutorial. There are 4 possible keys in the hashtable: Instruction: The instruction of this step Verification: Contains the command that can be used to verify the answer. The command should return boolean value. If this field exists, then answers and output field should not exist. Answers: An array of acceptable responses Hints: A hashtable. The key can be either number or string: If the key is a number, then the corresponding value will be displayed if the user fails to provide the correct answer within that number of attempt. If the key is a string, then the corresponding value will be displayed if the user enters that string. Output: The output provided by the tutorial when the user enters the correct answer. If a block has no verification, no answers and no output entry, then the user is always correct. If a block has verification entry, then that command will be run everytime the user output the answer to check whether the answer is correct. Otherwise, we will use answers and output entry to determine the answer. If a block has answers but not output entry, then the result of running the first answer will be compared to the result of the command that the user provides to determine whether the user is correct. If a block has output but not answers entry, then the value of the output entry will be compared to the result of running the command that the user provides to determine whether the user is correct. If a block has both answers and output entry, then we will check to see whether the command that the user provides fall into the list of answers to determine whether the user is correct. Any error resulted from running the user's command and the first answer will be suppressed (so basically this can be thought of as a form of mocking). You can directly edit the data file to create as many steps as you want to. .EXAMPLE New-Tutorial -Name MyNewTutorial -TutorialCommands @("Get-MyObject") New-Tutorial -Name MyNewTutorial -Destination "C:\Testing" New-Tutorial -Name MyNewTutorial -TutorialModules @("PackageManagement") -TutorialCommands @("Get-PackageProvider") #> function New-Tutorial { [CmdletBinding(SupportsShouldProcess)] Param ( # The name of the tutorial [Parameter(Mandatory=$true, Position=0)] [ValidateNotNullOrEmpty()] [string] $Name, # if this is true then user will create the tutorial from the terminal [switch] $Interactive, # List of modules that will be allowed to run [string[]] [ValidateNotNullOrEmpty()] $TutorialModules, # List of commands that will be allowed to run [string[]] [ValidateNotNullOrEmpty()] $TutorialCommands, # Destination to save the tutorial (if blanked, this will save to current folder) [string] [ValidateNotNullOrEmpty()] $Destination ) Begin { # If path is not supplied, use current directory if ([string]::IsNullOrWhiteSpace($Destination)) { $InstallationFolder = Resolve-Path '.\' } else { # If path supplied is wrong, raise error if (-not (Test-Path $Destination)) { ThrowError -ExceptionName "System.ArgumentException" ` -ExceptionMessage "Destination $Destination does not exists" ` -ErrorId "DirectoryDoesNotExists" ` -CallerPSCmdlet $PSCmdlet ` -ErrorCategory InvalidArgument ` -ExceptionObject "$Destination" } $InstallationFolder = Resolve-Path $Destination } # Try to make the directory $directory = mkdir "$($InstallationFolder.Path)\$Name" -ErrorAction Stop $moduleManifestCommand = Get-Command New-ModuleManifest if ($moduleManifestCommand.Parameters.ContainsKey("Tags")) { # If tags is supported New-ModuleManifest "$($directory.FullName)\$Name.psd1" -Tags "PowerShellTutorial" } else { New-ModuleManifest "$($directory.FullName)\$Name.psd1" } } Process { CreateTutorialInModule $directory.FullName $PSCmdlet $TutorialModules $TutorialCommands } End { } } # Returns the Tutorial on this machine function Get-Tutorial { [CmdletBinding()] Param( ) Begin { } Process { # Check whether there is a tutorial folder and a tutorialdata.psd1 Get-Module -ListAvailable ` | Where-Object {(-not [string]::IsNullOrWhiteSpace($_.Path)) -and (Test-Path (Join-Path (Join-Path (Split-Path $_.Path) "Tutorial") "$($_.Name).TutorialData.psd1"))} ` | Format-Table -Property ModuleType, Name } End { } } <# .Synopsis Restore a Tutorial that was stopped before. .EXAMPLE Restore-Tutorial -Name <TutorialName> #> function Restore-Tutorial { [CmdletBinding()] [Alias()] [OutputType([int])] Param ( # Name of the tutorial [Parameter(Mandatory=$true, Position=0)] [ValidateNotNullOrEmpty()] [string] $Name ) Begin { } Process { # no save data. just start as a new tutorial if (-not (Test-Path $script:xmlPath)) { Start-Tutorial $Name } else { $tutorialDataPath = ResolveTutorialDataPath $Name $tutorialNode = Update-TutorialNode $tutorialDataPath if ($tutorialNode -ne $null -and $tutorialNode.Block -ne $null) { $script:resumeTutorial = $true Start-Tutorial $tutorialDataPath -Block $tutorialNode.Block } } } End { $script:resumeTutorial = $false } } function Update-TutorialNode ([string]$Name, [int]$block=-1) { $xml = [xml] (Get-Content $script:xmlPath) if ($xml.LocalTutorialData -ne $null -and $xml.LocalTutorialData.ChildNodes -ne $null) { $tutorialBlock = $xml.LocalTutorialData.ChildNodes | Where-Object { $_.Name -eq $Name } | Select-Object -First 1 # update case if ($tutorialBlock -ne $null -and $block -ne -1) { [void]$tutorialBlock.SetAttribute("Block", $block) [void]$xml.Save($script:xmlPath) } return $tutorialBlock } return $null } # Start a new tutorial block function StartTutorialBlock { [CmdletBinding()] Param() $Global:TutorialAttempts = 0 $Global:TutorialIndex += 1 $i = $Global:TutorialIndex # no more block so clean up if ($i -ge $Global:TutorialBlocks.Count) { CleanUpTutorial return } $currentTutorialBlock = $Global:TutorialBlocks[$i] $Global:TutorialPrompt = "[$i] PSTutorial> " $instruction = $currentTutorialBlock["instruction"] [string[]] $acceptableResponses = $currentTutorialBlock["answers"] $Global:TutorialHint = "" $Global:TutorialAlmostCorrect = "" $Global:TutorialVerification = $currentTutorialBlock["verification"] $Global:ResultFromAnswer = "" # we only run the first answer if acceptable response is not null and output is null. # otherwise we don't run the first answer because the author may be mocking # we also don't run the first answer if there is a verification if ($acceptableResponses -ne $null -and $acceptableResponses.Count -gt 0 ` -and [string]::IsNullOrWhiteSpace($currentTutorialBlock["output"]) ` -and [string]::IsNullOrWhiteSpace($Global:TutorialVerification)) { try { $Global:ResultFromAnswer = Invoke-Expression $acceptableResponses[0] | Out-String } catch { # If we can't invoke then return empty string $Global:ResultFromAnswer = "" } finally { $Global:TutorialErrors.Clear() } } Write-Host -ForegroundColor Cyan "$instruction `n" Write-Host } # Verify answer and checks whether we can move on to the next block function TutorialMoveOn { if ($Global:TutorialAttempts -eq -1) { StartTutorialBlock return } $i = $Global:TutorialIndex $instruction = $Global:TutorialBlocks[$i]["instruction"] [hashtable] $hints = $Global:TutorialBlocks[$i]["hints"] [string[]] $acceptableResponses = $Global:TutorialBlocks[$i]["answers"] # Getting the last index History $lastHistoryIndex = (Get-History | Select-Object -Last 1).Id # if count catch up with id, user has input something if ($lastHistoryIndex -eq $Global:HistoryId) { [string]$response = (Get-History -Id $Global:HistoryId) $Global:HistoryId += 1 } # Verification time if ([string]::IsNullOrWhiteSpace($response)) { return } # See whether we can use verify first if (-not [string]::IsNullOrWhiteSpace($Global:TutorialVerification)) { $verification = $false try { $verification = Invoke-Expression $Global:TutorialVerification | Select-Object -Last 1 } catch {} # answer is correct! if ($verification -eq $true) { Write-Answer StartTutorialBlock return } Write-PSError } else { # the author does not supply verify keyword $result = $Global:LastOutput | Out-String [string]$expectedOutput = $Global:TutorialBlocks[$i]["output"] # we match output result if no answers are supplied if ($null -eq $acceptableResponses) { # if output is null, then nothing to do if ([string]::IsNullOrWhiteSpace($expectedOutput)) { # don't report error here $Global:TutorialErrors.Clear() StartTutorialBlock return } # output is not null, we match if (($expectedOutput -replace '\s+',' ').Trim() -ieq ($result -replace '\s+',' ').Trim()) { Write-PSError Write-Answer StartTutorialBlock return } } #here the acceptable response is not null if ($response -iin $acceptableResponses) { # acceptable response if (-not [string]::IsNullOrWhiteSpace($expectedOutput)) { # Mocking so clear possible error $Global:TutorialErrors.Clear() Write-Answer $expectedOutput } else { Write-PSError Write-Answer } StartTutorialBlock return } # here, response is not in acceptableResponses Write-PSError # we try to match user response with the result from one of the acceptable response if (-not [string]::IsNullOrWhiteSpace($result)) { if (($result -replace '\s+',' ').Trim() -ieq ($Global:ResultFromAnswer -replace '\s+',' ').Trim()) { Write-Answer StartTutorialBlock return } } } # incorrect answer Write-Host -ForegroundColor Red "$response is not correct`n" if ($hints -ne $null -and $hints.ContainsKey($response)) { $Global:TutorialAlmostCorrect = $hints[$response] } else { $Global:TutorialAttempts += 1 } # after we finished veryfing answer, if there is no change, we print out the same prompt if ($hints -ne $null -and $hints.ContainsKey($Global:TutorialAttempts)) { $Global:TutorialHint = $hints[$Global:TutorialAttempts] } if (-not [string]::IsNullOrWhiteSpace($Global:TutorialAlmostCorrect)) { Write-Host -ForegroundColor Green "Hints: $Global:TutorialAlmostCorrect`n" $Global:TutorialAlmostCorrect = "" } elseif (-not [string]::IsNullOrWhiteSpace($Global:TutorialHint)) { Write-Host -ForegroundColor Green "Hints: $Global:TutorialHint`n" } Write-Host -ForegroundColor Cyan "$instruction `n" Write-Host } # Given a string which can be either name or path, returns path to .TutorialData.psd1 file function ResolveTutorialDataPath ([string]$TutorialNameOrPath) { $script:module = $null if (Test-Path $TutorialNameOrPath) { $tutorialPath = Resolve-Path $TutorialNameOrPath # if the path is a psd1 folder if ($tutorialPath.Path.EndsWith(".TutorialData.psd1")) { $tutorialDataPath = $tutorialPath.Path } else { # if path is tutorial folder itself if ([System.IO.Path]::GetFileName($tutorialPath.Path) -eq "Tutorial") { $tutorialDirectory = $tutorialPath.Path } else { # checks that it has subdirectory tutorial folder [System.IO.DirectoryInfo]$tutorialDirectoryInfo = Get-ChildItem $tutorialPath.Path | Where-Object {$_ -is [System.IO.DirectoryInfo] -and $_.Name -eq "Tutorial"} | Select-Object -First 1 if ($tutorialDirectoryInfo -ne $null) { $tutorialDirectory = $tutorialDirectoryInfo.FullName } } if (-not [string]::IsNullOrWhiteSpace($tutorialDirectory)) { [System.IO.FileInfo]$tutorialDataPathFileInfo = Get-ChildItem $tutorialDirectory | Where-Object {$_ -is [System.IO.FileInfo] -and $_.Name.EndsWith(".TutorialData.psd1")} | Select-Object -First 1 if ($tutorialDataPathFileInfo -ne $null) { $tutorialDataPath = $tutorialDataPathFileInfo.FullName } } } } else { Import-Module $TutorialNameOrPath -Global -ErrorAction Stop $script:module = (Get-Module $TutorialNameOrPath) # get the path to the tutorialdata.psd1 file $tutorialDataPath = Join-Path (Join-Path (Split-Path $script:module.Path) "Tutorial") "$Name.TutorialData.psd1" } return $tutorialDataPath } <# .Synopsis Start a Tutorial session. Supply the name of the Tutorial, which is the name of a module that contains a Tutorial folder. You can also supply a path to a folder that contains a tutorial folder. .EXAMPLE Start-Tutorial Get-CommandTutorial #> function Start-Tutorial { [CmdletBinding()] Param ( # Name of the tutorial [Parameter(Mandatory=$true, Position=0)] [ValidateNotNullOrEmpty()] [string] $Name, [int] $Block=0 ) Begin { # Wrapper for Out-Default that saves the last object written # and handles missing commands if the command is a directory # or an URL. # function Global:Out-Default { [CmdletBinding(HelpUri='http://go.microsoft.com/fwlink/?LinkID=113362', RemotingCapability='None')] param( [switch] ${Transcript}, [Parameter(ValueFromPipeline=$true)] [psobject] ${InputObject}) begin { $wrappedCmdlet = $ExecutionContext.InvokeCommand.GetCmdlet( "Out-Default") $scriptCmdlet = { & $wrappedCmdlet @PSBoundParameters } $steppablePipeline = $scriptCmdlet.GetSteppablePipeline() $steppablePipeline.Begin($pscmdlet) $captured = @() } process { $captured += $_ # if we get an error record then we may want to suppress it if ($_ -isnot [System.Management.Automation.ErrorRecord]) { $steppablePipeline.Process($_) } else { $global:TutorialErrors.Add($_) } } end { if ($global:Formatted -eq $true) { $global:Formatted = $false } else { $global:LastOutput = $captured } $steppablePipeline.End() } } function Global:Format-Custom { [CmdletBinding(HelpUri='http://go.microsoft.com/fwlink/?LinkID=113301')] param( [Parameter(Position=0)] [System.Object[]] ${Property}, [ValidateRange(1, 2147483647)] [int] ${Depth}, [System.Object] ${GroupBy}, [string] ${View}, [switch] ${ShowError}, [switch] ${DisplayError}, [switch] ${Force}, [ValidateSet('CoreOnly','EnumOnly','Both')] [string] ${Expand}, [Parameter(ValueFromPipeline=$true)] [psobject] ${InputObject}) begin { try { $outBuffer = $null if ($PSBoundParameters.TryGetValue('OutBuffer', [ref]$outBuffer)) { $PSBoundParameters['OutBuffer'] = 1 } $wrappedCmd = $ExecutionContext.InvokeCommand.GetCommand('Microsoft.PowerShell.Utility\Format-Custom', [System.Management.Automation.CommandTypes]::Cmdlet) $scriptCmd = {& $wrappedCmd @PSBoundParameters } $steppablePipeline = $scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin) $steppablePipeline.Begin($PSCmdlet) $captured = @() } catch { throw } } process { try { $captured += $_ $steppablePipeline.Process($_) } catch { throw } } end { try { $global:Formatted = $true $PSBoundParameters["InputObject"] = $captured $global:LastOutput = Microsoft.PowerShell.Utility\Format-Custom @PSBoundParameters $steppablePipeline.End() } catch { throw } } <# .ForwardHelpTargetName Microsoft.PowerShell.Utility\Format-Custom .ForwardHelpCategory Cmdlet #> } function Global:Format-List { [CmdletBinding(HelpUri='http://go.microsoft.com/fwlink/?LinkID=113302')] param( [Parameter(Position=0)] [System.Object[]] ${Property}, [System.Object] ${GroupBy}, [string] ${View}, [switch] ${ShowError}, [switch] ${DisplayError}, [switch] ${Force}, [ValidateSet('CoreOnly','EnumOnly','Both')] [string] ${Expand}, [Parameter(ValueFromPipeline=$true)] [psobject] ${InputObject}) begin { try { $outBuffer = $null if ($PSBoundParameters.TryGetValue('OutBuffer', [ref]$outBuffer)) { $PSBoundParameters['OutBuffer'] = 1 } $wrappedCmd = $ExecutionContext.InvokeCommand.GetCommand('Microsoft.PowerShell.Utility\Format-List', [System.Management.Automation.CommandTypes]::Cmdlet) $scriptCmd = {& $wrappedCmd @PSBoundParameters } $steppablePipeline = $scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin) $steppablePipeline.Begin($PSCmdlet) $captured = @() } catch { throw } } process { try { $captured += $_ $steppablePipeline.Process($_) } catch { throw } } end { try { $global:Formatted = $true $global:LastOutput = Microsoft.PowerShell.Utility\Format-List @PSBoundParameters $steppablePipeline.End() } catch { throw } } <# .ForwardHelpTargetName Microsoft.PowerShell.Utility\Format-List .ForwardHelpCategory Cmdlet #> } function Global:Format-Table { [CmdletBinding(HelpUri='http://go.microsoft.com/fwlink/?LinkID=113303')] param( [switch] ${AutoSize}, [switch] ${HideTableHeaders}, [switch] ${Wrap}, [Parameter(Position=0)] [System.Object[]] ${Property}, [System.Object] ${GroupBy}, [string] ${View}, [switch] ${ShowError}, [switch] ${DisplayError}, [switch] ${Force}, [ValidateSet('CoreOnly','EnumOnly','Both')] [string] ${Expand}, [Parameter(ValueFromPipeline=$true)] [psobject] ${InputObject}) begin { try { $outBuffer = $null if ($PSBoundParameters.TryGetValue('OutBuffer', [ref]$outBuffer)) { $PSBoundParameters['OutBuffer'] = 1 } $wrappedCmd = $ExecutionContext.InvokeCommand.GetCommand('Microsoft.PowerShell.Utility\Format-Table', [System.Management.Automation.CommandTypes]::Cmdlet) $scriptCmd = {& $wrappedCmd @PSBoundParameters } $steppablePipeline = $scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin) $steppablePipeline.Begin($PSCmdlet) $captured = @() } catch { throw } } process { try { $captured += $_ $steppablePipeline.Process($_) } catch { throw } } end { try { $global:Formatted = $true $PSBoundParameters["InputObject"] = $captured $global:LastOutput = Microsoft.PowerShell.Utility\Format-Table @PSBoundParameters $steppablePipeline.End() } catch { throw } } <# .ForwardHelpTargetName Microsoft.PowerShell.Utility\Format-Table .ForwardHelpCategory Cmdlet #> } function Global:Format-Wide { [CmdletBinding(HelpUri='http://go.microsoft.com/fwlink/?LinkID=113304')] param( [Parameter(Position=0)] [System.Object] ${Property}, [switch] ${AutoSize}, [ValidateRange(1, 2147483647)] [int] ${Column}, [System.Object] ${GroupBy}, [string] ${View}, [switch] ${ShowError}, [switch] ${DisplayError}, [switch] ${Force}, [ValidateSet('CoreOnly','EnumOnly','Both')] [string] ${Expand}, [Parameter(ValueFromPipeline=$true)] [psobject] ${InputObject}) begin { try { $outBuffer = $null if ($PSBoundParameters.TryGetValue('OutBuffer', [ref]$outBuffer)) { $PSBoundParameters['OutBuffer'] = 1 } $wrappedCmd = $ExecutionContext.InvokeCommand.GetCommand('Microsoft.PowerShell.Utility\Format-Wide', [System.Management.Automation.CommandTypes]::Cmdlet) $scriptCmd = {& $wrappedCmd @PSBoundParameters } $steppablePipeline = $scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin) $steppablePipeline.Begin($PSCmdlet) $captured = @() } catch { throw } } process { try { $captured += $_ $steppablePipeline.Process($_) } catch { throw } } end { try { $global:Formatted = $true $PSBoundParameters["InputObject"] = $captured $global:LastOutput = Microsoft.PowerShell.Utility\Format-Wide @PSBoundParameters $steppablePipeline.End() } catch { throw } } <# .ForwardHelpTargetName Microsoft.PowerShell.Utility\Format-Wide .ForwardHelpCategory Cmdlet #> } $global:TutorialErrors = [System.Collections.ArrayList]::new() $Global:OldPrompt = Get-Content Function:\prompt try { # resolve the path $script:DataPath = ResolveTutorialDataPath $Name if (-not [string]::IsNullOrWhiteSpace($script:DataPath) -and (Test-Path $script:DataPath)) { $tutorialDataFileName = [System.IO.Path]::GetFileName($script:DataPath) $tutorialDict = Import-LocalizedData -BaseDirectory (Split-Path $script:DataPath) -FileName $tutorialDataFileName # Get the name of the tutorial $Name = $tutorialDataFileName.Substring(0, $tutorialDataFileName.IndexOf(".TutorialData.psd1")) } if ($null -eq $tutorialDict -or (-not $tutorialDict.ContainsKey("TutorialData"))) { ThrowError -ExceptionName "System.ArgumentException" ` -ExceptionMessage "Tutorial $Name does not have any tutorial data" ` -ErrorId "NoTutorialData" ` -CallerPSCmdlet $PSCmdlet ` -ErrorCategory InvalidData ` -ExceptionObject "$Name" } if (-not $script:resumeTutorial) { $xmlFolder = [System.IO.Path]::GetDirectoryName($script:xmlPath) if (-not (Test-Path $xmlFolder)) { $xmlDir = mkdir $xmlFolder } if (-not (Test-Path $script:xmlPath)) { # if the xml does not exist we have to create it $xml = [xml] $script:xmlTutorial } else { $xml = [xml] (Get-Content $script:xmlPath) $tutorialBlock = Update-TutorialNode $script:DataPath $Block } # if null then it does not exist so we have to create it if ($tutorialBlock -eq $null) { $tutorialNode = $xml.CreateElement("Tutorial") [void]$tutorialNode.SetAttribute("Name", $script:DataPath) [void]$tutorialNode.SetAttribute("Block", $Block) [void]$xml.SelectSingleNode("//LocalTutorialData").AppendChild($tutorialNode) [void]$xml.Save($script:xmlPath) } } $global:TutorialBlocks = $tutorialDict["TutorialData"] $RequiredCommands = @("Get-Command", "Get-FormatData", "Out-Default", "Select-Object", "Measure-Object", "prompt", "TabExpansion2", "PSConsoleHostReadLine", "Get-History", "Get-Help", "ForEach-Object", "Where-Object", "Out-String", "Format-List", "Format-Table", "Format-Wide", "Format-Custom", "Get-Module" ) if ($tutorialDict.ContainsKey("TutorialModules")) { foreach ($allowedModule in $tutorialDict["TutorialModules"]) { Import-Module -Name $allowedModule -Global } } if ($tutorialDict.ContainsKey("TutorialCommands")) { $RequiredCommands += $tutorialDict["TutorialCommands"] } $Global:OldApplications = [System.Collections.ArrayList]::new($ExecutionContext.SessionState.Applications) $Global:OldScripts = [System.Collections.ArrayList]::new($ExecutionContext.SessionState.Scripts) $Global:AllCommandsBeforeTutorial = Get-Command -CommandType Cmdlet,Alias,Function # Don't display commands that are not from tutorialdemo and commands that are not from the module $Global:AllCommandsBeforeTutorial ` | Where-Object {$RequiredCommands -notcontains $_.Name -and $_.ModuleName -ne "PowerShellTutorial" -and $_.ModuleName -ne $script:module.Name} ` | ForEach-Object {$_.Visibility = "Private"} $global:TutorialAttempts = -1 } catch { CleanUpTutorial throw } } Process { Write-Host -ForegroundColor Cyan "Welcome to $Name tutorial`n" Write-Host -ForegroundColor Cyan "Type Stop-Tutorial anytime to quit the tutorial. Your progress will be saved`n" if ($global:TutorialBlocks -is [hashtable]) { $global:TutorialBlocks = ,$global:TutorialBlocks } $global:TutorialIndex = $Block-1 # Account for the start-tutorial $Global:HistoryId = ((Get-History) | Select-Object -Last 1).Id + 2 function global:prompt { . $function:TutorialMoveOn return $Global:TutorialPrompt } } End { } } function Write-PSError { if ($global:TutorialErrors.Count -gt 0) { foreach ($err in $global:TutorialErrors) { Write-Host -ForegroundColor Red ($err | Out-String) } $global:TutorialErrors.Clear() } } |