Chapter 08Lesson 01~135 minutes

From Interactive Commands to .ps1 Scripts

Turn interactive PowerShell work into reproducible .ps1 entry points that behave predictably across working directories, users, and automation runners.

Learning objectives

  • Explain why scripts improve reproducibility, reviewability, version control, and non-interactive execution.
  • Invoke .ps1 files with explicit paths, the call operator, and pwsh -File.
  • Distinguish the caller working directory from the script directory and use $PSScriptRoot for script-owned resources.
  • Explain dot-sourcing as deliberate current-scope execution rather than normal script invocation.
  • Declare genuine prerequisites with #Requires and separate emitted objects, return behavior, and exit status.
  • Build and verify a script that works from more than one working directory.

1. Interactive success is not yet reproducible automation

An interactive command is useful for exploration, but operations work must survive a different terminal, a different working directory, code review, scheduling, and another engineer. A script is a text file containing PowerShell statements that can be versioned and executed again. The .ps1 extension tells PowerShell and tooling that the file contains PowerShell script code.

Moving commands into a script creates an explicit artifact. Git can show exactly what changed, reviewers can reason about the same code you ran, CI can execute it non-interactively, and you can attach requirements and help to the file. The goal is not “put commands in a file”; it is “make behavior repeatable.”

Get-Process -Id $PID |
    Select-Object ProcessName, Id, Path

# The same statements can be saved as Show-Shell.ps1.
DevOps habit: When a command becomes part of an operational procedure, move it into version-controlled code before it becomes tribal knowledge.

2. A .ps1 file is code; invoke it deliberately

PowerShell does not automatically run a script in the current directory just because its filename matches what you type. Specify a path such as ./Show-Context.ps1. The call operator & invokes a command, script, or script block. From an external shell or automation runner, pwsh -File starts PowerShell and tells it which script file to execute.

# From an existing PowerShell session
& ./Show-Context.ps1

# From a shell or CI step that starts PowerShell
pwsh -NoProfile -File ./Show-Context.ps1

Using -NoProfile in automation is often useful because it removes personal profile customizations from the execution environment. Lesson 5 explains why that matters. The script path still has to be correct relative to the process working directory.

3. The working directory and the script directory are different concepts

$PWD represents the current PowerShell location. $PSScriptRoot is the directory containing the executing script. A scheduler, CI runner, or engineer can launch a script from a different directory, so code that assumes “the current directory is where my script lives” eventually fails.

# Show-Context.ps1
[pscustomobject]@{
    WorkingDirectory = $PWD.Path
    ScriptDirectory  = $PSScriptRoot
    ScriptPath       = $PSCommandPath
}

If a script owns a sibling file such as config/defaults.json, build that path from $PSScriptRoot. If the user supplied a path, resolve it relative to the user’s intended working context instead. Those are different contracts.

$defaultsPath = Join-Path $PSScriptRoot 'config/defaults.json'
if (Test-Path -LiteralPath $defaultsPath) {
    Get-Item -LiteralPath $defaultsPath
}

4. Build a first script that works from different launch directories

The following script creates no machine-wide state. It reads a data file next to the script and emits structured objects. The important design choice is path anchoring: owned resources are relative to the script, not to whichever directory happened to be current at launch time.

# Show-Targets.ps1
#Requires -Version 7.0

$targetFile = Join-Path $PSScriptRoot 'targets.txt'

if (-not (Test-Path -LiteralPath $targetFile -PathType Leaf)) {
    throw "Target file not found: $targetFile"
}

Get-Content -LiteralPath $targetFile |
    Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
    ForEach-Object {
        [pscustomobject]@{
            Target = $_.Trim()
            Source = $targetFile
        }
    }

Launch the same script from its own directory and from another directory. $PWD changes; $PSScriptRoot does not. That observable behavior is the reason to prefer script-relative paths for files shipped with your tool.

5. Dot-sourcing runs a script in the current scope, so use it intentionally

Normal script invocation gets its own script scope. Dot-sourcing uses a dot followed by a space and a path, and executes the file in the current scope. Functions and variables created by the dot-sourced script therefore remain visible afterward. That is useful for some development workflows, but it also means the script can mutate the caller’s state.

# helper.ps1
$ToolMode = 'Training'
function Get-ToolMode { $ToolMode }

# Normal invocation: changes stay in the script scope
& ./helper.ps1

# Dot-sourcing: definitions are created in the current scope
. ./helper.ps1
Get-ToolMode
Do not use dot-sourcing as a default module system. If reusable commands belong to a tool, Chapter 15 will package them as a module with a defined public surface. Dot-source only when sharing caller scope is genuinely part of the design.

6. #Requires fails early when the runtime contract is not met

#Requires declares prerequisites for the entire script. Microsoft documents requirements for a PowerShell version, modules, PowerShell edition, and—on Windows—elevation. If a requirement is not satisfied, the script does not proceed into normal execution.

#Requires -Version 7.0
#Requires -PSEdition Core

# Module example when your script truly depends on one:
#Requires -Modules @{ ModuleName = 'Microsoft.PowerShell.Management'; ModuleVersion = '7.0.0' }

