Running cross-platform tasks via npm package scripts

[2022-08-31] dev, javascript, nodejs
(Ad, please don’t block)
Warning: This blog post is outdated. Instead, read chapter “Running cross-platform tasks via npm package scripts” in “Shell scripting with Node.js”.

The npm package manager lets us define small shell scripts for tasks and execute them via npm run. In this blog post, we explore how that works and how we can write them in a way that works across platforms (Unixes and Windows).

npm package scripts  

npm package scripts are defined via property "scripts" of package.json:

{
  ···
  "scripts": {
    "tsc": "tsc",
    "tscwatch": "tsc --watch",
    "tscclean": "shx rm -rf ./dist/*"
  },
  ···
}

The value of "scripts" is an object where each property defines a package script:

  • The property key defines the name of the script.
  • The property value defines what to do when the script is run.

If we type:

npm run <script-name>

then npm executes the script whose name is script-name in a shell. For example, we can use:

npm run tscwatch

to run the following command in a shell:

tsc --watch

In this post, we will occasionally use the npm option -s, which is an abbreviation for --silent and tells npm run to produce less output:

npm -s run <script-name>

This option is covered in more detail in the section on logging.

Shorter npm commands for running package scripts  

Some package scripts can be run via shorter npm commands:

Commands Equivalent
npm test, npm t npm run test
npm start npm run start
npm stop npm run stop
npm restart npm run restart
  • npm start: If there is no package script "start", npm runs node server.js.
  • npm restart: If there is no package script "restart", npm runs "prerestart", "stop", "start", "postrestart".

Which shell is used to run package scripts?  

By default, npm runs package scripts via cmd.exe on Windows and via /bin/sh on Unix. We can change that via the npm configuration setting script-shell.

However, doing so is rarely a good idea: Many existing cross-platform scripts are written for sh and cmd.exe and will stop working.

Preventing package scripts from being run automatically  

Some script names are reserved for life cycle scripts which npm runs whenever we execute certain npm commands.

For example, npm runs the script "postinstall" whenever we execute npm install (without arguments). Life cycle scripts are covered in more detail later.

If the configuration setting ignore-scripts is true, npm will never run scripts automatically, only if we invoke them directly.

Getting tab completion for package scripts on Unix  

On Unix, npm supports tab completion for commands and package script names via npm completion. We can install it by adding this line to our .profile / .zprofile / .bash_profile / etc.:

. <(npm completion)

If you need tab completion for non-Unix platforms, do a web search such as “npm tab completion PowerShell”.

Listing and organizing package scripts  

npm run without a name lists the available scripts. If the following scripts exist:

"scripts": {
  "tsc": "tsc",
  "tscwatch": "tsc --watch",
  "serve": "serve ./site/"
}

Then they are listed like this:

% npm run
Scripts available via `npm run-script`:
  tsc
    tsc
  tscwatch
    tsc --watch
  serve
    serve ./site/

Adding separators  

If there are many package scripts, we can misuse script names as separators (script "help" will be explained in the next subsection):

  "scripts": {
    "help": "scripts-help -w 40",
    "\n========== Building ==========": "",
    "tsc": "tsc",
    "tscwatch": "tsc --watch",
    "\n========== Serving ==========": "",
    "serve": "serve ./site/"
  },

Now the scripts are listed as follows:

% npm run
Scripts available via `npm run-script`:
  help
    scripts-help -w 40

========== Building ==========

  tsc
    tsc
  tscwatch
    tsc --watch
  
========== Serving ==========

  serve
    serve ./site/

Note that the trick of prepending newlines (\n) works on Unix and on Windows.

Printing help information  

The package script "help" prints help information via the bin script scripts-help from package @rauschma/scripts-help. We provide descriptions via the package.json property "scripts-help" (the value of "tscwatch" is abbreviated so that it fits into a single line):

"scripts-help": {
  "tsc": "Compile the TypeScript to JavaScript.",
  "tscwatch": "Watch the TypeScript source code [...]",
  "serve": "Serve the generated website via a local server."
}

This is what the help information looks like:

% npm -s run help
Package “demo”

╔══════╤══════════════════════════╗
║ help │ scripts-help -w 40       ║
╚══════╧══════════════════════════╝

Building

