I was chatting with someone on #node.js who wanted his script to pass a command-line option to node, so that his script was run in a particular node environment. The problem is that under linux you get to pass exactly one argument on the shebang (#!) line. If you use #!/usr/bin/env node
, you’ve already used your one argument. When I suggested he use the “-x” hack, we discovered that node didn’t have this hack. So I made a pull request complete with a TL;DR justification for why -x is necessary.
Turns out there’s a tidier hack that doesn’t require any changes to node, which relies on the interaction between bash and node. Â Here’s an example, lifted from pm2 and lightly modified for clarity:
#!/bin/sh
":" //# comment; exec /usr/bin/env node --noharmony "$0" "$@"
console.log('javascript');
Here’s how it works:
-
The
#!/bin/sh
causes the script to be identified as a shell script, and passed to/bin/sh
for execution./bin/sh
reads and executes scripts one line at a time, and we’re taking advantage of that below. -
The second line, as interpreted by the shell, consists of two commands.
2a. The first command is
":"
, which is the quoted version of the rarely-used bash command:
, which means “expand arguments and no-op”. The only argument to:
is//
, which is a valid path. The following#
is a bash comment, which is valid until the command separator;
.2b. The second command is
exec /usr/bin/env node --noharmony "$0" "$@"
which executes the node interpreter with the desired arguments and passes argument 0 (this script file) and the rest of the arguments to the bash script ("$@"
) -
The
exec
causes the bash process to be replaced by the node process, so bash does not attempt to process any further lines.
Now we’re running under node, with the desired command line arguments set. Unlike bash, node wants to read and parse the whole file. So let’s see what node sees:
- The
#!/bin/sh
line is ignored due to a special one-off in node – when loading a module, the contents of the first line will be ignored from#!
up to the first\n
. - The second line contains a string constant, the quoted string
":"
, followed by a Javascript comment introduced with//
. Automatic semicolon insertion happens so the constant is interpreted as a string in a statement context. Then the comment is parsed, and everything up to the end of the line is ignored by node.
This won’t lint clean. jslint and jshint both complain:
$ jslint test
test:2:1: Expected an assignment or function call and instead saw an expression.
test:2:4: Expected ';' and instead saw 'console'.
$ jshint test
test: line 2, col 1, Missing semicolon.
1 error
But it works right now, as a hack-around for the Linux one-argument shebang problem.
Note that there’s a spot in the line where you can insert a comment (as long as it doesn’t contain anything that bash interprets, notably ;
). What to put there? I recommend a link to a web page (such as the one you’re reading now, https://sambal.org/?p=1014) that explains WTF this weird-looking line is all about. For example:
#!/bin/sh
":" //# https://sambal.org/?p=1014 ; exec /usr/bin/env node --noharmony "$0" "$@"
console.log('javascript');
Happy hacking!
Sometimes I swear you’re speaking a language similar, but not identical, to English.
Almost, but not entirely, completely unlike English.
Jesus! That is some weird multi-language parsing. Is there any good reason why shebang doesn’t take multiple arguments?
Walt- the tl;dr links to three threads (2 lkml one FreeBSD) discussing it at far too much length.
So yes but I can’t even.
Wow, a solution I can barely understand to a problem I can’t fathom existing. Can’t node get config/environment info from an XML file like a normal app? Or is this more like JVM settings?
Far too clever
John, yeah it’s like jvm settings, it configures language version, tracing, memory size stuff.
So why take over the current shell’s settings vs forking another process with the settings you want like Java’s JVM does? Is node that immature?
It’s just acting as its own wrapper script. With java you’d have a sh or bat file that configures the jvm and loads your main, right? Same thing here but on unixy systems can omit the sh file
I felt on this post by accident; for fun, I found a way to pass jslint and jshint tests with this hack, for those who might be interested, but this makes the code even more ugly.
If you add
.charAt(0);
just after the second line, jshint and jslint both “see†the":".charAt(0)
expression as a function call (even if it does nothing) and this fixes the missing semicolon at the same time.#!/bin/sh
":" //# comment; exec /usr/bin/env node --noharmony "$0" "$@";
.charAt(0);
console.log('If you are seeing this in your code, ugliness should sound like a familiar concept to you.');
Note that I had to indent the third line in order to pass jslint tests.
I think this may brake auto-generated cmd file on windows, when module is installed from NPM globally.
Addtional work to remove ‘\r’ should be added to the script.
console.dir(process.argv) shows ‘\r’ adding to last argument passed to the script. If no arguments are passed, there will be one argument (‘\r’) in process.argv.
‘\r’ is added if there is Windows EOL after shebang.
It’s also possible to work around this problem using awk:
#!/usr/bin/awk END {system("node --noharmony " FILENAME)}
Explanation:
Under Linux everything after the shebang path gets parsed to the program as the first argument. Therefore we can’t use ‘#!/usr/bin/env akw’ and so this probably won’t work on every system. This is also the reason why we are using awk ― it just interprets the first argument. With system we then execute a bash command and open the script with node and parse the arguments.
Don’t forget for ESLint:
[‘spaced-comment’]: [“error”, “always”, { “markers”: [“#”] }],
Otherwise ESLint –fix will place space between // and #. Which will break bash script.