# The Complete PowerShell Scripting Tutorial
### From First Script to System Automation, Build Pipelines & Production-Grade Tools

---

## How to Use This Tutorial

This is a comprehensive, professional reference for PowerShell scripting, covering **PowerShell 7+ (`pwsh`, cross‑platform)** with notes on where **Windows PowerShell 5.1** differs, since both are in active use. Every concept includes runnable code.

**Companion document:** see `bash_tutorial.md` for the Bash equivalent, and `bash_vs_powershell_and_projects.md` for a direct side‑by‑side comparison and capstone projects in both languages.

---

## Table of Contents

**Part I — Foundations**
1. Introduction, Editions & Execution Policy
2. Script Basics, Variables & the Object Pipeline
3. Cmdlets, Aliases, Help & Discovery

**Part II — Control Flow**
4. Conditionals & Loops
5. Functions & Advanced Functions

**Part III — Data Structures**
6. Arrays, Hashtables & `PSCustomObject`
7. Strings, Regex & Here‑Strings

**Part IV — The Pipeline & Objects**
8. Mastering the Pipeline: `Select/Where/ForEach/Sort/Group`
9. Formatting & Output
10. Error Handling

**Part V — Scripts & Modules**
11. Scripts, Scope, Profiles & Modules
12. Classes & Enums

**Part VI — System Programming**
13. File System, Registry, Processes & Services
14. WMI/CIM & .NET Integration
15. Networking & Remoting

**Part VII — Scheduling & Configuration**
16. Scheduled Tasks & Background Jobs
17. Configuration Files: JSON, XML, CSV, INI

**Part VIII — Build Automation & Compilers**
18. Build Automation: MSBuild, `dotnet`, `csc`, CI/CD

**Part IX — Professional Engineering**
19. Security: Execution Policy, Signing, Credentials
20. Debugging, Logging & Performance
21. Cross‑Platform PowerShell

**Part X — Real‑World Projects**
22. Four Complete Projects

**Appendix**
A. PowerShell Cheat Sheet
B. Further Resources

---

# Part I — Foundations

## Chapter 1 — Introduction, Editions & Execution Policy

### What PowerShell Is — and How It Differs From Bash

PowerShell's defining feature, and the single most important mental model shift if you're coming from Bash: **the pipeline carries objects, not text.** When you pipe the output of one cmdlet into another, you're passing structured .NET objects with properties and methods — not raw strings you then have to re‑parse with `grep`/`awk`. This eliminates an entire category of brittle text‑scraping that dominates shell scripting.

| | Bash | PowerShell |
|---|---|---|
| Pipeline payload | text (lines of bytes) | .NET objects |
| Primary platform | Linux/macOS (Unix) | Windows‑native; cross‑platform since v6 |
| Case sensitivity | commands & vars case‑sensitive | case‑**insensitive** throughout |
| Command naming | short, cryptic (`grep`, `ls`) | verbose, `Verb-Noun` (`Get-ChildItem`) |
| Typing | untyped strings | rich type system via .NET |

### Editions: Windows PowerShell vs PowerShell 7+

- **Windows PowerShell 5.1** — built into Windows, .NET Framework‑based, Windows‑only, the "legacy" edition still required for some modules (e.g., older `ActiveDirectory`/`Exchange` modules).
- **PowerShell 7+ (`pwsh`)** — open‑source, .NET‑based, **cross‑platform** (Windows/Linux/macOS), actively developed. This is what new scripts should target.

```powershell
$PSVersionTable                 # shows PSVersion, PSEdition (Desktop vs Core), OS, etc.
$PSVersionTable.PSVersion
```

### Installing PowerShell 7+

```bash
# Linux (Ubuntu/Debian) via apt
sudo apt update && sudo apt install -y powershell

# macOS via Homebrew
brew install --cask powershell

# Windows via winget
winget install Microsoft.PowerShell
```

```powershell
pwsh                # launch PowerShell 7+ (vs `powershell` for 5.1 on Windows)
```

### Execution Policy (Windows‑Specific Safety Gate)

Windows restricts script execution by default — a security control with no direct Bash analogue (Unix relies on the executable bit instead).

```powershell
Get-ExecutionPolicy -List
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
# RemoteSigned: local scripts run freely; downloaded scripts must be signed.
# Restricted (default on some systems): no scripts at all.
# Bypass: nothing blocked -- use only when you understand the implications.
```

This setting **does not exist** in `pwsh` on Linux/macOS, where the Unix execute‑permission model applies instead (`chmod +x script.ps1`).

---

## Chapter 2 — Script Basics, Variables & the Object Pipeline

### Your First Script

```powershell
# hello.ps1
$name = $env:USERNAME
Write-Output "Hello, $name. Today is $(Get-Date -Format 'yyyy-MM-dd')."
```

```powershell
./hello.ps1                 # run it (may need execution policy adjustment, see Ch.1)
pwsh -File ./hello.ps1        # run explicitly with the PowerShell 7 interpreter
```

### Variables

```powershell
$name = "Hawaii"            # no `export`-style distinction needed for basic use
$age = 30
Write-Host "$name is $age"

$name = $null                 # PowerShell has a real $null, distinct from empty string ""
[int]$count = "5"              # explicit type constraint -- throws if "5" weren't convertible
$readonly = "fixed"
Set-Variable -Name readonly -Option ReadOnly -Force
```

PowerShell variable names are case‑**insensitive** (`$Name` and `$name` are the same variable) — unlike Bash.

### Everything Is an Object — `Get-Member`

```powershell
$date = Get-Date
$date | Get-Member                 # list every property & method available on this object
$date.Year                          # access a property directly
$date.AddDays(7)                     # call a method directly
(Get-Process | Select-Object -First 1).GetType().FullName
```

`Get-Member` is the PowerShell equivalent of "what can I do with this value?" — it is the single most useful exploration command, especially when you don't yet know an object's shape.

### Scope (`$global`, `$script`, `$local`, function scope)

```powershell
$x = "outer"
function Test-Scope {
    $x = "inner"          # local to the function by default, like Bash's `local`
    $global:x = "forced global change"   # explicit escape hatch
}
Test-Scope
Write-Host $x              # "forced global change"
```

### Comments & Documentation Blocks

```powershell
<#
.SYNOPSIS
    Deploys the current build to a target environment.
.DESCRIPTION
    Pulls the latest release, runs the build, and restarts the service.
.PARAMETER Environment
    The target environment name (staging|production).
.EXAMPLE
    ./Deploy.ps1 -Environment production
#>
param([string]$Environment)
```

This comment‑based help block (`.SYNOPSIS`, `.DESCRIPTION`, `.PARAMETER`, `.EXAMPLE`) is a PowerShell‑native convention — it makes your script respond correctly to `Get-Help ./Deploy.ps1 -Full`, the same way built‑in cmdlets do.

---

## Chapter 3 — Cmdlets, Aliases, Help & Discovery

### The `Verb-Noun` Convention

Every built‑in cmdlet follows `Verb-Noun` (`Get-Process`, `Set-Content`, `New-Item`, `Remove-Item`). Approved verbs are deliberately constrained (`Get`, `Set`, `New`, `Remove`, `Start`, `Stop`, `Test`, `Invoke`...) so behavior is predictable just from the name.

```powershell
Get-Command -Verb Get -Noun *process*    # discover cmdlets by pattern
Get-Verb                                   # list all approved verbs and their meaning
```

### Discovering & Learning Cmdlets

```powershell
Get-Command Get-Process            # what is this cmdlet, where does it live
Get-Help Get-Process -Full           # full documentation
Get-Help Get-Process -Examples        # just usage examples
Get-Help about_Operators               # conceptual "about_*" help topics (huge, underused resource)
Update-Help                             # download the latest help content (run once, as admin)
```

### Aliases — Bridging From Bash/CMD Habits

```powershell
Get-Alias ls          # ls -> Get-ChildItem
Get-Alias cat           # cat -> Get-Content
Get-Alias rm             # rm -> Remove-Item
Get-Alias | Where-Object { $_.Definition -like "*ChildItem*" }

New-Alias -Name ll -Value Get-ChildItem    # define your own alias
```

Many familiar Unix command **names** exist as aliases (`ls`, `cat`, `cp`, `rm`, `pwd`, `ps`), but they map to PowerShell cmdlets with PowerShell's own parameter sets — `ls -la` does **not** work the way it does in Bash. Learn the real cmdlet names (`Get-ChildItem`, `Get-Content`) rather than relying on the aliases once you move past trivial use.

