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.
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
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.
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
| Mistake | Why it fails | Safer pattern |
|---|---|---|
Assume $PWD equals script folder |
Schedulers and callers choose the working directory |
Use $PSScriptRoot for resources shipped with
the script
|
| Dot-source every helper file | Caller scope becomes hidden shared state | Use normal invocation or later, a module |
Use Write-Host as the only result |
Other commands cannot reuse the data | Emit objects; reserve host text for human-only presentation |
Use exit as a return value |
Process status and data output are different channels | Emit data; use a documented exit code for process status |
| Depend on personal aliases/profile | CI and other users may not have them |
Use 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?
$PSScriptRoot follows the script file
location while $PWD follows the caller’s current
location.
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?
return exits the current scope and can emit data;
exit 5 communicates an exit status to the
host/calling script.
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
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.