╔══════════╤══════════════════════════════════════════╗
║ tsc      │ Compile the TypeScript to JavaScript.    ║
╟──────────┼──────────────────────────────────────────╢
║ tscwatch │ Watch the TypeScript source code and     ║
║          │ compile it incrementally when and if     ║
║          │ there are changes.                       ║
╚══════════╧══════════════════════════════════════════╝

Serving

╔═══════╤══════════════════════════════════════════╗
║ serve │ Serve the generated website via a local  ║
║       │ server.                                  ║
╚═══════╧══════════════════════════════════════════╝

Kinds of package scripts  

If certain names are used for scripts, they are run automatically in some situations:

  • Pre scripts and post scripts are run before and after scripts.
  • Life cycle scripts are run when a user performs an action such as npm install.

All other scripts are called directly-run scripts.

Pre and post scripts  

Whenever npm runs a package script PS, it automatically runs the following scripts – if they exist:

  • prePS beforehand (a pre script)
  • postPS afterward (a post script)

The following scripts contain the pre script prehello and the post script posthello:

"scripts": {
  "hello": "echo hello",
  "prehello": "echo BEFORE",
  "posthello": "echo AFTER"
},

This is what happens if we run hello:

% npm -s run hello
BEFORE
hello
AFTER

Life cycle scripts  

npm runs life cycle scripts during npm commands such as:

  • npm publish (which uploads packages to the npm registry)
  • npm pack (which creates archives for registry packages, package directories, etc.)
  • npm install (which is used without arguments to install dependencies for packages that were downloaded from sources other than the npm registry)

If any of the life cycle scripts fail, the whole command stops immediately with an error.

What are use cases for life cycle scripts?

  • Compiling TypeScript: If a package contains TypeScript code, we normally compile it to JavaScript code before we use it. While the latter code is often not checked into version control, it has to be uploaded to the npm registry, so that the package can be used from JavaScript. A life cycle script lets us compile the TypeScript code before npm publish uploads the package. That ensures that in the npm registry, the JavaScript code is always in sync with our TypeScript code. It also ensures that our TypeScript code has no static type errors because compilation (and therefore publishing) stops when those are encountered.

  • Running tests: We can also use a life cycle script to run tests before publishing a package. If the tests fail, the package won’t be published.

These are the most important life cycle scripts (for detailed information on all life cycle scripts, see the npm documentation):

  • "prepare":
    • Runs before a package archive (a .tgz file) is created:
      • During npm publish
      • During npm pack
    • Runs when a package is installed from git or a local path.
    • Runs when npm install is used without arguments or when a package is installed globally.
  • "prepack" runs before a package archive (a .tgz file) is created:
    • During npm publish
    • During npm pack
  • "prepublishOnly" only runs during npm publish.
  • "install" runs when npm install is used without arguments or when a package is installed globally.
    • Note that we can also create a pre script "preinstall" and/or a post script "postinstall". Their names make it clearer when npm runs them.

The following table summarizes when these life cycle scripts are run:

prepublishOnly prepack prepare install
npm publish
npm pack
npm install
global install
install via git, path

Caveat: Doing things automatically is always a bit tricky. I usually follow these rules:

  • I automate for myself (e.g. via prepublishOnly).
  • I don’t automate for others (e.g. via postinstall).

The shell environment in which package scripts are run  

In this section, we’ll occasionally use

node -p <expr>

which runs the JavaScript code in expr and prints the result to the terminal - for example:

% node -p "'hello everyone!'.toUpperCase()" 
HELLO EVERYONE!

The current directory  

When a package script runs, the current directory is always the package directory, independently of where we are in the directory tree whose root it is. We can confirm that by adding the following script to package.json:

"cwd": "node -p \"process.cwd()\""

Let’s try out cwd on Unix:

% cd /Users/robin/new-package/src/util 
% npm -s run cwd
/Users/robin/new-package

Changing the current directory in this manner, helps with writing package scripts because we can use paths that are relative to the package directory.

The shell PATH  

When a module M imports from a module whose specifier starts with the name of a package P, Node.js goes through node_modules directories until it finds the directory of P:

  • First node_modules in the parent directory of M (if it exists)
  • Second node_modules in the parent of the parent directory of M (if it exists)
  • And so on, until it reaches the root of the file system.

That is, M inherits the node_modules directories of its ancestor directories.

