Npm lifecycle scripts are great:
- it exposes all the keys in a
package.json
as environment variables to child processes - by default running an entrypoint will also attempt to run a
pre*
and apost*
commands. - Using
onchange
,npm-run-all
,npx
,cross-var
,cross-env
can help automate many typical project tasks. - the keys can actually contain mostly whatever characters you want, below you see I use it to mark out groups of commands.
Here's an example of what I've used in the past:
"dev": "npm run docker:run -- tool npm run container:nuxt:dev",
"build": "npm run docker:run -- tool npm run container:nuxt:build",
"start": "npm run docker:run -- tool npm run container:nuxt:start",
"generate": "npm run docker:run -- tool npm run container:nuxt:generate",
"yarn:rebuild": "npm run docker:run -- yarn",
"// [ci] commands": "where docker volume mounting is not possible",
"ci:generate": "npm run docker:run -- build npm run container:nuxt:generate",
"// [test] commands": "",
"test:unit": "echo 0",
"test:integration": "echo 0",
"// [container] commands": "",
"container:nuxt:dev": "nuxt --spa --config-file ./project/nuxt.config.js",
"container:nuxt:build": "nuxt build --universal --config-file ./project/nuxt.config.js",
"container:nuxt:generate": "nuxt generate --spa --config-file ./project/nuxt.config.js",
"container:nuxt:start": "nuxt start --spa --config-file ./project/nuxt.config.js",
"// [workstation] Release Commands": "",
"git:checkoutStatic": "git checkout ./docs",
"git:cleanStatic": "git clean -f ./docs",
"git:pull": "git pull",
"git:check": "git diff --check && git diff --exit-code && git diff --cached --exit-code",
"git:publish": "npx npm-run-all --serial git:add git:commit-amend git:push-origin",
"git:add": "git add .",
"git:commit-amend": "git commit --amend --no-edit",
"git:push-origin": "git push --follow-tags",
"prerelease": "npx npm-run-all --serial git:checkoutStatic git:cleanStatic git:pull",
"release": "npx standard-version",
"postrelease": "npx npm-run-all --serial generate git:publish",
"// [workstation] Docker Commands": "",
"docker:run": "docker-compose run --service-ports --rm",
"docker:shell": "npm run docker:run -- build /bin/sh",
"docker:exec": "npm run docker:run -- build",
"docker:build": "npm run docker:run -- make docker-make --no-push",
"docker:push": "npm run docker:run -- make docker-make",
"predocker:release": "npm run git:pull",
"docker:release": "npx standard-version",
"postdocker:release": "npm run git:publish",
"// [container] Release Commands": "",
"container:release": "npx run-s release:build-frontend release:publish-styleguide",
"container:release:changelog": "conventional-changelog --infile=./project/CHANGELOG.md"
That's a lot there and it covers the majority of my work flow, so lets go over the groups of commands below.
$ npm run dev
$ npm run build
$ npm run start
$ npm run generate
Usually we're just compiling the source code in development mode or in production mode, so here these lines are relevant:
"dev": "npm run docker:run -- tool npm run container:nuxt:dev",
"build": "npm run docker:run -- tool npm run container:nuxt:build",
"start": "npm run docker:run -- tool npm run container:nuxt:start",
"generate": "npm run docker:run -- tool npm run container:nuxt:generate",
dev
will first call docker:run
supplying arguments tool npm run container:nuxt:dev
.
These are really just fed into docker-compose
. So we're running the tool
service defined in docker-compose.yml
and then telling docker-compose to run npm run container:nuxt:dev
from within the container. Any commands meant to be run within a docker container are labeled as such.
So the chain is :
npm run dev
npm run docker:run
docker-compose run ... tool npm run container:nuxt:dev
nuxt --spa --config-file ./project/nuxt-config.js
One thing you'll notice is that I point at the configuration file in the project
directory. This doesn't exist here, but the docker-compose.yml
describes volumes that mount the project directory to that path. I do this because of the nature of file vs directory docker volumes. File volumes don't reliably get updated back to the host, especially if it gets removed in the container and rebuilt. So mounting a folder at a place and operating on files within is more reliable and flexible.
$ npm run yarn:rebuild
To reliably build the docker image with the right node modules, we need to maintain an offline cache of them. This command just launches the yarn
docker-compose service, which is hard coded to yarn install the modules in order to obtain an offline copy of the modules. This technique only retains the offline cache, everything else is thrown away, because creating the container later is what we keep.
$ npm run release
This whole chain starts off with standard version, a much better alternative to just npm version patch|minor|major
.
The idea with this chain, is that you need to automate all the "git paperwork" when making a release.
- conflicts need to be checked for,
- dist output needs to be reset to remote state avoid conflicts before pulling.
- version in
package.json
needs to be made. - a git tag needs to be made.
- changelog needs to be generated.
- a build needs to be made.
- you might also want to run tests here.
- new files need to be commited.
- and finally new commits pushed.
So running $ npm run release
triggers the following chain:
prerelease
is triggered firstgit:check
👈 this one is a nice trickgit:checkoutStatic
git:cleanStatic
git:pull
- then
release
is run - then
postrelease
is triggered.generate
docker:run
container:nuxt:generate
git:publish
git:add
git:commit-amend
git:push-origin
Something I want to improve here is that very early on bail if we're running this on the incorrect git branch:
...
"git:only-master": "npx git-branch-require master",
...
git-require-branch
doesn't exist (yet), so if you make it... it needs to take a branch name amd return an exit code of 0 or 1 to indicate success or failure. We wan't this because the npm run process will stop the whole chain when it receives an exit code of 1.
I've learn through much pain and anguish some key lessons:
- Focus on your strengths: don't run your own email server.
- Backups: always make your dependencies available offline.
- Sandbox Environment: ensure it's the same every time it's used.
I encountered that last point early back when I started python development, where Ubuntu utilised python libraries for it's operating system which also meant they were available when I ran my own python programs. This lead to unreliable code because I couldn't control the versions of those libraries, and when I tried installing the ones I needed it overwrote the system versions. Then some people came up with Virtualenv, which just like npm and node_modules made python packages installed and resolved locally.
But when I started nodejs development, my colleagues used a mixture of windows, macosx and me on linux. As you can imagine just getting nodejs installed was a error prone experience.
After some time I explored using Docker to containerise our frontend development environment, so that now instead of worrying about what version of node or node_modules we needed (and all the other requirements that randomly pop up), we said "just install docker and any version of node that has npx (8+)".
[!NOTE] Retrospective 2024
I've moved on from using
package.json#scripts
. You should definitely do the same. Something like a Justfile is perfect.