PowerShell Automation for Development Workflows
Practical guide to using PowerShell for automating development workflows, covering scripting fundamentals, cross-platform support, CI/CD integration, and common automation patterns for Node.js developers.
PowerShell Automation for Development Workflows
PowerShell is not just a Windows tool anymore. PowerShell 7+ runs on macOS, Linux, and Windows, and it handles structured data natively — objects instead of text streams. For development automation tasks like managing deployments, processing JSON APIs, manipulating files, and orchestrating CI/CD pipelines, PowerShell's object pipeline is genuinely better than bash for certain classes of problems. This guide covers practical PowerShell patterns for Node.js developers who need to automate beyond what bash handles cleanly.
Prerequisites
- PowerShell 7+ installed (cross-platform)
- Basic command-line experience
- Familiarity with at least one scripting language
- Node.js development environment
Installing PowerShell 7
PowerShell 7 (pwsh) is separate from Windows PowerShell 5.1 (powershell.exe). Install it on any platform:
# macOS
brew install powershell
# Ubuntu/Debian
sudo apt-get install -y powershell
# Windows (via winget)
winget install Microsoft.PowerShell
# Docker
docker run -it mcr.microsoft.com/powershell
# Verify
pwsh --version
# PowerShell 7.4.1
PowerShell Fundamentals for Developers
Everything is an Object
This is PowerShell's key difference from bash. Commands return objects, not text.
# Get process information - returns objects with properties
Get-Process node | Select-Object Id, ProcessName, CPU, WorkingSet64
# Id ProcessName CPU WorkingSet64
# -- ----------- --- --------------
# 1234 node 12.5 157286400
# 5678 node 3.2 89456640
# Filter objects by properties
Get-Process | Where-Object { $_.CPU -gt 10 } | Sort-Object CPU -Descending
In bash, you would parse ps aux output with awk and grep. In PowerShell, you filter and sort typed objects.
Variables and Types
# Variables use $ prefix
$projectName = "my-app"
$port = 3000
$features = @("auth", "database", "docker")
$config = @{
host = "localhost"
port = 5432
database = "myapp"
}
# String interpolation (double quotes only)
Write-Host "Deploying $projectName on port $port"
Write-Host "Database: $($config.host):$($config.port)"
# Single quotes are literal
Write-Host 'No interpolation: $projectName'
The Pipeline
PowerShell pipes objects, not text:
# Get large files, sort by size, format as table
Get-ChildItem -Recurse -File |
Where-Object { $_.Length -gt 1MB } |
Sort-Object Length -Descending |
Select-Object Name, @{N="SizeMB"; E={[math]::Round($_.Length/1MB, 2)}}, Directory |
Format-Table -AutoSize
# Name SizeMB Directory
# ---- ------ ---------
# package-lock.json 2.34 C:\project
# bundle.js 1.87 C:\project\dist
Functions
function Deploy-Application {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Version,
[Parameter()]
[string]$Environment = "staging",
[Parameter()]
[switch]$Force
)
Write-Host "Deploying v$Version to $Environment"
if (-not $Force) {
$confirm = Read-Host "Continue? (y/n)"
if ($confirm -ne 'y') {
Write-Host "Aborted."
return
}
}
# Deployment logic here
docker build -t "myapp:$Version" .
docker tag "myapp:$Version" "registry.example.com/myapp:$Version"
docker push "registry.example.com/myapp:$Version"
Write-Host "Deployment complete: v$Version -> $Environment"
}
# Usage
Deploy-Application -Version "1.2.3" -Environment "production" -Force
Error Handling
# Try/Catch/Finally
function Invoke-DatabaseMigration {
param([string]$ConnectionString)
try {
Write-Host "Running migrations..."
$result = node ./scripts/migrate.js 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Migration failed with exit code $LASTEXITCODE`n$result"
}
Write-Host "Migration successful"
}
catch {
Write-Error "Migration error: $_"
# Rollback logic here
throw
}
finally {
Write-Host "Cleaning up temporary files..."
Remove-Item -Path "./tmp/migration-*" -ErrorAction SilentlyContinue
}
}
# ErrorAction parameter controls error behavior per-command
Get-Content "nonexistent.txt" -ErrorAction Stop # Throws terminating error
Get-Content "nonexistent.txt" -ErrorAction SilentlyContinue # Suppresses error
Get-Content "nonexistent.txt" -ErrorAction Continue # Shows error, continues
Working with JSON
PowerShell handles JSON natively — no jq needed.
# Read package.json
$pkg = Get-Content "package.json" | ConvertFrom-Json
Write-Host "Name: $($pkg.name)"
Write-Host "Version: $($pkg.version)"
Write-Host "Dependencies: $($pkg.dependencies.PSObject.Properties.Count)"
# List all dependencies
$pkg.dependencies.PSObject.Properties | ForEach-Object {
Write-Host " $($_.Name): $($_.Value)"
}
# Modify and save
$pkg.version = "2.0.0"
$pkg | ConvertTo-Json -Depth 10 | Set-Content "package.json"
# API calls with JSON
$response = Invoke-RestMethod -Uri "https://api.github.com/repos/nodejs/node/releases/latest"
Write-Host "Latest Node.js: $($response.tag_name)"
Write-Host "Published: $($response.published_at)"
Processing API Responses
# Fetch and process npm registry data
function Get-PackageInfo {
param([string]$PackageName)
$data = Invoke-RestMethod -Uri "https://registry.npmjs.org/$PackageName"
[PSCustomObject]@{
Name = $data.name
Version = $data.'dist-tags'.latest
Description = $data.description
License = $data.license
Downloads = (Invoke-RestMethod "https://api.npmjs.org/downloads/point/last-week/$PackageName").downloads
}
}
# Compare package sizes
@("express", "fastify", "koa") | ForEach-Object {
Get-PackageInfo -PackageName $_
} | Format-Table -AutoSize
# Name Version Description License Downloads
# ---- ------- ----------- ------- ---------
# express 4.18.2 Fast, unopinionated web framework MIT 32456789
# fastify 4.26.0 Fast and low overhead web framework MIT 4567890
# koa 2.15.0 Expressive HTTP middleware MIT 1234567
File and Directory Operations
# Find files by pattern
Get-ChildItem -Recurse -Include "*.js" -Exclude "node_modules" |
Select-Object FullName, Length, LastWriteTime
# Count lines of code
Get-ChildItem -Recurse -Include "*.js" -Exclude "*node_modules*", "*dist*" |
ForEach-Object { (Get-Content $_.FullName).Count } |
Measure-Object -Sum |
Select-Object -ExpandProperty Sum
# Find and replace across files
Get-ChildItem -Recurse -Include "*.js" -Exclude "*node_modules*" | ForEach-Object {
$content = Get-Content $_.FullName -Raw
if ($content -match "oldFunctionName") {
$content -replace "oldFunctionName", "newFunctionName" |
Set-Content $_.FullName
Write-Host "Updated: $($_.FullName)"
}
}
# Copy with filtering
$excludes = @("node_modules", ".git", "dist", "coverage")
Get-ChildItem -Recurse |
Where-Object { $dir = $_.FullName; -not ($excludes | Where-Object { $dir -match $_ }) } |
Copy-Item -Destination "./backup/" -Force
Development Workflow Scripts
Project Setup
# setup-project.ps1
function Initialize-DevEnvironment {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ProjectName,
[Parameter()]
[ValidateSet("postgresql", "mysql", "mongodb", "none")]
[string]$Database = "postgresql"
)
$projectDir = Join-Path (Get-Location) $ProjectName
if (Test-Path $projectDir) {
Write-Error "Directory already exists: $projectDir"
return
}
Write-Host "Creating project: $ProjectName" -ForegroundColor Cyan
# Create structure
$dirs = @("src", "src/routes", "src/models", "test", "scripts", "docker")
foreach ($dir in $dirs) {
New-Item -ItemType Directory -Path (Join-Path $projectDir $dir) -Force | Out-Null
Write-Host " Created: $dir/" -ForegroundColor Gray
}
# Initialize npm
Push-Location $projectDir
try {
npm init -y | Out-Null
npm install express | Out-Null
npm install --save-dev nodemon jest | Out-Null
if ($Database -eq "postgresql") {
npm install pg | Out-Null
}
elseif ($Database -eq "mongodb") {
npm install mongodb | Out-Null
}
Write-Host "Dependencies installed" -ForegroundColor Green
}
finally {
Pop-Location
}
# Create .env
$envContent = @"
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://devuser:devpass@localhost:5432/${ProjectName}_dev
"@
Set-Content -Path (Join-Path $projectDir ".env") -Value $envContent
# Create .gitignore
$gitignore = @"
node_modules/
.env
dist/
coverage/
*.log
"@
Set-Content -Path (Join-Path $projectDir ".gitignore") -Value $gitignore
Write-Host "`nProject created: $ProjectName" -ForegroundColor Green
Write-Host " cd $ProjectName && npm run dev" -ForegroundColor Gray
}
Docker Management
# docker-helpers.ps1
function Start-DevStack {
param([string]$ComposeFile = "docker-compose.yml")
Write-Host "Starting development stack..." -ForegroundColor Cyan
docker compose -f $ComposeFile up -d --build
# Wait for services
$services = @(
@{Name="PostgreSQL"; Host="localhost"; Port=5432},
@{Name="Redis"; Host="localhost"; Port=6379},
@{Name="API"; Host="localhost"; Port=3000}
)
foreach ($svc in $services) {
Write-Host " Waiting for $($svc.Name)..." -NoNewline
$ready = $false
for ($i = 0; $i -lt 30; $i++) {
try {
$tcp = New-Object System.Net.Sockets.TcpClient
$tcp.Connect($svc.Host, $svc.Port)
$tcp.Close()
$ready = $true
break
}
catch {
Start-Sleep -Seconds 1
}
}
if ($ready) {
Write-Host " Ready" -ForegroundColor Green
}
else {
Write-Host " TIMEOUT" -ForegroundColor Red
}
}
}
function Stop-DevStack {
param(
[string]$ComposeFile = "docker-compose.yml",
[switch]$RemoveVolumes
)
$args = @("-f", $ComposeFile, "down")
if ($RemoveVolumes) { $args += "-v" }
docker compose @args
Write-Host "Development stack stopped" -ForegroundColor Yellow
}
function Get-ContainerLogs {
param(
[Parameter(Mandatory)]
[string]$Service,
[int]$Lines = 50,
[switch]$Follow
)
$args = @("compose", "logs", "--tail", $Lines, $Service)
if ($Follow) { $args += "-f" }
& docker @args
}
Build and Deploy
# deploy.ps1
function Publish-Application {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Version,
[Parameter(Mandatory)]
[ValidateSet("staging", "production")]
[string]$Environment,
[Parameter()]
[string]$Registry = "ghcr.io/myorg/myapp"
)
$ErrorActionPreference = "Stop"
$imageTag = "${Registry}:${Version}"
# Validate version format
if ($Version -notmatch '^\d+\.\d+\.\d+$') {
throw "Version must be semver format (e.g., 1.2.3)"
}
Write-Host "`nDeployment Plan:" -ForegroundColor Cyan
Write-Host " Version: $Version"
Write-Host " Environment: $Environment"
Write-Host " Image: $imageTag"
if ($Environment -eq "production") {
Write-Host "`nWARNING: Deploying to PRODUCTION" -ForegroundColor Red
$confirm = Read-Host "Type 'yes' to confirm"
if ($confirm -ne "yes") {
Write-Host "Aborted." -ForegroundColor Yellow
return
}
}
# Run tests
Write-Host "`nRunning tests..." -ForegroundColor Cyan
npm test
if ($LASTEXITCODE -ne 0) {
throw "Tests failed. Aborting deployment."
}
# Build image
Write-Host "Building image..." -ForegroundColor Cyan
docker build -t $imageTag --target production .
if ($LASTEXITCODE -ne 0) {
throw "Docker build failed"
}
# Push to registry
Write-Host "Pushing to registry..." -ForegroundColor Cyan
docker push $imageTag
if ($LASTEXITCODE -ne 0) {
throw "Docker push failed"
}
# Deploy
Write-Host "Deploying to $Environment..." -ForegroundColor Cyan
$deployResult = Invoke-RestMethod -Method Post `
-Uri "https://deploy.example.com/api/deploy" `
-Headers @{ Authorization = "Bearer $env:DEPLOY_TOKEN" } `
-ContentType "application/json" `
-Body (@{
image = $imageTag
environment = $Environment
version = $Version
} | ConvertTo-Json)
Write-Host "`nDeployment complete!" -ForegroundColor Green
Write-Host " Deploy ID: $($deployResult.id)"
Write-Host " Status: $($deployResult.status)"
}
CI/CD Integration
Azure Pipelines PowerShell Tasks
# azure-pipelines.yml
steps:
- task: PowerShell@2
displayName: 'Run deployment checks'
inputs:
targetType: 'inline'
script: |
$pkg = Get-Content "package.json" | ConvertFrom-Json
Write-Host "##vso[build.updatebuildnumber]$($pkg.version)"
# Validate no TODO comments in source
$todos = Get-ChildItem -Recurse -Include "*.js" -Exclude "*node_modules*" |
Select-String -Pattern "TODO|FIXME|HACK" |
Measure-Object |
Select-Object -ExpandProperty Count
if ($todos -gt 0) {
Write-Warning "Found $todos TODO/FIXME/HACK comments"
}
pwsh: true # Use PowerShell 7 (cross-platform)
GitHub Actions
- name: Process deployment
shell: pwsh
run: |
$version = (Get-Content "package.json" | ConvertFrom-Json).version
Write-Host "Deploying version: $version"
# Build matrix of services
$services = @("api", "worker", "scheduler")
foreach ($svc in $services) {
Write-Host "Building $svc..."
docker build -t "myapp-${svc}:${version}" -f "docker/${svc}.Dockerfile" .
}
Cross-Platform Considerations
# Path handling (works on all platforms)
$configPath = Join-Path $PSScriptRoot "config" "settings.json"
# Not: "$PSScriptRoot\config\settings.json" (Windows-only)
# Line endings
$content = Get-Content "file.txt" -Raw
$content = $content -replace "`r`n", "`n" # Normalize to LF
# Environment variables
$env:NODE_ENV = "production" # Works everywhere
# Running external commands
if ($IsWindows) {
& npm.cmd install
}
else {
& npm install
}
# Or use the cross-platform approach
$npm = Get-Command npm -ErrorAction SilentlyContinue
if ($npm) {
& $npm.Source install
}
Complete Working Example
A comprehensive development automation module:
# DevTools.psm1 - Development workflow automation module
function Test-Prerequisites {
$required = @("node", "npm", "docker", "git")
$missing = @()
foreach ($cmd in $required) {
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) {
$missing += $cmd
}
}
if ($missing.Count -gt 0) {
Write-Error "Missing required tools: $($missing -join ', ')"
return $false
}
# Check Node.js version
$nodeVersion = (node --version) -replace 'v', ''
$major = [int]($nodeVersion.Split('.')[0])
if ($major -lt 18) {
Write-Error "Node.js 18+ required (current: $nodeVersion)"
return $false
}
Write-Host "All prerequisites met" -ForegroundColor Green
return $true
}
function Get-ProjectInfo {
$pkg = Get-Content "package.json" -ErrorAction Stop | ConvertFrom-Json
[PSCustomObject]@{
Name = $pkg.name
Version = $pkg.version
Dependencies = $pkg.dependencies.PSObject.Properties.Count
DevDeps = $pkg.devDependencies.PSObject.Properties.Count
Scripts = ($pkg.scripts.PSObject.Properties | ForEach-Object { $_.Name }) -join ", "
NodeModules = if (Test-Path "node_modules") {
"{0:N0} packages" -f (Get-ChildItem "node_modules" -Directory).Count
} else { "not installed" }
GitBranch = git rev-parse --abbrev-ref HEAD 2>$null
GitStatus = if ((git status --porcelain 2>$null).Count -gt 0) { "dirty" } else { "clean" }
}
}
function Invoke-ProjectHealth {
Write-Host "`nProject Health Check" -ForegroundColor Cyan
Write-Host ("=" * 40)
$info = Get-ProjectInfo
$info | Format-List
# Check for outdated dependencies
Write-Host "Checking for outdated packages..." -ForegroundColor Gray
$outdated = npm outdated --json 2>$null | ConvertFrom-Json -ErrorAction SilentlyContinue
if ($outdated) {
$outdatedCount = ($outdated.PSObject.Properties).Count
if ($outdatedCount -gt 0) {
Write-Host "$outdatedCount outdated packages:" -ForegroundColor Yellow
$outdated.PSObject.Properties | ForEach-Object {
$pkg = $_.Value
Write-Host " $($_.Name): $($pkg.current) -> $($pkg.latest)" -ForegroundColor Gray
}
}
}
else {
Write-Host "All packages up to date" -ForegroundColor Green
}
# Check for security vulnerabilities
Write-Host "`nChecking for vulnerabilities..." -ForegroundColor Gray
$audit = npm audit --json 2>$null | ConvertFrom-Json -ErrorAction SilentlyContinue
if ($audit -and $audit.metadata) {
$vulns = $audit.metadata.vulnerabilities
$total = $vulns.low + $vulns.moderate + $vulns.high + $vulns.critical
if ($total -gt 0) {
Write-Host "Found $total vulnerabilities:" -ForegroundColor Red
Write-Host " Critical: $($vulns.critical) High: $($vulns.high) Moderate: $($vulns.moderate) Low: $($vulns.low)"
}
else {
Write-Host "No vulnerabilities found" -ForegroundColor Green
}
}
}
Export-ModuleMember -Function @(
'Test-Prerequisites',
'Get-ProjectInfo',
'Invoke-ProjectHealth'
)
# Usage
Import-Module ./DevTools.psm1
Test-Prerequisites
Get-ProjectInfo | Format-List
Invoke-ProjectHealth
Common Issues and Troubleshooting
1. Execution Policy Blocks Scripts
File cannot be loaded because running scripts is disabled on this system.
# Check current policy
Get-ExecutionPolicy
# Allow local scripts (recommended)
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
# Or bypass for a single script
pwsh -ExecutionPolicy Bypass -File script.ps1
2. External Command Error Handling
# PowerShell doesn't throw on non-zero exit codes by default
npm test # Returns exit code 1
# Script continues! PowerShell ignores $LASTEXITCODE
# Fix: check $LASTEXITCODE explicitly
npm test
if ($LASTEXITCODE -ne 0) {
throw "Tests failed with exit code $LASTEXITCODE"
}
# Or set $ErrorActionPreference and use $PSNativeCommandUseErrorActionPreference (PS 7.3+)
$PSNativeCommandUseErrorActionPreference = $true
$ErrorActionPreference = "Stop"
npm test # Now throws on non-zero exit code
3. JSON Depth Truncation
# Default depth is 2 - nested objects become strings
$deep | ConvertTo-Json
# "dependencies": "@{express=^4.18.0; pg=^8.11.0}"
# Fix: specify depth
$deep | ConvertTo-Json -Depth 10
4. Path Separator Issues
# Don't hardcode backslashes
$bad = "$root\src\app.js" # Fails on macOS/Linux
# Use Join-Path
$good = Join-Path $root "src" "app.js" # Works everywhere
# Or use forward slashes (works everywhere including Windows)
$also_good = "$root/src/app.js"
Best Practices
- Use PowerShell 7 (
pwsh) for cross-platform scripts. Windows PowerShell 5.1 is Windows-only and missing modern features. - Leverage the object pipeline. Stop parsing text with regex when PowerShell gives you typed objects with properties.
- Use
ConvertFrom-JsonandConvertTo-Jsonfor all JSON work. No need for external tools likejq. - Check
$LASTEXITCODEafter external commands. PowerShell does not throw on non-zero exit codes by default. - Use
Join-Pathfor file paths. Never hardcode backslashes or forward slashes. - Write functions with
[CmdletBinding()]and typed parameters. You get free argument validation, help text, and-Verbose/-Debugsupport. - Use
$ErrorActionPreference = "Stop"for scripts. This makes PowerShell fail on errors instead of silently continuing. - Prefer
Invoke-RestMethodovercurl. It returns parsed objects directly instead of raw text.