A similar kind of inheritance happens with bin scripts, which are stored in node_modules/.bin when we install a package. npm run temporarily adds entries to the shell PATH variable ($PATH on Unix, %Path% on Windows):

  • node_modules/.bin in the package directory
  • node_modules/.bin in the package directory’s parent
  • Etc.

To see these additions, we can use the following package script:

"bin-dirs": "node -p \"JS\""

JS stands for a single line with this JavaScript code:

(process.env.PATH ?? process.env.Path)
.split(path.delimiter)
.filter(p => p.includes('.bin'))

On Unix, we get the following output if we run bin-dirs:

% npm -s run bin-dirs
[
  '/Users/robin/new-package/node_modules/.bin',
  '/Users/robin/node_modules/.bin',
  '/Users/node_modules/.bin',
  '/node_modules/.bin'
]

On Windows, we get:

>npm -s run bin-dirs
[
  'C:\\Users\\charlie\\new-package\\node_modules\\.bin',
  'C:\\Users\\charlie\\node_modules\\.bin',
  'C:\\Users\\node_modules\\.bin',
  'C:\\node_modules\\.bin'
]

Using environment variables in package scripts  

In task runners such as Make, Grunt, and Gulp, variables are important because they help reduce redundancy. Alas, while package scripts don’t have their own variables, we can work around that deficiency by using environment variables (which are also called shell variables).

We can use the following commands to list platform-specific environment variables:

  • Unix: env
  • Windows Command shell: SET
  • Both platforms: node -p process.env

On macOS, the result looks like this:

TERM_PROGRAM=Apple_Terminal
SHELL=/bin/zsh
TMPDIR=/var/folders/ph/sz0384m11vxf5byk12fzjms40000gn/T/
USER=robin
PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
PWD=/Users/robin/new-package
HOME=/Users/robin
LOGNAME=robin
···

In the Windows Command shell, the result looks like this:

Path=C:\Windows;C:\Users\charlie\AppData\Roaming\npm;···
PATHEXT=.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC
PROMPT=$P$G
TEMP=C:\Users\charlie\AppData\Local\Temp
TMP=C:\Users\charlie\AppData\Local\Temp
USERNAME=charlie
USERPROFILE=C:\Users\charlie
···

Additionally, npm temporarily adds more environment variables before it runs a package script. To see what the end result looks like, we can use the following command:

npm run env

This command invokes a built-in package script. Let’s try it out for this package.json:

{
  "name": "@my-scope/new-package",
  "version": "1.0.0",
  "bin": {
    "hello": "./hello.mjs"
  },
  "config": {
    "stringProp": "yes",
    "arrayProp": ["a", "b", "c"],
    "objectProp": {
      "one": 1,
      "two": 2
    }
  }
}

The names of all of npm’s temporary variables start with npm_. Let’s only print those, in alphabetical order:

npm run env | grep npm_ | sort

The npm_ variables have a hierarchical structure. Under npm_lifecycle_, we find the name and the definition of the currently running package script:

npm_lifecycle_event: 'env',
npm_lifecycle_script: 'env',

On Windows, npm_lifecycle_script would SET in this case.

Under prefix npm_config_, we can see some of npm’s configuration settings (which are described in the npm documentation). These are a few examples:

npm_config_cache: '/Users/robin/.npm',
npm_config_global_prefix: '/usr/local',
npm_config_globalconfig: '/usr/local/etc/npmrc',
npm_config_local_prefix: '/Users/robin/new-package',
npm_config_prefix: '/usr/local'
npm_config_user_agent: 'npm/8.15.0 node/v18.7.0 darwin arm64 workspaces/false',
npm_config_userconfig: '/Users/robin/.npmrc',

The prefix npm_package_ gives us access to the contents of package.json. Its top level looks like this:

npm_package_json: '/Users/robin/new-package/package.json',
npm_package_name: '@my-scope/new-package',
npm_package_version: '1.0.0',

Under npm_package_bin_, we can find the properties of the package.json property "bin":

npm_package_bin_hello: 'hello.mjs',

The npm_package_config_ entries give us access to the properties of "config":

npm_package_config_arrayProp: 'a\n\nb\n\nc',
npm_package_config_objectProp_one: '1',
npm_package_config_objectProp_two: '2',
npm_package_config_stringProp: 'yes',

That means that "config" lets us set up variables that we can use in package scripts. The next subsection explores that further.

