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:
-
sh -c '...' _ one two
Tell
sh
to execute the string passed to-c
as a script, and passes arguments_
,one
, andtwo
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. -
test $# -eq 0 && set -- DEFAULT
Check if there were no arguments passed and, if so, sets
$1
toDEFAULT
.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. -
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",
}
}