A script may contain multiple #Requires statements. They apply to the script globally even if written inside a function. #Requires -RunAsAdministrator is Windows-oriented: current Microsoft documentation says the requirement is ignored on non-Windows systems. Do not use it as a portable “root required” check.

Prefer the narrowest requirement: Do not pin a version or module merely because it was installed on your workstation. Require only features the script actually needs.

7. Emitted objects, return, and exit codes solve different problems

PowerShell writes successful expression and command results to the success output stream. A script can therefore emit objects without a return statement. The return keyword exits the current script/function/script block at that point and can also emit a value, but it does not magically suppress output emitted earlier.

# Emit an object for another PowerShell command to consume
[pscustomobject]@{ Status = 'Ready'; CheckedAt = Get-Date }

# Exit this script early from a guard condition
if (-not $env:PATH) {
    return
}

exit N communicates process/script status to a host. With pwsh -File, an explicit exit 2 becomes the process exit code. That is useful for CI and schedulers. It is not the same thing as returning an object.

# Validate-Environment.ps1
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
    Write-Error 'git is required for this operation.'
    exit 2
}

[pscustomobject]@{ Tool = 'git'; Status = 'Available' }
exit 0

On Unix-like systems, process exit codes are conventionally limited to 0–255; keep automation exit codes small and documented.

8. Execution context is an input to your script even when you did not declare it

A script runs inside a PowerShell process with a current directory, environment variables, loaded modules, preferences, profiles, credentials, platform, and PowerShell version. Those ambient values are part of the execution context. Predictable scripts either avoid depending on them or turn the important ones into explicit parameters/configuration.

[pscustomobject]@{
    PowerShell = $PSVersionTable.PSVersion.ToString()
    Edition    = $PSEdition
    Platform   = if ($IsWindows) {'Windows'} elseif ($IsLinux) {'Linux'} else {'macOS'}
    WorkingDir = $PWD.Path
    ScriptRoot = $PSScriptRoot
}

This is why “it worked in my terminal” is weak evidence. A useful script makes assumptions observable and ideally validates them.

9. Lab: run one script predictably from two different working directories

Create a disposable directory with a script and a sibling data file. The script must find its data through $PSScriptRoot, not through $PWD.

$root = Join-Path ([System.IO.Path]::GetTempPath()) 'ps-script-entry-lab'
$tool = Join-Path $root 'tool'
New-Item -ItemType Directory -Path $tool -Force | Out-Null

Set-Content -LiteralPath (Join-Path $tool 'targets.txt') -Value @('api-01','worker 02')

$scriptText = @'
#Requires -Version 7.0
$data = Join-Path $PSScriptRoot 'targets.txt'
Get-Content -LiteralPath $data | ForEach-Object {
    [pscustomobject]@{ Target = $_; ScriptRoot = $PSScriptRoot; WorkingDir = $PWD.Path }
}
'@
Set-Content -LiteralPath (Join-Path $tool 'Show-Targets.ps1') -Value $scriptText -Encoding utf8

Push-Location $tool
& ./Show-Targets.ps1
Pop-Location

Push-Location ([System.IO.Path]::GetTempPath())
& (Join-Path $tool 'Show-Targets.ps1')
Pop-Location

Remove-Item -LiteralPath $root -Recurse -Force
  • Both runs should emit two structured objects.
  • The WorkingDir values should differ between runs.
  • The ScriptRoot values should be identical.
  • No machine-wide environment variables or privileged paths are modified.

10. Common script-entry mistakes and safer patterns

MistakeWhy it failsSafer pattern
Assume $PWD equals script folderSchedulers and callers choose the working directoryUse $PSScriptRoot for resources shipped with the script
Dot-source every helper fileCaller scope becomes hidden shared stateUse normal invocation or later, a module
Use Write-Host as the only resultOther commands cannot reuse the dataEmit objects; reserve host text for human-only presentation
Use exit as a return valueProcess status and data output are different channelsEmit data; use a documented exit code for process status
Depend on personal aliases/profileCI and other users may not have themUse canonical commands and test with pwsh -NoProfile

11. Knowledge check

Question 1. Why is $PSScriptRoot safer than $PWD for a config file shipped next to a script?

Question 2. What does dot-sourcing change compared with normal script invocation?

Question 3. What is the job of #Requires?

Question 4. Is return 5 the same as process exit code 5?

Question 5. Why run automation with pwsh -NoProfile during testing?

12. Summary

A production-minded PowerShell script is a version-controlled entry point with explicit paths, requirements, outputs, and execution assumptions. Use $PSScriptRoot for files owned by the script, invoke scripts with an explicit path or pwsh -File, reserve dot-sourcing for deliberate current-scope behavior, declare genuine prerequisites with #Requires, emit reusable objects, and use exit codes only to communicate status to the host process.

13. Further reading

Next lesson

Replace hard-coded values with a discoverable parameter contract

Continue to Lesson 2, where the chapter builds on this boundary with replace hard-coded values with a discoverable parameter contract.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.