Note the object was converted to “nested” entries (line 2 and line 3), while the Array (line 1) and the numbers (line 2 and line 3) were converted to strings.

These are the remaining npm_ environment variables:

npm_command: 'run-script',
npm_execpath: '/usr/local/lib/node_modules/npm/bin/npm-cli.js',
npm_node_execpath: '/usr/local/bin/node',

Getting and setting environment variables  

The following package.json demonstrates how we can access variables defined via "config" in package scripts:

{
  "scripts": {
    "hi:unix": "echo $​npm_package_config_hi",
    "hi:windows": "echo %​npm_package_config_hi%"
  },
  "config": {
    "hi": "HELLO"
  }
}

Alas, there is no built-in cross-platform way of accessing environment variables from package scripts.

There are, however, packages with bin scripts that can help us.

Package env-var lets us get environment variables:

"scripts": {
  "hi": "env-var echo {{npm_package_config_hi}}"
}

Package cross-env lets us set environment variables:

"scripts": {
  "build": "cross-env FIRST=one SECOND=two node ./build.mjs"
}

Setting up environment variables via .env files  

There are also packages that let us set up environment variables via .env files. These files have the following format:

# Comment
SECRET_HOST="https://example.com"
SECRET_KEY="123456789" # another comment

Using a file that is separate from package.json enables us to keep that data out of version control.

These are packages that support .env files:

  • Package dotenv supports them for JavaScript modules. We can preload it:

    node -r dotenv/config app.mjs
    

    And we can import it:

    import dotenv from 'dotenv';
    dotenv.config();
    console.log(process.env);
    
  • Package node-env-run lets us use .env files via a shell command:

    # Loads `.env` and runs an arbitrary shell script.
    # If there are CLI options, we need to use `--`.
    nodenv --exec node -- -p process.env.SECRET
    
    # Loads `.env` and uses `node` to run `script.mjs`.
    nodenv script.mjs
    
  • Package env-cmd is an alternative to the previous package:

    # Loads `.env` and runs an arbitrary shell script
    env-cmd node -p process.env.SECRET
    

    The package has more features: switching between sets of variables, more file formats, etc.

Arguments for package scripts  

Let’s explore how arguments are passed on to shell commands that we invoke via package scripts. We’ll use the following package.json:

{
  ···
  "scripts": {
    "args": "log-args"
  },
  "dependencies": {
    "log-args": "^1.0.0"
  }
}

The bin script log-args looks like this:

for (const [key,value] of Object.entries(process.env)) {
  if (key.startsWith('npm_config_arg')) {
    console.log(`${key}=${JSON.stringify(value)}`);
  }
}
console.log(process.argv.slice(2));

Positional arguments work as expected:

% npm -s run args three positional arguments
[ 'three', 'positional', 'arguments' ]

npm run consumes options and creates environment variables for them. They are not added to process.argv:

% npm -s run args --arg1='first arg' --arg2='second arg'
npm_config_arg2="second arg"
npm_config_arg1="first arg"
[]

If we want options to show up in process.argv, we have to use the option terminator --. That terminator is usually inserted after the name of the package script:

% npm -s run args -- --arg1='first arg' --arg2='second arg' 
[ '--arg1=first arg', '--arg2=second arg' ]

But we can also insert it before that name:

% npm -s run -- args --arg1='first arg' --arg2='second arg' 
[ '--arg1=first arg', '--arg2=second arg' ]

The npm log level (how much output is produced)  

npm supports the following log levels:

Log level npm option Aliases
silent --loglevel silent -s --silent
error --loglevel error
warn --loglevel warn -q --quiet
notice --loglevel notice
http --loglevel http
timing --loglevel timing
info --loglevel info -d
verbose --loglevel verbose -dd --verbose
silly --loglevel silly -ddd

Logging refers to two kinds of activities:

  • Printing information to the terminal
  • Writing information to npm logs

The following subsections describe:

  • How log levels affect these activities. In principle, silent logs least, while silly logs most.

  • How to configure logging. The previous table shows how to temporarily change the log level via command line options, but there are more settings. And we can change them either temporarily or permanently.

Log levels and information printed to the terminal  

By default, package scripts are relatively verbose when it comes to terminal output. Take, for example, the following package.json file:

