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.
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!).
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!
- Why is it trying to get the
FullName
property of thepnpm
binary? - The
Get-ChildItem
is supposed to be only searching under./src/posts
for*.md
files, so how it it even ending up at that path?
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.