Cli Tools

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-Json and ConvertTo-Json for all JSON work. No need for external tools like jq.
  • Check $LASTEXITCODE after external commands. PowerShell does not throw on non-zero exit codes by default.
  • Use Join-Path for file paths. Never hardcode backslashes or forward slashes.
  • Write functions with [CmdletBinding()] and typed parameters. You get free argument validation, help text, and -Verbose/-Debug support.
  • Use $ErrorActionPreference = "Stop" for scripts. This makes PowerShell fail on errors instead of silently continuing.
  • Prefer Invoke-RestMethod over curl. It returns parsed objects directly instead of raw text.

References

Powered by Contentful