{
  "name": "@my-scope/new-package",
  "version": "1.0.0",
  "scripts": {
    "hello": "echo Hello",
    "err": "more does-not-exist.txt"
  },
  ···
}

This is what happens if the log level is higher than silent and the package script exits without errors:

% npm run hello

> @my-scope/new-package@1.0.0 hello
> echo Hello

Hello

This is what happens if the log level is higher than silent and the package script fails:

% npm run err      

> @my-scope/new-package@1.0.0 err
> more does-not-exist.txt

does-not-exist.txt: No such file or directory

With log level silent, the output becomes less cluttered:

% npm -s run hello
Hello

% npm -s run err
does-not-exist.txt: No such file or directory

Some errors are swallowed by -s:

% npm -s run abc
%

We need at least log level error to see them:

% npm --loglevel error run abc
npm ERR! Missing script: "abc"
npm ERR! 
npm ERR! To see a list of scripts, run:
npm ERR!   npm run

npm ERR! A complete log of this run can be found in:
npm ERR!     /Users/robin/.npm/_logs/2072-08-30T14_59_40_474Z-debug-0.log

Unfortunately, log level silent also suppresses the output of npm run (without arguments):

% npm -s run
%

Log levels and information written to the npm log  

By default, the logs are written to the npm cache directory, whose path we can get via npm config:

% npm config get cache
/Users/robin/.npm

The contents of the log directory look like this:

% ls -1 /Users/robin/.npm/_logs
2072-08-28T11_44_38_499Z-debug-0.log
2072-08-28T11_45_45_703Z-debug-0.log
2072-08-28T11_52_04_345Z-debug-0.log

Each line in a log starts with a line index and a log level. This is an example of a log that was written with log level notice. Interestingly, even log levels that are “more verbose” than notice (such as silly) show up in it:

0 verbose cli /usr/local/bin/node /usr/local/bin/npm
1 info using npm@8.15.0
···
33 silly logfile done cleaning log files
34 timing command:run Completed in 9ms
···

If npm run returns with an error, the corresponding log ends like this:

34 timing command:run Completed in 7ms
35 verbose exit 1
36 timing npm Completed in 28ms
37 verbose code 1

If there is no error, the corresponding log ends like this:

34 timing command:run Completed in 7ms
35 verbose exit 0
36 timing npm Completed in 26ms
37 info ok

Configuring logging  

npm config list --long prints default values for various settings. These are the default values for logging-related settings:

% npm config list --long | grep log
loglevel = "notice"
logs-dir = null
logs-max = 10

If the value of logs-dir is null, npm uses directory _logs inside the npm cache directory (as mentioned previously).

  • logs-dir lets us override the default so that npm writes its logs to a directory of our choosing.
  • logs-max lets us configure how many files are written to the log directory before npm deletes old files. If we set logs-max to 0, no logs are ever written.
  • loglevel lets us configure npm’s log level.

To permanently change these settings, we also use npm config – for example:

  • Getting the current log level:
    npm config get loglevel
    
  • Permanently setting the current log level:
    npm config set loglevel silent
    
  • Permanently resetting the log level to the built-in default:
    npm config delete loglevel
    

We can also temporarily change settings via command line options – for example:

npm --loglevel silent run build

Other ways of changing settings (such as using environment variables) are explained by the npm documentation.

Output of life cycle scripts that run during npm install  

The output of life cycle scripts than run during npm install (without arguments) is hidden. We can change that by (temporarily or permanently) setting foreground-scripts to true.

Observations of how npm logging works  

  • Only log level silent turns off extra output when using npm run.
  • The log levels have no effect on whether log files are created and on what is written to them.
  • Error messages are not written to the logs.

Cross-platform shell scripting  

The two shells that are most commonly used for package scripts are:

  • sh on Unix
  • cmd.exe on Windows

In this section, we examine constructs that work in both shells.

Paths and quoting  

Tips:

  • Use relative paths whose segments are separated by slashes: Windows accepts slashes as separators even though you’d normally use backslashes on that platform.

  • Double-quote arguments: While sh supports single quotes, the Windows Command shell doesn’t. Unfortunately, we have to escape double quotes when we use them in package script definitions:

    "dir": "mkdir \"\my dir""
    

Chaining commands  

There are two ways in which we can chain commands that work on both platforms:

  • A command after && is only executed if the previous command succeeded (exit code is 0).
  • A command after || is only executed if the previous command failed (exit code is not 0).