### Common Cmdlets Cheat-Start

```powershell
Get-ChildItem -Path . -Recurse -Filter *.ps1     # find files (like `find`/`ls -R`)
Get-Content file.txt                              # read a file (like `cat`)
Set-Content file.txt -Value "new content"           # overwrite a file
Add-Content file.txt -Value "appended line"           # append
Select-String -Path *.log -Pattern "ERROR"             # like `grep`
Get-Process | Sort-Object CPU -Descending | Select-Object -First 5   # top 5 by CPU
```

---

# Part II — Control Flow

## Chapter 4 — Conditionals & Loops

### `if` / `elseif` / `else`

```powershell
$n = Read-Host "Enter a number"
$n = [int]$n

if ($n -gt 100) {
    Write-Output "Big"
} elseif ($n -gt 10) {
    Write-Output "Medium"
} else {
    Write-Output "Small"
}
```

### Comparison & Logical Operators (Not C-style Symbols!)

PowerShell uses word-based operators, not `==`/`!=`/`&&` — a frequent source of confusion coming from Bash, C, or Python.

```powershell
$a -eq $b      # equal
$a -ne $b      # not equal
$a -gt $b      # greater than
$a -lt $b      # less than
$a -ge $b; $a -le $b
$a -and $b     # logical AND
$a -or $b      # logical OR
-not $a        # logical NOT
$str -like "*.txt"     # wildcard match
$str -match "^\d+$"     # regex match (populates $Matches)
$str -contains "x"       # for COLLECTIONS: is "x" an element? (NOT a substring check!)
"hello" -clike "HELLO"     # case-SENSITIVE variant (prefix any operator with c)
```

`-eq`/`-ne`/etc. are also case-**insensitive** for strings by default (`"ABC" -eq "abc"` is `$true`) — use the `c`-prefixed variants (`-ceq`, `-clike`, `-cmatch`) when case must matter.

### `switch` — More Powerful Than Bash's `case`

```powershell
$value = "production"
switch ($value) {
    "staging"    { Write-Output "Deploying to staging" }
    "production" { Write-Output "Deploying to production" }
    default      { Write-Output "Unknown environment" }
}

# switch can match regex, wildcards, and even script blocks (predicates)
switch -Regex ("error_404.log") {
    "^error_"  { Write-Output "Error log detected" }
    "\.log$"   { Write-Output "Log file" }    # NOTE: switch falls through and tests ALL arms by default
}

# switch over a collection -- runs once per element automatically
switch (1, 2, 3, 4) {
    {$_ % 2 -eq 0} { "$_ is even" }
    default        { "$_ is odd" }
}
```

### Loops

```powershell
foreach ($color in "red", "green", "blue") {
    Write-Output "Color: $color"
}

for ($i = 0; $i -lt 5; $i++) {
    Write-Output "i=$i"
}

$count = 0
while ($count -lt 5) {
    Write-Output "count=$count"
    $count++
}

do {
    Write-Output "Runs at least once"
} while ($false)

do {
    Write-Output "Runs at least once (until version)"
} until ($true)
```

### `foreach` (keyword) vs `ForEach-Object` (cmdlet) — An Important Distinction

```powershell
# foreach KEYWORD: loads the whole collection into memory first, runs in the current scope
foreach ($p in Get-Process) { $p.Name }

# ForEach-Object CMDLET: streams one object at a time through the PIPELINE
Get-Process | ForEach-Object { $_.Name }

# ForEach-Object is what you reach for in pipelines; foreach is what you reach
# for in standalone script blocks where you don't need streaming.
```

### `break` and `continue`

```powershell
foreach ($i in 1..10) {
    if ($i -eq 5) { continue }
    if ($i -eq 8) { break }
    Write-Output $i
}

# break/continue can target labeled loops too
:outer foreach ($i in 1..3) {
    foreach ($j in 1..3) {
        if ($j -eq 2) { continue outer }
        "$i,$j"
    }
}
```

---

## Chapter 5 — Functions & Advanced Functions

### Basic Functions

```powershell
function Greet {
    param([string]$Name = "World")
    Write-Output "Hello, $Name!"
}
Greet -Name "Hawaii"
Greet              # uses default "World"
```

### Returning Data

Like Bash, anything **not captured/suppressed** inside a function is emitted to the pipeline — there's no need for an explicit `return` to produce output, though `return` can be used to exit early with a value.

```powershell
function Add-Numbers {
    param([int]$A, [int]$B)
    return $A + $B
}
$result = Add-Numbers -A 3 -B 4
Write-Output $result   # 7

# PITFALL: any uncaptured output anywhere in the function becomes part of
# the return value, even Write-Output calls used for "debug" purposes:
function Get-Data {
    Write-Output "Fetching..."   # this becomes PART of the returned collection!
    return @(1, 2, 3)
}
$data = Get-Data        # $data is actually @("Fetching...", 1, 2, 3) -- a common bug source
# Use Write-Host (goes to console only, NOT the pipeline) for incidental messages instead:
function Get-DataFixed {
    Write-Host "Fetching..."     # console-only, doesn't pollute the return value
    return @(1, 2, 3)
}
```

### Advanced Functions — `[CmdletBinding()]` and `param()` Validation

"Advanced functions" behave like real, built-in cmdlets — they get `-Verbose`, `-WhatIf`, parameter validation, and pipeline binding for free.

```powershell
function Set-UserStatus {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [ValidateNotNullOrEmpty()]
        [string]$Username,

        [Parameter()]
        [ValidateSet("Active", "Disabled", "Locked")]
        [string]$Status = "Active",

        [ValidateRange(1, 365)]
        [int]$ExpiryDays = 90
    )

    process {
        if ($PSCmdlet.ShouldProcess($Username, "Set status to $Status")) {
            Write-Verbose "Setting $Username to $Status"
            # ... actual logic here ...
        }
    }
}

"alice", "bob" | Set-UserStatus -Status Disabled -Verbose
Set-UserStatus -Username carol -Status Active -WhatIf   # preview without making changes
```

`SupportsShouldProcess` + `ShouldProcess` is what powers `-WhatIf`/`-Confirm` on real cmdlets like `Remove-Item` — adding it to your own functions gives callers the same safety net for free.

### `begin` / `process` / `end` Blocks (Pipeline-Aware Functions)

```powershell
function Measure-Items {
    begin  { $count = 0; Write-Verbose "Starting" }
    process { $count++ }            # runs ONCE PER PIPELINE ITEM
    end    { Write-Output "Total items: $count" }
}
1..100 | Measure-Items
```

### Default Parameters, Splatting & Variadic Arguments

```powershell
function New-Report {
    param(
        [string]$Title = "Untitled",
        [string[]]$Sections   # array parameter
    )
    "Report: $Title"
    $Sections | ForEach-Object { "  - $_" }
}
New-Report -Title "Q1" -Sections "Revenue", "Costs", "Headcount"

# Splatting: pass a hashtable as a full set of named parameters
$params = @{ Title = "Q2"; Sections = @("Revenue", "Margin") }
New-Report @params
```

---

# Part III — Data Structures

## Chapter 6 — Arrays, Hashtables & `PSCustomObject`

### Arrays

```powershell
$fruits = "apple", "banana", "cherry"      # parens optional for simple array literals
$fruits = @("apple", "banana", "cherry")    # explicit array syntax -- prefer this for clarity
$fruits += "date"                            # append (creates a NEW array under the hood -- O(n))

$fruits[0]                                     # apple
$fruits[-1]                                      # date -- negative indices work natively!
$fruits[1..2]                                      # banana, cherry -- range slicing
$fruits.Count                                        # 4
$fruits -join ", "                                     # "apple, banana, cherry, date"

foreach ($f in $fruits) { $f }
$fruits | ForEach-Object { $_ }

# Strongly-typed arrays
[int[]]$numbers = 1, 2, 3
[string[]]$names = @()

# Generic List -- use when you need genuinely efficient appends in a loop
$list = [System.Collections.Generic.List[string]]::new()
$list.Add("x")
$list.Add("y")
```

`$array += $item` reallocates the entire array every time — fine for small/occasional appends, a real performance problem in tight loops over thousands of items. Use `[System.Collections.Generic.List[T]]` or `ArrayList` for that case (see Chapter 20).

