/blog/

2025 0825 package.json scripts with default arguments

Developers in the NPM ecosystem have shoehorned the primitive scripts section of package.json into an unloved and unlovable build system. The result is something that any sane person would drop like a rod of Cobalt-60 and subsequently run away from, toward something a little more ergonomic like raw m4 or the C preprocessor.

When a script needs argument-handling logic, however, these same developers seem to lose their nerve. They’ll call out to an external shell script or JavaScript file, or drop another build system like make in on top, afraid that this (of all things) will be the last straw under which their fragile package.json will finally, mercifully, collapse.

Not me though. I am stubborn enough to try to use package.json without any extra build logic, and I’m masochistic enough to do argument handling inline with no line breaks in order to satisfy both POSIX shell and JSON file format constraints at the same time.

And so, when I need to define an NPM script that should provide some default argument but allow the user to override it, I use this idiom:

{
  "scripts": {
    "ex": "sh -c 'test $# -eq 0 && set -- DEFAULT; echo EXAMPLE \"$@\"' _"
  }
}

This accepts arguments from the user if provided, but uses a DEFAULT argument if not.

What happens when you run this?

npm run ex
# EXAMPLE DEFAULT

npm run ex one
# EXAMPLE one

npm run ex one two
# EXAMPLE one two

You can run this on the commandline and it does the same thing.

sh -c 'test $# -eq 0 && set -- DEFAULT; echo EXAMPLE "$@"' _
# EXAMPLE DEFAULT
sh -c 'test $# -eq 0 && set -- DEFAULT; echo EXAMPLE "$@"' _ one
# EXAMPLE one
sh -c 'test $# -eq 0 && set -- DEFAULT; echo EXAMPLE "$@"' _ one two
# EXAMPLE one two

Neat!

Let’s take this in steps, from outer context to inner context:

  1. sh -c '...' _ one two

    Tell sh to execute the string passed to -c as a script, and passes arguments _, one, and two to that script.

    Note that this includes passing $0, which is why we first pass a _ character. We have no use for setting $0 here, but if we don’t, then the first argument the user provides will be interpreted as the value for $0, which will consume it — probably not what they expect.

  2. test $# -eq 0 && set -- DEFAULT

    Check if there were no arguments passed and, if so, sets $1 to DEFAULT.

    This invocation of set may be surprising to non shell programmers, but it is documented in the manual:

    The remaining arguments shall be assigned in order to the positional parameters….

    The special argument -- immediately following the set command name can be used to delimit the arguments if the first argument begins with + or -, or to prevent inadvertent listing of all shell variables when there are no arguments. The command set -- without argument shall unset all positional parameters and set the special parameter # to zero.

    What this means is that you use set to define the values of $1, $2, etc (but not $0) in your script.

  3. echo EXAMPLE "$@"

    Run the command (a simple echo EXAMPLE for this demonstration), and pass it "$@".

    Some more commonly-known shell programming esoterica, "$@" expands to all input arguments, properly quoted, which means that arguments with spaces in them are handled correctly.

Here’s a real example and the reason I made this discovery. prettier requires a file or directory argument when it runs. Most of the time, I want to run it on . (meaning the current directory), but I’d also like to allow the user to pass specific files or directories instead.

{
  "scripts": {
    "format": "sh -c 'test $# -eq 0 && set -- .; prettier --write --ignore-path .gitignore --ignore-path .prettierignore \"$@\"' format",
  }
}

Responses

Webmentions

Hosted on remote sites, and collected here via Webmention.io (thanks!).

Comments

Comments are hosted on this site and powered by Remark42 (thanks!).