Chaining while ignoring the exit code differs between platforms:

  • Unix: ;
  • Windows Command shell: &

The following interaction demonstrates how && and || work on Unix (on Windows, we’d use dir instead of ls):

% ls unknown && echo "SUCCESS" || echo "FAILURE"
ls: unknown: No such file or directory
FAILURE

% ls package.json && echo "SUCCESS" || echo "FAILURE"
package.json
SUCCESS

The exit codes of package scripts  

The exit code can be accessed via a shell variable:

  • Unix: $?
  • Windows Command shell: %errorlevel%

npm run returns with the same exit code as the last shell script that was executed:

{
  ···
  "scripts": {
    "hello": "echo Hello",
    "err": "more does-not-exist.txt"
  }
}

The following interaction happens on Unix:

% npm -s run hello ; echo $?
Hello
0
% npm -s run err ; echo $?
does-not-exist.txt: No such file or directory
1

Piping and redirecting input and output  

  • Piping between commands: |
  • Writing output to a file: cmd > stdout-saved-to-file.txt
  • Reading input from a file: cmd < stdin-from-file.txt

Commands that work on both platforms  

The following commands exist on both platforms (but differ when it comes to options):

  • cd
  • echo. Caveat on Windows: double quotes are printed, not ignored
  • exit
  • mkdir
  • more
  • rmdir
  • sort

Running bin scripts and package-internal modules  

The following package.json demonstrates three ways of invoking bin scripts in dependencies:

{
  "scripts": {
    "hi1": "./node_modules/.bin/cowsay Hello",
    "hi2": "cowsay Hello",
    "hi3": "npx cowsay Hello"
  },
  "dependencies": {
    "cowsay": "^1.5.0"
  }
}

Explanations:

  • hi1: Bin scripts in dependencies are installed in the directory node_modules/.bin.

  • hi2: As we have seen, npm adds node_modules/.bin to the shell PATH while it executes package scripts. That means that we can use local bin scripts as if they were installed globally.

  • hi3: When npx runs a script, it also adds node_modules/.bin to the shell PATH.

On Unix, we can invoke package-local scripts directly – if they have hashbangs and are executable. However that doesn’t work on Windows, which is why it is better to invoke them via node:

"build": "node ./build.mjs"

node --eval and node --print  

When the functionality of a package script becomes too complex, it’s often a good idea to implement it via a Node.js module – which makes it easy to write cross-platform code.

However, we can also use the node command to run small JavaScript snippets, which is useful for performing small tasks in a cross-platform manner. The relevant options are:

  • node --eval <expr> evaluates the JavaScript expression expr.
    • Abbreviation: node -e
  • node --print <expr> evaluates the JavaScript expression expr and prints the result to the terminal.
    • Abbreviation: node -p

The following commands work on both Unix and Windows (only the comments are Unix-specific):

# Print a string to the terminal (cross-platform echo)
node -p "'How are you?'"

# Print the value of an environment variable
# (Alas, we can’t change variables via `process.env`)
node -p process.env.USER # only Unix
node -p process.env.USERNAME # only Windows
node -p "process.env.USER ?? process.env.USERNAME"

# Print all environment variables
node -p process.env

# Print the current working directory
node -p "process.cwd()"

# Print the path of the current home directory
node -p "os.homedir()"

# Print the path of the current temporary directory
node -p "os.tmpdir()"

# Print the contents of a text file
node -p "fs.readFileSync('package.json', 'utf-8')"

# Write a string to a file
node -e "fs.writeFileSync('file.txt', 'Text content', 'utf-8')"

If we need platform-specific line terminators, we can use os.EOL – for example, we could replace 'Text content' in the previous command with:

`line 1${os.EOL}line2${os.EOL}`

Observations:

  • It’s important to put the JavaScript code in double quotes if it contains parentheses – otherwise Unix will complain.
  • All built-in modules can be accessed via variables. That’s why we don’t need to import os or fs.
  • fs supports more file system operations. These are documented in the blog post “Working with the file system on Node.js”.

Helper packages for common operations  

Running package scripts from a command line  

npm-quick-run provides a bin script nr that lets us use abbreviations to run package scripts – for example:

  • nr m -w executes "npm run mocha -- -w" (if "mocha" is the first package scripts whose name starts with an “m”).
  • nr c:o runs the package script "cypress:open".
  • Etc.