### Hashtables

```powershell
$config = @{
    Host = "localhost"
    Port = 8080
}
$config["Host"]                    # localhost
$config.Host                        # same thing -- dot notation works on hashtables too
$config["Timeout"] = 30               # add a new key
$config.Remove("Port")                 # remove a key
$config.Keys; $config.Values             # enumerate
$config.ContainsKey("Host")                # $true

foreach ($key in $config.Keys) {
    "$key => $($config[$key])"
}

# Ordered hashtable (preserves insertion order -- regular hashtables don't guarantee it)
$ordered = [ordered]@{ First = 1; Second = 2; Third = 3 }
```

### `PSCustomObject` — Structured Records (the PowerShell "struct")

This is how you build clean, typed-feeling data in PowerShell — the natural target for anything you'd reach for a `dict`/object literal for in Python/JS.

```powershell
$user = [PSCustomObject]@{
    Name  = "Alice"
    Email = "alice@example.com"
    Age   = 30
}
$user.Name                          # Alice
$user | Get-Member                   # real typed object, not just a bag of keys
$user.PSObject.Properties.Name         # list property names dynamically

# Building a collection of records -- the bread and butter of PowerShell reporting
$users = @(
    [PSCustomObject]@{ Name = "Alice"; Age = 30 }
    [PSCustomObject]@{ Name = "Bob";   Age = 25 }
)
$users | Sort-Object Age | Format-Table
$users | Where-Object { $_.Age -gt 26 }
$users | Export-Csv users.csv -NoTypeInformation
```

`PSCustomObject` collections piped into `Format-Table`, `Sort-Object`, `Where-Object`, `Export-Csv`, or `ConvertTo-Json` is the single most common, most powerful pattern in idiomatic PowerShell — build your data as objects, then let the pipeline cmdlets do the filtering/formatting/exporting.

---

## Chapter 7 — Strings, Regex & Here-Strings

### Basic String Operations

```powershell
$s = "Hello, World!"
$s.Length                       # 13
$s.ToUpper(); $s.ToLower()
$s.Substring(7)                  # "World!"
$s.Substring(7, 5)                 # "World"
$s.Replace("World", "PowerShell")    # Hello, PowerShell!
$s.Split(",")                          # array: "Hello", " World!"
$s.Trim()                                # strip leading/trailing whitespace
$s.Contains("World")                       # $true
$s.StartsWith("Hello"); $s.EndsWith("!")
$s -split ","                                # PowerShell operator equivalent of .Split() -- regex-aware
$s -replace "World", "PS"                       # operator equivalent of .Replace() -- regex-aware
```

### String Interpolation & Formatting

```powershell
$name = "Hawaii"; $score = 95.5
"Hello, $name! Score: $score"                     # double quotes interpolate
'Hello, $name!'                                     # single quotes are LITERAL, like Bash
"Result: $($score * 2)"                               # subexpression $(...) for expressions/method calls
"{0} scored {1:N1}%" -f $name, $score                   # -f format operator, like printf

$formatted = "{0,-10}{1,10}" -f "Name", "Score"            # column alignment via width specifiers
"Price: {0:C}" -f 19.99                                      # currency formatting -> $19.99
"Date: {0:yyyy-MM-dd}" -f (Get-Date)
```

### Here-Strings

```powershell
$template = @"
Server: $env:COMPUTERNAME
Date:   $(Get-Date -Format 'yyyy-MM-dd')
"@

$literal = @'
No interpolation happens here: $env:COMPUTERNAME stays literal.
'@
```

### Regular Expressions

```powershell
"version 3.14.7" -match '\d+\.\d+\.\d+'    # $true; sets $Matches automatically
$Matches[0]                                   # "3.14.7"

if ("user@example.com" -match '^(?<user>[^@]+)@(?<domain>.+)$') {
    $Matches.user      # named capture groups -- very convenient
    $Matches.domain
}

[regex]::Matches("a1 b22 c333", '\d+') | ForEach-Object { $_.Value }   # all matches, not just first

"  many   spaces  " -replace '\s+', ' '          # collapse whitespace (regex-aware -replace)
Select-String -Path *.log -Pattern 'ERROR:\s*(.+)' | ForEach-Object {
    $_.Matches[0].Groups[1].Value                  # extract captured text from each matching line
}
```

---

# Part IV — The Pipeline & Objects

## Chapter 8 — Mastering the Pipeline: `Select/Where/ForEach/Sort/Group`

These five cmdlets are used in nearly every real PowerShell one-liner — collectively they're the PowerShell equivalent of `awk`/`grep`/`sort`/`uniq` combined, but operating on objects instead of text.

### `Where-Object` — Filtering

```powershell
Get-Process | Where-Object { $_.CPU -gt 10 }
Get-ChildItem | Where-Object Extension -eq ".log"          # simplified syntax (no script block needed)
Get-Service | Where-Object { $_.Status -eq "Running" -and $_.Name -like "W*" }
```

### `Select-Object` — Projecting & Trimming

```powershell
Get-Process | Select-Object Name, CPU, Id                  # pick specific properties
Get-Process | Select-Object -First 5                         # like `head`
Get-Process | Select-Object -Last 5                            # like `tail`
Get-Process | Select-Object -Unique Name                         # distinct values
Get-Process | Select-Object -ExpandProperty Name                   # unwrap to raw strings, not objects
Get-Process | Select-Object Name, @{Name="MB";Expression={$_.WS/1MB}}  # computed/calculated property
```

### `ForEach-Object` — Transforming

```powershell
1..5 | ForEach-Object { $_ * $_ }                            # 1 4 9 16 25
Get-ChildItem *.txt | ForEach-Object { Rename-Item $_ ($_.Name + ".bak") }

# ForEach-Object -Parallel (PowerShell 7+) -- real concurrency, like `xargs -P`
1..10 | ForEach-Object -Parallel { Start-Sleep 1; $_ * 2 } -ThrottleLimit 5
```

### `Sort-Object` & `Group-Object`

```powershell
Get-Process | Sort-Object CPU -Descending | Select-Object -First 10
Get-ChildItem | Sort-Object Length, Name

Get-Process | Group-Object Company | Sort-Object Count -Descending
Get-ChildItem -Recurse -File | Group-Object Extension | Select-Object Name, Count
```

### Chaining It All Together — A Realistic Example

```powershell
Get-Process |
    Where-Object { $_.WorkingSet64 -gt 100MB } |
    Sort-Object WorkingSet64 -Descending |
    Select-Object -First 10 Name, Id, @{N="MemoryMB";E={[math]::Round($_.WorkingSet64/1MB,1)}} |
    Format-Table -AutoSize
```

### `Measure-Object` — Aggregation (sum/average/min/max/count)

```powershell
Get-ChildItem *.log | Measure-Object -Property Length -Sum -Average -Maximum
(Get-Process | Measure-Object -Property CPU -Sum).Sum
```

---

## Chapter 9 — Formatting & Output

### Display Formatting (Console Output Only — Doesn't Change the Underlying Object)

```powershell
Get-Process | Format-Table -Property Name, CPU, Id -AutoSize
Get-Process | Format-List *                          # every property, vertically -- good for inspection
Get-Service | Format-Wide -Column 3
Get-Process | Out-GridView                              # interactive sortable/filterable GUI grid (Windows)
```

A common beginner mistake: piping `Format-Table` output into something else expecting real objects (`Format-Table` output is display-only text-like formatting objects, not the original data). Filter/sort/select **before** formatting, format **last**.

### Exporting Data

```powershell
Get-Process | Export-Csv processes.csv -NoTypeInformation
Get-Process | ConvertTo-Json -Depth 3 | Set-Content processes.json
Get-Process | ConvertTo-Html -Property Name, CPU | Set-Content report.html
Get-Process | Export-Clixml processes.xml               # PowerShell's own serialization -- round-trips perfectly

$restored = Import-Clixml processes.xml                   # full object fidelity preserved, unlike CSV/JSON
```

### `Write-*` Cmdlets — Know the Difference

| Cmdlet | Destination | Notes |
|---|---|---|
| `Write-Output` | pipeline (success stream) | default for returning data |
| `Write-Host` | console directly | bypasses the pipeline — good for user-facing messages |
| `Write-Verbose` | verbose stream | shown only with `-Verbose` |
| `Write-Warning` | warning stream | yellow, shown by default |
| `Write-Error` | error stream | does NOT stop execution by default (see Ch.10) |
| `Write-Debug` | debug stream | shown only with `-Debug` |

