package.json を避けて Node.js スクリプトを起動する Bash スクリプト

ちょっと何言ってるのか分からないタイトルだけど、特殊な状況で変なことをやろうとしている記事。

以下のように、構文エラーとなる package.json が存在するディレクトリ配下で、その package.json に依存しない Node.js スクリプトを実行したい、というのが今回の状況。

# 構文エラーとなる `package.json` が存在する
$ cat ./package.json
{
  "name": example,
  // この package.json には構文エラーになる記述が含まれています
}

# Node.js スクリプトは何も `require()` していない、`package.json` を必要としない内容
$ cat ./test.js
#!/usr/bin/env node
console.log('Hello World');

# 実行しようとするとエラーになる
$ node ./test.js
internal/modules/cjs/loader.js:285
    throw e;
    ^

SyntaxError: Error parsing /home/neo/test-directory/package.json: Unexpected token e in JSON at position 12
    at parse (<anonymous>)
    at readPackage (internal/modules/cjs/loader.js:272:20)
    at readPackageScope (internal/modules/cjs/loader.js:297:19)
    at shouldUseESMLoader (internal/modules/run_main.js:42:15)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:70:24)
    at internal/main/run_main_module.js:17:47 {
  path: '/home/neo/test-directory/package.json'
}

このように、参照しなくて良い package.json なのだが、どうしてもコレを先にロードしようとして JSON パースできずに異常終了してしまう。

package.json をリネームしたり、test.js から見て子ディレクトリにでも移しておけば問題は起こらなくなるのだが、今回はこのような package.jsontest.js が同ディレクトリにある状態で、何とか package.json の存在を無視して test.js を動かす方法はないかと考えてみた。

こういう時は、以前 Windows バッチでやったような、「あるシェルスクリプトの中に別言語のコードを埋め込んで実行する」方法を応用して考えてみる。

今回は MacOS や WSL (Linux) 上で動けば良かったので、「自身を Node.js スクリプトとして動かす Bash スクリプト」を書いてやることにした。コード全量は以下のとおり。

#!/usr/bin/env bash
# Bash ココカラ
temp_file_path="$(mktemp)---$(basename $0)"
start_line_number="$(awk '/^#!\/usr\/bin\/env node$/ { print FNR }' "$0")"
tail --lines "+${start_line_number}" "$0" > "${temp_file_path}"
node "${temp_file_path}"
rm -f "${temp_file_path}"
exit 0
# Bash ココマデ

#!/usr/bin/env node
// ココカラ Node.js スクリプトを自由に書いて OK
console.log(['Hello World', process.cwd(), __dirname, __filename, 'End'].join('\n'));

拡張子は .js としているが、1行目から9行目までは Bash スクリプトであり、1行目の Shebang は Bash なので、$ ./bash-to-node.js と実行するとまずは Bash スクリプトとして実行される。

Node.js スクリプトは11行目の #!/usr/bin/env node という Shebang 以降であり、この下には任意の Node.js スクリプトを書けば良い。require() できるのは Node.js の組み込みモジュールのみである。

node コマンドで JS ファイルを実行すると、そのファイルがあるディレクトリの package.json が先に読み込まれてしまうので、1~9行目の Bash スクリプト自身で、自身の Node.js コード部分のみを Temp ディレクトリ配下にコピーしてから実行している。

もう少し Bash 部分のコードを細かく見てみよう。

#!/usr/bin/env bash

# Bash ココカラ

# `mktemp` コマンドを使い、一時ディレクトリ配下にファイルを安全に作成する
temp_file_path="$(mktemp)---$(basename $0)"

# `awk` を駆使して自ファイル内から `#!/usr/bin/env node` という Shebang の行を探し出す
start_line_number="$(awk '/^#!\/usr\/bin\/env node$/ { print FNR }' "$0")"

# `tail` で Shebang の行以降を出力し、一時ファイルに書き込む
tail --lines "+${start_line_number}" "$0" > "${temp_file_path}"

# `node` コマンドで一時ファイルを実行する・一時ディレクトリの方には `package.json` が存在しないので上手く動くというワケ
node "${temp_file_path}"

# `node` コマンドの実行後、一時ファイルを削除する
rm -f "${temp_file_path}"

# Bash としての処理を終了し、以降の Node.js スクリプト部分が実行されないようにする
exit 0

# Bash ココマデ

こういう仕組み。mktemp コマンドと、tail--lines +11 という + を使った指定方法がミソである。

mktemp コマンドで一時ディレクトリ配下にコピーを作ってから実行しているので、__dirname__filename のパスが一時ディレクトリになることに留意。process.cwd() ならカレントディレクトリがズレずに取得できるので、カレントディレクトリを参照する処理があるならコチラを使うと良いかと。

$ pwd
/home/neo/test-directory

$ ls
bash-to-node.js*  package.json

# 実行時の一例
$ ./bash-to-node.js
process.cwd() : /home/neo/test-directory
__dirname     : /tmp
__filename    : /tmp/tmp.pDIlNEmpgl---bash-to-node.js

結局のところ、このファイルは最初に Bash として実行され、11行目以降の処理はどんなプログラミング言語であっても良いので、他の言語にも転用できる。…まぁ、こんなことやる必要がないだろうが。w