Running multiple scripts concurrently or sequentially  

Running shell scripts concurrently:

  • Unix: &
  • Windows Command shell: start

The following two packages give us cross-platform options for that and for related functionality:

  • concurrently runs multiple shell commands concurrently – for example:

    concurrently "npm run clean" "npm run build"
    
  • npm-run-all provides several kinds of functionality – for example:

    • A more convenient way of invoking package scripts sequentially. The following two commands are equivalent:
      npm-run-all clean lint build
      npm run clean && npm run lint && npm run build
      
    • Running package scripts concurrently:
      npm-run-all --parallel lint build
      
    • Using a wildcard to run multiple scripts – for example, watch:* stands for all package scripts whose names start with watch: (watch:html, watch:js, etc.):
      npm-run-all "watch:*"
      npm-run-all --parallel "watch:*"
      

File system operations  

Package shx lets us use “Unix syntax” to run various file system operations. Everything it does, works on Unix and Windows.

Creating a directory:

"create-asset-dir": "shx mkdir ./assets"

Removing a directory:

"remove-asset-dir": "shx rm -rf ./assets"

Clearing a directory (double quotes to be safe w.r.t. the wildcard symbol *):

"tscclean": "shx rm -rf \"./dist/*\""

Copying a file:

"copy-index": "shx cp ./html/index.html ./out/index.html"

Removing a file:

"remove-index": "shx rm ./out/index.html"

shx is based on the JavaScript library ShellJS, whose repository lists all supported commands. In addition to the Unix commands we have already seen, it also emulates: cat, chmod, echo, find, grep, head, ln, ls, mv, pwd, sed, sort, tail, touch, uniq, and others.

Putting files or directories into the trash  

Package trash-cli works on macOS (10.12+), Linux, and Windows (8+). It puts files and directories into the trash and supports paths and glob patterns. These are examples of using it:

trash tmp-file.txt
trash tmp-dir
trash "*.jpg"

Copying trees of files  

Package copyfiles lets us copy trees of files.

The following is a use case for copyfiles: In TypeScript, we can import non-code assets such as CSS and images. The TypeScript compiler compiles the code to a “dist” (output) directory but ignores non-code assets. This cross-platform shell command copies them to the dist directory:

copyfiles --up 1 "./ts/**/*.{css,png,svg,gif}" ./dist

TypeScript compiles:

my-pkg/ts/client/picker.ts  -> my-pkg/dist/client/picker.js

copy-assets copies:

my-pkg/ts/client/picker.css -> my-pkg/dist/client/picker.css
my-pkg/ts/client/icon.svg   -> my-pkg/dist/client/icon.svg

Watching files  

Package onchange watches files and runs a shell command every time they change – for example:

onchange 'app/**/*.js' 'test/**/*.js' -- npm test

One common alternative (among many others):

Miscellaneous functionality  

  • cli-error-notifier shows a native desktop notification if a script fails (has a non-zero exit code). It supports many operating systems.

HTTP servers  

During development, it’s often useful to have an HTTP server. The following packages (among many others) can help:

Expanding the capabilities of package scripts  

per-env: switching between scripts, depending on $NODE_ENV  

The bin script per-env lets us run a package script SCRIPT and automatically switches between (e.g.) SCRIPT:development, SCRIPT:staging, and SCRIPT:production, depending on the value of the environment variable NODE_ENV:

{
  "scripts": {
    // If NODE_ENV is missing, the default is "development"
    "build": "per-env",

    "build:development": "webpack -d --watch",
    "build:staging": "webpack -p",
    "build:production": "webpack -p"
  },
  // Processes spawned by `per-env` inherit environment-specific
  // variables, if defined.
  "per-env": {
    "production": {
      "DOCKER_USER": "my",
      "DOCKER_REPO": "project"
    }
  }
}

Defining operating-system-specific scripts  

The bin script cross-os switches between scripts depending on the current operating system.

{
  "scripts": {
    "user": "cross-os user"
  },
  "cross-os": {
    "user": {
      "darwin": "echo $USER",
      "win32": "echo %USERNAME%",
      "linux": "echo $USER"
    }
  },
  ···
}

Supported property values are: darwin, freebsd, linux, sunos, win32.

Sources of this blog post