```powershell
Write-Output "data"            # goes to pipeline -- can be captured/piped further
Write-Host "status message" -ForegroundColor Green   # console only, never captured by $x = ...
Write-Verbose "Connecting to $host" -Verbose
Write-Warning "Config file missing, using defaults"
```

---

## Chapter 10 — Error Handling

### Terminating vs Non-Terminating Errors

This distinction has no real Bash equivalent and trips up almost everyone coming from another language: most cmdlet errors (a failed `Get-Item` on a missing file, say) are **non-terminating** by default — the script *keeps running* unless you explicitly tell it not to.

```powershell
Get-Item "doesnotexist.txt"     # prints a red error, but the SCRIPT CONTINUES past this line
Write-Output "I still ran"        # this DOES print

$ErrorActionPreference = "Stop"     # GLOBAL: make ALL non-terminating errors stop execution
Get-Item "doesnotexist.txt" -ErrorAction Stop   # PER-CALL: stop execution just for this command
Get-Item "doesnotexist.txt" -ErrorAction SilentlyContinue   # suppress entirely
Get-Item "doesnotexist.txt" -ErrorAction Continue            # default: warn and continue
```

### `try` / `catch` / `finally`

`try`/`catch` only intercepts **terminating** errors — set `-ErrorAction Stop` (or `$ErrorActionPreference = "Stop"`) on the command inside the `try` block, or nothing will be caught.

```powershell
try {
    Get-Content "missing.txt" -ErrorAction Stop
} catch [System.Management.Automation.ItemNotFoundException] {
    Write-Warning "File not found: $($_.Exception.Message)"
} catch {
    Write-Error "Unexpected error: $($_.Exception.Message)"
} finally {
    Write-Output "Cleanup runs regardless of outcome"
}
```

### Inspecting & Throwing Errors

```powershell
try {
    1/0
} catch {
    $_.Exception.Message          # the error text
    $_.Exception.GetType().FullName   # the .NET exception type -- useful for specific `catch` clauses
    $_.ScriptStackTrace              # where it happened
}

$Error[0]                # the most recent error globally, even outside try/catch
$Error.Clear()             # clear the global error history

throw "Something went badly wrong"          # raise your own terminating error
throw [System.IO.FileNotFoundException]::new("Config missing")
```

### Custom Error Records for Library-Quality Functions

```powershell
function Test-Age {
    param([int]$Age)
    if ($Age -lt 0) {
        $exception = [System.ArgumentException]::new("Age cannot be negative")
        $errorRecord = [System.Management.Automation.ErrorRecord]::new(
            $exception, "InvalidAge", [System.Management.Automation.ErrorCategory]::InvalidArgument, $Age
        )
        $PSCmdlet.ThrowTerminatingError($errorRecord)
    }
}
```

---

# Part V — Scripts & Modules

## Chapter 11 — Scripts, Scope, Profiles & Modules

### Script Files & Parameters

```powershell
# Deploy.ps1
[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [ValidateSet("staging", "production")]
    [string]$Environment,

    [switch]$DryRun     # boolean flag -- present or absent, like Bash's [[ flag ]]
)

Write-Output "Deploying to $Environment"
if ($DryRun) { Write-Output "(dry run -- no changes will be made)" }
```

```powershell
./Deploy.ps1 -Environment production
./Deploy.ps1 -Environment production -DryRun
```

### Dot-Sourcing — Importing Functions from Another Script

```powershell
. ./Helpers.ps1            # dot-source: runs Helpers.ps1 in the CURRENT scope, exposing its functions/vars
Get-Helper-Function          # now usable, as if defined locally
```

This is the PowerShell equivalent of Bash's `source script.sh` — without the leading dot, a script runs in its **own** scope and any functions/variables it defines disappear when it finishes.

### PowerShell Profiles (Your `.bashrc` Equivalent)

```powershell
$PROFILE                                          # path to your current user/host profile script
notepad $PROFILE                                    # edit it (Windows)
code $PROFILE                                        # or with VS Code, any platform

# A few common profile additions:
Set-Alias ll Get-ChildItem
function gs { git status }
$env:PATH += ";C:\tools"
```

### Modules — `.psm1` and Manifests `.psd1`

```powershell
# MyModule/MyModule.psm1
function Get-Greeting {
    param([string]$Name)
    "Hello, $Name!"
}
Export-ModuleMember -Function Get-Greeting    # explicitly control what's exposed publicly
```

```powershell
# Generate a manifest (metadata: version, author, dependencies, exported members)
New-ModuleManifest -Path ./MyModule/MyModule.psd1 -RootModule MyModule.psm1 `
    -Author "Hawaii" -ModuleVersion "1.0.0" -FunctionsToExport @("Get-Greeting")
```

```powershell
Import-Module ./MyModule/MyModule.psd1
Get-Greeting -Name "World"
Get-Module                                  # list currently loaded modules
Get-Module -ListAvailable                     # list all INSTALLED modules
Find-Module -Name "Pester"                      # search the PowerShell Gallery
Install-Module -Name "Pester" -Scope CurrentUser  # install from the Gallery (like pip/npm for PowerShell)
```

### Module Search Path & Auto-Loading

PowerShell auto-loads modules located on `$env:PSModulePath` the first time one of their cmdlets is called — you usually don't need an explicit `Import-Module` for properly installed modules.

```powershell
$env:PSModulePath -split [IO.Path]::PathSeparator
```

---

## Chapter 12 — Classes & Enums

PowerShell 5+ supports real OOP via the `class` keyword — useful once your script's data needs validation, behavior, or inheritance beyond what `PSCustomObject` conveniently offers.

### Classes

```powershell
class Server {
    [string]$Name
    [string]$IPAddress
    [bool]$IsOnline

    Server([string]$name, [string]$ip) {
        $this.Name = $name
        $this.IPAddress = $ip
        $this.IsOnline = $false
    }

    [string] Ping() {
        $this.IsOnline = Test-Connection -ComputerName $this.IPAddress -Count 1 -Quiet
        return "$($this.Name): $(if ($this.IsOnline) {'UP'} else {'DOWN'})"
    }
}

$web1 = [Server]::new("web1", "10.0.0.5")
$web1.Ping()
$web1.IsOnline
```

### Inheritance

```powershell
class Database : Server {
    [int]$Port

    Database([string]$name, [string]$ip, [int]$port) : base($name, $ip) {
        $this.Port = $port
    }

    [string] TestConnection() {
        return "Testing $($this.Name):$($this.Port)..."
    }
}

$db = [Database]::new("primary-db", "10.0.0.10", 5432)
$db.Ping()                  # inherited from Server
$db.TestConnection()          # defined on Database
```

### Enums

```powershell
enum Environment {
    Development
    Staging
    Production
}

function Deploy {
    param([Environment]$Target)
    "Deploying to $Target"
}
Deploy -Target Production       # type-checked -- typos like "Productoin" fail immediately
```

### Validating Parameters Against a Class/Enum

```powershell
function Set-Server {
    param([ValidateScript({$_ -is [Server]})][Server]$Target)
    $Target.Ping()
}
```

---

# Part VI — System Programming

## Chapter 13 — File System, Registry, Processes & Services

### File System Operations

```powershell
Get-ChildItem -Path C:\Logs -Recurse -Filter *.log
New-Item -ItemType Directory -Path C:\Temp\NewFolder -Force
New-Item -ItemType File -Path C:\Temp\file.txt
Copy-Item -Path source.txt -Destination dest.txt
Move-Item -Path old.txt -Destination new.txt
Remove-Item -Path file.txt -Force
Remove-Item -Path C:\Temp\OldFolder -Recurse -Force
Test-Path C:\Logs\app.log                              # like Bash's [[ -f file ]]
Rename-Item -Path old.txt -NewName new.txt

Get-Content app.log -Tail 20                              # like `tail -20`
Get-Content app.log -Wait -Tail 10                          # like `tail -f` (follow)
Get-Content app.log | Measure-Object -Line                    # like `wc -l`

