Cross-platform PowerShell commands in npm scripts

Using inline PowerShell scripts from package.json scripts, and how to solve the problem of PowerShell variables not working as expected.

Blogging

PowerShell

I’m a big fan of Bob the Builder’s catchphrase “Use the right tool for the job”. Not that he first came up with it, but his song is quite catchy😃

A tool I often reach for when needing to script functionality is PowerShell. What I really like is that often while you can achieve similar results with Bash stringing together various Unix tools, the PowerShell equivalent almost always much more readable.

And that really matters when someone is trying to understand what a script is doing (and that someone is usually me a few days or weeks later!).

PowerShell logo

My blog uses Markdown (.md) files for posts, and I make use of the VZ File Templates Visual Studio Code extension, with a template file to start new posts.

The template contains a front-matter section that I then customise for the post:

title: Blog post title
date: '$year$-$monthPadded$-$dayPadded$T$hoursPadded$:$minutesPadded$:00.000+09:30'
#image: /assets
#imageAlt: image description
# description: summary of post
tags:
- A Tag

I wanted to add some simple validation to my blog to check that the front matter in post Markdown files was valid - where “valid” means that I had not forgotten to update those placeholder values appropriately.

I came up with the following PowerShell to do this:

if (Get-ChildItem -Path ./src/posts -Filter '*.md' -Recurse | Where-Object { 
    (Get-Content $_.FullName -Raw) -match '^(description: summary of post|image: /assets|imageAlt: image description title: Blog post title)$' 
    }
) { 
    Write-Host 'Front matter has not been updated'; exit 1 
} else { 
    Write-Host 'All front matter is properly updated' 
}

So if the description, image, imageAlt or title lines aren’t updated it will fail.

I could just add this to a build pipeline, but I like the idea of being able to run the same logic locally, as well as from a pipeline. I could have put it into a .ps1 script, but given the script wasn’t that long, and the project in question is already using Node and has a package.json file, using a npm (or pnpm) script seemed reasonable.

I added the following to package.json:

  "scripts": {
    "validate-frontmatter": "pwsh -noprofile -Command \"if (Get-ChildItem -Path ./src/posts -Filter '*.md' -Recurse | Where-Object { (Get-Content $_.FullName -Raw) -match '^(description: summary of post|image: /assets|imageAlt: image description|title: Blog post title)$' }) { Write-Host 'Front matter has not been updated'; exit 1 } else { Write-Host 'All front matter is properly updated' }\"",
  },

and confirmed it worked locally by running pnpm validate-frontmatter, and it also ran when called in the build pipeline.

It was a while later that I bothered to review the details of the build step and to my surprise there were a whole bunch of errors being logged, though they didn’t cause the step to fail:

 Get-Content: Cannot find path '/home/runner/setup-pnpm/node_modules/.bin/pnpm.FullName' because it does not exist.

That is really strange!

The only thing I could think of was maybe it related to the actual “Get-Content $_.FullName” part?

$_ in PowerShell is supposed to be the current item when you’re iterating over items from a pipeline. The pipeline is running on an Ubuntu build agent with Bash as the default shell. So maybe, just maybe is there a chance that the $_ is being interpreted by Bash before it gets seen by PowerShell?

My first attempt was to escape the $ with a backslash, but VS Code threw up a bunch of squigglies warning me that was invalid JSON. What about two backslashes? That seems valid:

Get-Content -Raw \\$_.FullName

BINGO! And that worked in the pipeline!

But turns out it doesn’t now when run in Windows.

So how can we make it compatible with both Bash/Ubuntu and Windows? I don’t think we can in the one line. The option I went with was to use a helper package run-script-os. Our scripts now become the following:

    "validate-frontmatter": "run-script-os",
    "validate-frontmatter:default": "pwsh -noprofile -Command \"if (Get-ChildItem -Path ./src/posts -Filter '*.md' -Recurse | Where-Object { (Get-Content -Raw \\$_.FullName) -match '^(description: summary of post|image: /assets|imageAlt: image description|title: Blog post title)$' }) { Write-Host 'Front matter has not been updated'; exit 1 } else { Write-Host 'All front matter is properly updated' }\"",
    "validate-frontmatter:windows": "pwsh -noprofile -Command \"if (Get-ChildItem -Path ./src/posts -Filter '*.md' -Recurse | Where-Object { (Get-Content $_.FullName -Raw) -match '^(description: summary of post|image: /assets|imageAlt: image description|title: Blog post title)$' }) { Write-Host 'Front matter has not been updated'; exit 1 } else { Write-Host 'All front matter is properly updated' }\"",

validate-frontmatter:windows is now the original version that works on Windows. validate-frontmatter:default is the updated version that should work on non-Windows platforms. validate-frontmatter now just calls run-script-os which figures out which other script to defer to depending on the current platform.

You can see the final version in my astro-blog-engine GitHub repository

Alternate solution

The alternative would be to not use inline PowerShell but move the logic to a separate .ps1 script file. That way the package.json script line doesn’t contain any $_ variables that are at risk of being interpreted by the wrong shell. If the script logic was any longer or complex then that would be a good solution.