# File permissions & ownership (Windows ACLs -- conceptually like chmod/chown but object-based)
Get-Acl C:\Secure\file.txt | Format-List
$acl = Get-Acl C:\Secure\file.txt
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule("alice","FullControl","Allow")
$acl.SetAccessRule($rule)
Set-Acl C:\Secure\file.txt $acl

# On Linux/macOS with pwsh, real POSIX permissions are available too:
Get-ChildItem -Path /etc/passwd | Select-Object Mode, UnixMode 2>$null
```

### Registry (Windows-Specific System Configuration Store)

The registry is exposed as just another PowerShell "drive" (`HKLM:`, `HKCU:`) — the same cmdlets used for files work on it.

```powershell
Get-ChildItem HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion
Get-ItemProperty -Path "HKLM:\SOFTWARE\MyApp" -Name "Version"
New-Item -Path "HKCU:\Software\MyApp" -Force
Set-ItemProperty -Path "HKCU:\Software\MyApp" -Name "Setting1" -Value "Enabled"
Remove-Item -Path "HKCU:\Software\MyApp" -Recurse
```

### Process Management

```powershell
Get-Process                                           # like `ps aux`
Get-Process -Name "chrome"
Get-Process | Where-Object { $_.WorkingSet64 -gt 500MB }

Start-Process -FilePath "notepad.exe"
Start-Process -FilePath "myapp.exe" -ArgumentList "--config", "prod.json" -NoNewWindow -Wait

Stop-Process -Name "notepad" -Force
Stop-Process -Id 1234

(Get-Process -Id $PID).Path        # path to the currently running PowerShell's own executable
```

### Services

```powershell
Get-Service                                    # like `systemctl list-units`
Get-Service -Name "wuauserv"
Get-Service | Where-Object Status -eq "Running"

Start-Service -Name "MyAppService"
Stop-Service -Name "MyAppService" -Force
Restart-Service -Name "MyAppService"
Set-Service -Name "MyAppService" -StartupType Automatic

New-Service -Name "MyAppService" -BinaryPathName "C:\App\myapp.exe" -StartupType Automatic
```

### Event Logs

```powershell
Get-EventLog -LogName Application -Newest 20 -EntryType Error      # classic event log (Windows PowerShell)
Get-WinEvent -LogName System -MaxEvents 20                           # modern, faster, cross-log API
Get-WinEvent -FilterHashtable @{LogName='Application'; Level=2} -MaxEvents 50   # Level 2 = Error

# Writing to the event log from your own scripts (useful for production diagnostics)
New-EventLog -LogName Application -Source "MyApp" -ErrorAction SilentlyContinue
Write-EventLog -LogName Application -Source "MyApp" -EventId 1001 -EntryType Information -Message "Deploy started"
```

---

## Chapter 14 — WMI/CIM & .NET Integration

### CIM Cmdlets — Querying System Information (Modern Replacement for WMI Cmdlets)

```powershell
Get-CimInstance -ClassName Win32_OperatingSystem | Select-Object Caption, Version, OSArchitecture
Get-CimInstance -ClassName Win32_LogicalDisk | Select-Object DeviceID, @{N="FreeGB";E={[math]::Round($_.FreeSpace/1GB,1)}}
Get-CimInstance -ClassName Win32_Process | Where-Object Name -eq "notepad.exe"
Get-CimInstance -ClassName Win32_BIOS

# CIM works over remoting too, querying a fleet of machines
Get-CimInstance -ClassName Win32_OperatingSystem -ComputerName server1, server2 -Credential (Get-Credential)
```

### Direct .NET Integration — The Real Power Underneath PowerShell

Because PowerShell *is* .NET, you can drop into the base class library directly whenever a cmdlet doesn't cover what you need.

```powershell
[System.IO.File]::ReadAllText("C:\config.json")
[System.IO.Path]::Combine("C:\base", "sub", "file.txt")
[System.Net.Dns]::GetHostAddresses("example.com")
[System.Diagnostics.Stopwatch]::StartNew()              # precise timing
[guid]::NewGuid()
[System.Math]::Round(3.14159, 2)

# Loading and using a third-party .NET assembly directly
Add-Type -Path "C:\libs\SomeLibrary.dll"
$instance = [SomeNamespace.SomeClass]::new()

# Even inline C# when a tight loop needs raw .NET performance:
Add-Type -TypeDefinition @"
public class FastMath {
    public static int Square(int x) { return x * x; }
}
"@
[FastMath]::Square(9)        # 81
```

## Chapter 15 — Networking & Remoting

### Networking Cmdlets

```powershell
Test-Connection -ComputerName example.com -Count 2          # like `ping`
Test-NetConnection -ComputerName example.com -Port 443         # like `nc -zv` -- TCP port test
Resolve-DnsName example.com                                       # like `dig`/`host`
Get-NetIPAddress                                                    # like `ip addr`
Get-NetTCPConnection -State Listen                                    # like `ss -tln`

Invoke-WebRequest -Uri https://example.com -OutFile page.html
$response = Invoke-RestMethod -Uri https://api.example.com/data -Method Get
$response.results[0]                                                       # auto-parsed JSON -> objects!

Invoke-RestMethod -Uri https://api.example.com/users -Method Post `
    -Body (@{name="Alice"; email="alice@example.com"} | ConvertTo-Json) `
    -ContentType "application/json"
```

`Invoke-RestMethod` auto-deserializes JSON/XML responses directly into PowerShell objects — there's no separate "now parse it" step the way there is piping `curl` into `jq` in Bash.

### A "Wait for Service" Loop (PowerShell Equivalent of the Bash Pattern in Ch.14 of the Bash Guide)

```powershell
$maxAttempts = 30
for ($i = 0; $i -lt $maxAttempts; $i++) {
    try {
        $r = Invoke-WebRequest -Uri "http://localhost:8080/health" -UseBasicParsing -TimeoutSec 2
        if ($r.StatusCode -eq 200) { Write-Output "Service is up."; break }
    } catch {
        Write-Output "Waiting for service... (attempt $($i+1))"
        Start-Sleep -Seconds 2
    }
}
```

### PowerShell Remoting — Running Commands on Other Machines

```powershell
Enable-PSRemoting -Force                          # run ONCE on the target machine (as admin)

Invoke-Command -ComputerName server1 -ScriptBlock { Get-Service -Name "MyApp" }
Invoke-Command -ComputerName server1, server2 -Credential (Get-Credential) -ScriptBlock {
    Get-CimInstance Win32_OperatingSystem | Select-Object Caption
}

# Persistent sessions -- reuse a connection across multiple commands (faster than reconnecting each time)
$session = New-PSSession -ComputerName server1
Invoke-Command -Session $session -ScriptBlock { Get-Process }
Enter-PSSession -Session $session                    # interactive remote shell, like `ssh`
Remove-PSSession -Session $session
```

### SSH-Based Remoting (Cross-Platform, PowerShell 7+)

```powershell
Invoke-Command -HostName linuxserver.example.com -UserName admin -ScriptBlock { uname -a }
Enter-PSSession -HostName linuxserver.example.com -UserName admin    # SSH-based, works against Linux targets too
```

This SSH transport is what makes `pwsh` genuinely useful for managing **mixed Windows/Linux fleets** from one tool and one scripting language — a capability Bash alone doesn't have toward Windows targets.

---

# Part VII — Scheduling & Configuration

## Chapter 16 — Scheduled Tasks & Background Jobs

### Windows Scheduled Tasks (Cron's Windows Counterpart)

```powershell
$action  = New-ScheduledTaskAction -Execute "pwsh.exe" -Argument "-File C:\Scripts\Backup.ps1"
$trigger = New-ScheduledTaskTrigger -Daily -At "2:00AM"
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName "NightlyBackup" -Action $action -Trigger $trigger -Principal $principal

Get-ScheduledTask -TaskName "NightlyBackup"
Start-ScheduledTask -TaskName "NightlyBackup"          # run it immediately, on demand
Disable-ScheduledTask -TaskName "NightlyBackup"
Unregister-ScheduledTask -TaskName "NightlyBackup" -Confirm:$false
```

### Linux `cron` From `pwsh` (Cross-Platform Note)

On Linux, `pwsh` itself isn't the scheduler — you still use `cron` or a `systemd` timer (Bash Ch.15) and simply point the job at `pwsh -File script.ps1`:

```bash
# crontab entry, calling a PowerShell script via pwsh
0 2 * * * pwsh -File /opt/scripts/backup.ps1 >> /var/log/backup.log 2>&1
```

### Background Jobs

```powershell
$job = Start-Job -ScriptBlock { Get-Process | Sort-Object CPU -Descending | Select-Object -First 5 }
Get-Job                                  # list jobs and their state
Receive-Job -Job $job -Wait                # block until done, then return its output
Remove-Job -Job $job

# Multiple parallel jobs, like backgrounding several Bash commands with `&`
$jobs = foreach ($server in "server1", "server2", "server3") {
    Start-Job -ScriptBlock { param($s) Test-Connection -ComputerName $s -Count 1 } -ArgumentList $server
}
$jobs | Wait-Job | Receive-Job
```

### Thread Jobs & `ForEach-Object -Parallel` — Lighter-Weight Concurrency (PS7+)

`Start-Job` spins up a whole separate process (heavy). `ThreadJob`/`ForEach-Object -Parallel` use threads instead — much lighter, ideal for many short-lived parallel tasks like the network probe above.

```powershell
Install-Module ThreadJob -Scope CurrentUser -Force    # ships built-in on PS7+ in most distributions
1..3 | ForEach-Object -ThrottleLimit 3 -Parallel {
    "server$_" | ForEach-Object { Test-Connection -ComputerName $_ -Count 1 }
}
```

---

## Chapter 17 — Configuration Files: JSON, XML, CSV, INI

### JSON — Native, First-Class Support

```powershell
# config.json: {"database": {"host": "localhost", "port": 5432}, "features": ["a","b"]}
$config = Get-Content config.json -Raw | ConvertFrom-Json
$config.database.host           # localhost
$config.database.port             # 5432
$config.features                    # array

# Generating JSON
$settings = [PSCustomObject]@{
    database = @{ host = "localhost"; port = 5432 }
    features = @("a", "b")
}
$settings | ConvertTo-Json -Depth 5 | Set-Content config.json
```

### XML — Also Native

```powershell
# config.xml: <config><database host="localhost" port="5432" /></config>
[xml]$xml = Get-Content config.xml
$xml.config.database.host        # localhost -- attribute access via dot notation
$xml.SelectNodes("//database")     # XPath queries supported too

$xml = [xml]@"
<config><database host="localhost" port="5432" /></config>
"@
$xml.Save("config.xml")
```

### CSV — Native, Object-Aware (No Manual Field-Splitting Needed)

```powershell
$users = Import-Csv users.csv             # each row becomes a PSCustomObject automatically
$users | Where-Object Age -gt 30 | Select-Object Name, Email
$users | Export-Csv filtered.csv -NoTypeInformation
```

### INI Files (No Native Cmdlet — a Small Reusable Parser)

```powershell
function ConvertFrom-Ini {
    param([string]$Path)
    $ini = [ordered]@{}
    $section = ""
    foreach ($line in Get-Content $Path) {
        $line = $line.Trim()
        if ($line -match '^\[(.+)\]$') {
            $section = $Matches[1]
            $ini[$section] = [ordered]@{}
        } elseif ($line -match '^([^;#][^=]*)=(.*)$') {
            $ini[$section][$Matches[1].Trim()] = $Matches[2].Trim()
        }
    }
    return $ini
}

$config = ConvertFrom-Ini -Path "config.ini"
$config.database.host
```

### `.psd1` — PowerShell's Own "Native" Data Format

```powershell
# settings.psd1
@{
    Database = @{ Host = "localhost"; Port = 5432 }
    Features = @("a", "b")
}
```

```powershell
$settings = Import-PowerShellDataFile -Path settings.psd1   # safe, structured (no arbitrary code execution)
$settings.Database.Host
```

`Import-PowerShellDataFile` is the PowerShell-native config format — used heavily by module manifests (`.psd1`) themselves — and is preferred over `Invoke-Expression`/dot-sourcing a settings script when you just need data, since it deliberately disallows executable code.

---

# Part VIII — Build Automation & Compilers

## Chapter 18 — Build Automation: MSBuild, `dotnet`, `csc`, CI/CD

PowerShell occupies the same "orchestration glue" role on the .NET/Windows side that Bash occupies on Linux — driving compilers, managing build artifacts, and running CI pipelines.

### Invoking the .NET CLI (`dotnet`) Directly

```powershell
dotnet new console -o MyApp
Set-Location MyApp
dotnet build -c Release
dotnet run
dotnet test
dotnet publish -c Release -r win-x64 --self-contained true -o ./publish
```

### Driving `csc` (the C# Compiler) Directly — Without a Project File

```powershell
# A minimal compile, comparable to invoking gcc directly in the Bash tutorial
csc /out:app.exe Program.cs
.\app.exe
```

### MSBuild — Compiling `.sln`/`.csproj` Files

```powershell
msbuild MySolution.sln /p:Configuration=Release /p:Platform=x64 /m   # /m = parallel build
msbuild MyProject.csproj /t:Clean,Build /v:minimal
```

### A Hand-Written Build Script (PowerShell Equivalent of the Bash `build.sh` from Chapter 17 of the Bash Guide)

```powershell
# build.ps1 -- only recompile if source is newer than the existing binary
[CmdletBinding()]
param([string]$Configuration = "Release")

$ErrorActionPreference = "Stop"
$srcFiles = Get-ChildItem -Path .\src -Filter *.cs
$outputDir = ".\build"
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null

$needsBuild = $false
foreach ($file in $srcFiles) {
    $obj = Join-Path $outputDir ($file.BaseName + ".dll")
    if (-not (Test-Path $obj) -or $file.LastWriteTime -gt (Get-Item $obj).LastWriteTime) {
        $needsBuild = $true
        break
    }
}

if ($needsBuild) {
    Write-Output "Changes detected -- building..."
    dotnet build -c $Configuration -o $outputDir
    if ($LASTEXITCODE -ne 0) { throw "Build failed with exit code $LASTEXITCODE" }
} else {
    Write-Output "Already up to date -- skipping build."
}
Write-Output "Build artifact: $outputDir"
```

Note `$LASTEXITCODE` — PowerShell's equivalent of Bash's `$?`, but specifically for the exit code of the last **native/external** executable (cmdlets use `$?`/exceptions instead, since they're not separate processes).

### Invoking Other Compilers/Toolchains from PowerShell (Cross-Platform `pwsh`)

```powershell
# C/C++ via gcc/clang -- works identically on pwsh+Linux/macOS
& gcc -Wall -O2 -o myprogram main.c
& clang++ -std=c++20 -o app main.cpp

# Go, Rust, Node -- PowerShell calls them exactly the way Bash does
& go build -o bin/app.exe ./cmd/app
& cargo build --release
& npx tsc --outDir dist
```

The `&` (call operator) is required when invoking an executable whose path is stored in a variable or contains special characters — PowerShell otherwise treats a bare string as data, not a command to run.

### A CI-Style Build Script (the PowerShell Equivalent of a GitHub Actions/Azure DevOps Build Step)

```powershell
# ci-build.ps1
$ErrorActionPreference = "Stop"
trap {
    Write-Error "BUILD FAILED: $($_.Exception.Message)"
    exit 1
}

Write-Output "==> Restoring dependencies"
dotnet restore

Write-Output "==> Building"
dotnet build -c Release --no-restore
if ($LASTEXITCODE -ne 0) { throw "dotnet build failed" }

Write-Output "==> Running tests"
dotnet test -c Release --no-build --logger "console;verbosity=normal"
if ($LASTEXITCODE -ne 0) { throw "Tests failed" }

Write-Output "==> Publishing"
dotnet publish -c Release -o ./artifacts
Write-Output "BUILD SUCCEEDED"
```

```yaml
# Excerpt of a GitHub Actions step that simply invokes the script above --
# the actual build logic lives in PowerShell, exactly as it does for Bash + CI YAML.
- name: Build
  shell: pwsh
  run: ./ci-build.ps1
```

The structural parallel to the Bash CI chapter is intentional: **the YAML is a trigger; the real build engine is the script** — whether that script is Bash or PowerShell is mostly a platform/ecosystem choice, not a difference in what's actually happening underneath.

---

# Part IX — Professional Engineering

## Chapter 19 — Security: Execution Policy, Signing, Credentials

### Execution Policy Recap & Script Signing

```powershell
Get-ExecutionPolicy -List
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned

# Signing a script with a code-signing certificate (removes the need to loosen policy further)
$cert = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert | Select-Object -First 1
Set-AuthenticodeSignature -FilePath .\Deploy.ps1 -Certificate $cert
Get-AuthenticodeSignature .\Deploy.ps1            # verify a script's signature/status
```

### Credentials & Secrets — Never Hardcode Passwords

```powershell
$cred = Get-Credential                       # interactive prompt -- never paste a plaintext password into a script
Invoke-Command -ComputerName server1 -Credential $cred -ScriptBlock { Get-Service }

# SecureString -- in-memory encrypted representation of sensitive strings
$securePassword = Read-Host -AsSecureString "Enter password"
$cred = [System.Management.Automation.PSCredential]::new("username", $securePassword)

# Microsoft.PowerShell.SecretManagement -- a proper secrets vault, the PowerShell-native
# equivalent of reading from a secrets manager rather than an .env file
Install-Module Microsoft.PowerShell.SecretManagement -Scope CurrentUser
Register-SecretVault -Name LocalVault -ModuleName Microsoft.PowerShell.SecretStore
Set-Secret -Name "ApiKey" -Secret "abc123"
$apiKey = Get-Secret -Name "ApiKey" -AsPlainText
```

### Input Validation & Avoiding Injection

```powershell
# DON'T build commands as strings and Invoke-Expression them with untrusted input
Invoke-Expression "Get-Process -Name $userInput"     # injection risk, analogous to Bash's eval

# DO pass values as actual parameters
Get-Process -Name $userInput

# Validate against an explicit allow-list before acting on user input
[ValidateSet("start","stop","restart")]
param([string]$Action)
```

### Constrained Language Mode & JEA (Just Enough Administration) — Awareness

For environments where a script's capabilities must be tightly restricted (e.g., a help-desk operator running a delegated script), PowerShell supports **Just Enough Administration (JEA)** — defining a role-capability file that exposes only specific cmdlets/parameters to specific users, enforced by a constrained PowerShell session. This is the PowerShell-native equivalent in spirit to a Unix restricted shell or sudoers rule, but considerably more granular.

---

## Chapter 20 — Debugging, Logging & Performance

### Debugging Tools

```powershell
Set-PSBreakpoint -Script .\script.ps1 -Line 10        # set a breakpoint, then run the script
Set-PSBreakpoint -Command "Get-Process"                  # break whenever this command is called
Set-PSBreakpoint -Variable "config" -Mode Write            # break when $config is modified

# Step through interactively once a breakpoint is hit: s (step), v (step over), c (continue), q (quit)

$DebugPreference = "Continue"
Write-Debug "Connecting with timeout=$timeout"

Set-StrictMode -Version Latest    # errors LOUDLY on uninitialized variables, like Bash's `set -u`
```

VS Code's PowerShell extension provides a full graphical debugger (breakpoints, call stack, variable inspection) — for anything beyond a one-liner, this is far more productive than `Write-Host`-driven debugging.

### Logging

```powershell
function Write-Log {
    param([string]$Message, [ValidateSet("INFO","WARN","ERROR")][string]$Level = "INFO")
    $line = "{0} [{1}] {2}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Level, $Message
    Add-Content -Path "C:\Logs\myscript.log" -Value $line
    Write-Host $line
}
Write-Log "Starting backup"
Write-Log "Connection failed" -Level ERROR

Start-Transcript -Path "C:\Logs\session.log"   # capture EVERYTHING printed during a session, automatically
Stop-Transcript
```

### Performance

```powershell
Measure-Command { Get-ChildItem -Recurse C:\Windows }     # time a block of code

# Prefer .NET collections over += in tight loops (recap from Chapter 6)
$list = [System.Collections.Generic.List[int]]::new()
1..100000 | ForEach-Object { $list.Add($_) }       # fast
# vs:
$arr = @(); 1..100000 | ForEach-Object { $arr += $_ }   # slow -- O(n^2) due to reallocation each time

# Avoid Write-Host in tight loops over large datasets -- it's surprisingly costly; batch output instead
$results = foreach ($item in $bigCollection) { Process-Item $item }   # collect, then output/format once

# Use the pipeline's native streaming for large datasets instead of loading everything into memory first
Get-Content hugefile.log | Where-Object { $_ -match "ERROR" }    # streams line-by-line
```

---

## Chapter 21 — Cross-Platform PowerShell

### What Works Everywhere vs Windows-Only

```powershell
$IsWindows; $IsLinux; $IsMacOS          # automatic platform-detection variables, PS7+

if ($IsWindows) {
    Get-Service                            # Windows-only cmdlet
} elseif ($IsLinux) {
    & systemctl status nginx                  # call the native tool instead
}
```

| Works everywhere (PS7+) | Windows-only |
|---|---|
| `Get-ChildItem`, `Get-Content`, pipeline cmdlets | `Get-Service`, registry (`HKLM:`), WMI/CIM-heavy cmdlets |
| `Invoke-RestMethod`, `ConvertTo-Json`, classes | Active Directory module, COM objects |
| SSH-based remoting | WinRM-based remoting (works cross-platform as a *client*, but the *target* is typically Windows) |

### Path Handling Across Platforms

```powershell
Join-Path -Path "/home/user" -ChildPath "file.txt"     # cross-platform-correct path building
[System.IO.Path]::DirectorySeparatorChar                  # '/' on Linux/macOS, '\' on Windows
```

Always build paths with `Join-Path`/`Split-Path` rather than string-concatenating `\` or `/` literally — this is what makes a script portable between Windows and `pwsh` on Linux without modification.

---

# Part X — Real-World Projects

## Chapter 22 — Four Complete Projects

The same four projects from the Bash tutorial, reimplemented idiomatically in PowerShell — notice how object-oriented filtering (`Where-Object`, `Sort-Object`) replaces text-pipeline tools like `awk`/`grep` for equivalent logic.

### Project 1 — System Health Check & Monitor

```powershell
# HealthCheck.ps1
[CmdletBinding()]
param(
    [int]$CpuThreshold = 90,
    [int]$MemThreshold = 90,
    [int]$DiskThreshold = 85
)
$ErrorActionPreference = "Stop"
$exitCode = 0

function Test-Threshold {
    param([string]$Label, [double]$Value, [double]$Threshold)
    if ($Value -ge $Threshold) {
        Write-Output "[CRITICAL] $Label at $([math]::Round($Value,1))% (threshold $Threshold%)"
        $script:exitCode = 2
    } else {
        Write-Output "[OK] $Label at $([math]::Round($Value,1))%"
    }
}

if ($IsWindows -or $null -eq $IsWindows) {
    $cpu = (Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average
    $os = Get-CimInstance Win32_OperatingSystem
    $memUsed = 100 * (1 - ($os.FreePhysicalMemory / $os.TotalVisibleMemorySize))
    $disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'"
    $diskUsed = 100 * (1 - ($disk.FreeSpace / $disk.Size))
} else {
    $cpu = 100 - [double](& sh -c "top -bn1 | grep Cpu | awk '{print \$8}'")
    $memUsed = [double](& sh -c "free | awk '/Mem/{printf \"%.1f\", (\$2-\$7)/\$2*100}'")
    $diskUsed = [double]((& df / | Select-Object -Last 1) -replace '.*\s(\d+)%.*','$1')
}

Test-Threshold "CPU usage"    $cpu      $CpuThreshold
Test-Threshold "Memory usage" $memUsed  $MemThreshold
Test-Threshold "Disk usage"   $diskUsed $DiskThreshold

exit $exitCode
```

### Project 2 — Automated Backup with Rotation

```powershell
# Backup.ps1
[CmdletBinding()]
param(
    [Parameter(Mandatory)][string]$SourcePath,
    [Parameter(Mandatory)][string]$BackupRoot,
    [int]$RetentionDays = 7
)
$ErrorActionPreference = "Stop"

function Write-Log { param($Message) Write-Output "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $Message" }

New-Item -ItemType Directory -Path $BackupRoot -Force | Out-Null
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$archiveName = "backup-$timestamp.zip"
$archivePath = Join-Path $BackupRoot $archiveName

Write-Log "Starting backup of $SourcePath -> $archivePath"
Compress-Archive -Path $SourcePath -DestinationPath $archivePath -CompressionLevel Optimal
$sizeMB = [math]::Round((Get-Item $archivePath).Length / 1MB, 2)
Write-Log "Backup created: ${sizeMB}MB"

Write-Log "Pruning backups older than $RetentionDays days"
Get-ChildItem -Path $BackupRoot -Filter "backup-*.zip" |
    Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$RetentionDays) } |
    ForEach-Object { Write-Log "Deleting old backup: $($_.Name)"; Remove-Item $_.FullName }

Write-Log "Backup complete. Current backups:"
Get-ChildItem -Path $BackupRoot -Filter "backup-*.zip" | Format-Table Name, Length, LastWriteTime
```

### Project 3 — Log Analyzer

```powershell
# LogAnalyzer.ps1 -- summarizes an IIS/Nginx-style access log
[CmdletBinding()]
param([Parameter(Mandatory)][string]$LogFile)

if (-not (Test-Path $LogFile)) { throw "Cannot read $LogFile" }

$lines = Get-Content $LogFile
Write-Output "=== Log Analysis: $LogFile ==="
Write-Output "Total requests: $($lines.Count)"

# Parse a simple combined-log-format-style line: IP - - [date] "METHOD path proto" status size
$parsed = $lines | ForEach-Object {
    if ($_ -match '^(?<ip>\S+).*\"(?<method>\S+)\s(?<path>\S+).*\"\s(?<status>\d{3})') {
        [PSCustomObject]@{ IP = $Matches.ip; Path = $Matches.path; Status = $Matches.status }
    }
}

Write-Output "`n--- Top 10 IPs ---"
$parsed | Group-Object IP | Sort-Object Count -Descending | Select-Object -First 10 Count, Name

Write-Output "`n--- Top 10 Requested Paths ---"
$parsed | Group-Object Path | Sort-Object Count -Descending | Select-Object -First 10 Count, Name

Write-Output "`n--- Status Code Breakdown ---"
$parsed | Group-Object Status | Sort-Object Count -Descending | Select-Object Count, Name

$errors = ($parsed | Where-Object { $_.Status -match '^5' }).Count
$errorRate = if ($parsed.Count) { [math]::Round(($errors / $parsed.Count) * 100, 2) } else { 0 }
Write-Output "`n--- Error Rate ---"
Write-Output "5xx errors: $errors / $($parsed.Count) ($errorRate%)"
```

### Project 4 — Deployment Script

```powershell
# Deploy.ps1
[CmdletBinding()]
param([Parameter(Mandatory)][string]$Environment)
$ErrorActionPreference = "Stop"

$AppDir       = "C:\Apps\MyApp"
$ReleasesDir  = Join-Path $AppDir "releases"
$CurrentLink  = Join-Path $AppDir "current"
$ReleaseId    = Get-Date -Format "yyyyMMddHHmmss"
$ReleaseDir   = Join-Path $ReleasesDir $ReleaseId
$HealthUrl    = "http://localhost:8080/health"
$PreviousRelease = $null

function Write-Log { param($Message) Write-Output "[$(Get-Date -Format 'HH:mm:ss')] $Message" }

function Invoke-Rollback {
    Write-Log "DEPLOY FAILED -- rolling back to previous release"
    if ($PreviousRelease) {
        cmd /c mklink /D $CurrentLink $PreviousRelease 2>$null
        Restart-Service -Name "MyAppService"
        Write-Log "Rolled back to $PreviousRelease"
    }
    exit 1
}

try {
    if (Test-Path $CurrentLink) {
        $PreviousRelease = (Get-Item $CurrentLink).Target
    }

    Write-Log "Deploying release $ReleaseId to $Environment"
    New-Item -ItemType Directory -Path $ReleaseDir -Force | Out-Null
    git clone --depth 1 --branch $Environment git@example.com:org/myapp.git $ReleaseDir

    Set-Location $ReleaseDir
    Write-Log "Installing dependencies & building"
    npm ci --omit=dev
    npm run build

    Write-Log "Switching symlink to new release"
    if (Test-Path $CurrentLink) { Remove-Item $CurrentLink -Force }
    cmd /c mklink /D $CurrentLink $ReleaseDir
    Restart-Service -Name "MyAppService"

    Write-Log "Waiting for health check"
    $healthy = $false
    for ($i = 0; $i -lt 10; $i++) {
        try {
            $r = Invoke-WebRequest -Uri $HealthUrl -UseBasicParsing -TimeoutSec 3
            if ($r.StatusCode -eq 200) { $healthy = $true; break }
        } catch { Start-Sleep -Seconds 3 }
    }

    if (-not $healthy) { throw "Health check never passed" }

    Write-Log "Health check passed. Deployment successful."
    Get-ChildItem $ReleasesDir | Sort-Object LastWriteTime -Descending |
        Select-Object -Skip 5 | Remove-Item -Recurse -Force
}
catch {
    Write-Log "Error: $($_.Exception.Message)"
    Invoke-Rollback
}
```

---

# Appendix A — PowerShell Cheat Sheet

### Safety Header
```powershell
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
```

### Comparison Operators
| Op | Meaning | Op | Meaning |
|---|---|---|---|
| `-eq` / `-ne` | equal / not equal | `-and` / `-or` | logical AND/OR |
| `-gt` / `-lt` | greater / less than | `-not` / `!` | logical NOT |
| `-ge` / `-le` | >= / <= | `-like` | wildcard match |
| `-match` | regex match | `-contains` | collection membership |

### Variables & Types
```powershell
$x = "value"; [int]$n = 5; $arr = @(1,2,3); $map = @{k="v"}
$x.GetType().FullName
```

### Pipeline Essentials
```powershell
Get-X | Where-Object { cond } | Sort-Object Prop | Select-Object -First N
Get-X | ForEach-Object { $_.Prop }
Get-X | Group-Object Prop
Get-X | Measure-Object -Property P -Sum -Average
```

### Output & Export
```powershell
Format-Table -AutoSize     Format-List *          Out-GridView
ConvertTo-Json -Depth N    Export-Csv -NoTypeInformation     Export-Clixml
```

### Error Handling
```powershell
try { Cmd -ErrorAction Stop } catch [SpecificException] { } catch { } finally { }
$_.Exception.Message    $Error[0]    throw "message"
```

### Functions
```powershell
function Verb-Noun {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Name)
    process { "Hello $Name" }
}
```

### Common Cmdlets by Bash Equivalent
| Bash | PowerShell |
|---|---|
| `ls` | `Get-ChildItem` |
| `cat` | `Get-Content` |
| `grep` | `Select-String` |
| `find` | `Get-ChildItem -Recurse` |
| `ps` | `Get-Process` |
| `kill` | `Stop-Process` |
| `curl` | `Invoke-WebRequest` / `Invoke-RestMethod` |
| `cp`/`mv`/`rm` | `Copy-Item`/`Move-Item`/`Remove-Item` |
| `chmod`/`chown` | `Set-Acl`/`Get-Acl` (Windows ACL model) |
| `export VAR=` | `$env:VAR =` |

### Remoting & Jobs
```powershell
Invoke-Command -ComputerName srv -ScriptBlock { ... }
Start-Job -ScriptBlock { ... }; Receive-Job -Wait
1..N | ForEach-Object -Parallel { ... } -ThrottleLimit N
```

### Debugging
```powershell
Measure-Command { ... }
Set-PSBreakpoint -Script file.ps1 -Line N
$DebugPreference = "Continue"; Write-Debug "msg"
```

---

# Appendix B — Further Resources

- **Microsoft Learn — PowerShell Docs** — the canonical, authoritative reference for every cmdlet, `about_*` topic, and language feature: https://learn.microsoft.com/powershell/
- **`about_*` conceptual help** — `Get-Help about_Operators`, `about_Functions_Advanced`, `about_Scopes`, etc. — genuinely excellent, often-overlooked built-in documentation.
- **PowerShell Gallery** — https://www.powershellgallery.com — the module ecosystem (`Find-Module`, `Install-Module`).
- **PSScriptAnalyzer** — the `shellcheck` equivalent for PowerShell: `Install-Module PSScriptAnalyzer` then `Invoke-ScriptAnalyzer -Path script.ps1`. Use it on everything, same as `shellcheck` for Bash.
- **Pester** — the standard PowerShell testing framework, worth learning once your scripts grow past a few hundred lines.

**See also:** `bash_tutorial.md` for the Bash equivalent of everything in this document, and `bash_vs_powershell_and_projects.md` for direct side-by-side comparisons.
