うまくいかないときの npm コマンド
npm ls、npm explain、overrides、npm ciで予期しない依存関係を追跡し、誤ったバージョンを修正してlockfileのずれを防ぎます。
package.json のどこからも要求していないパッケージが node_modules に現れたり、固定していないバージョンで入っていたりしたときは、何かに手を付ける前に npm ls <package> でその位置を確認し、npm explain <package> でどの依存がそれを引き込んだのかを確認しましょう。
node_modules の中のバージョン番号を見つめて「お前はどこから来たんだ?」と思った経験は、どの開発者にもあるはずです。反射的な行動は決まっています。ツリーの何かがおかしいので、rm -rf node_modules して再インストールし、あとは祈る。問題が消えることもあります。しかしそれ以上に多いのは、そのまま問題が再発するケースです。インストーラーは同じ入力から同じツリーを再構築しただけであり、しかも今となっては何が変わったのか分からなくなっています。
この記事では、1つの調査プロセスを追っていきます。予期しないパッケージやバージョンを、それを要求した依存関係までたどり、適切なレイヤーで修正する流れです。ERESOLVE、EACCES、ネイティブビルドエラーといったインストール時の失敗については、このブログの別記事、ERESOLVE 競合の解決、EACCES パーミッションエラー、node-gyp のビルド失敗のガイドで扱っています。本記事は、まだエラーが何も出ていない場合のためのものです。
要点
npm ls <package>は、インストール済みツリー内でそのパッケージが現れるすべての場所と、各場所でのバージョンを表示します。npm explain <package>は、それを要求した依存関係の連鎖を表示します。--allを付けない場合、npm lsは直接依存のみを一覧します。--allを付けると完全なツリーを出力し、--depth=<n>はその両極の間で明示的な打ち切り地点を設定します。npm whyはnpm explainのエイリアスなので、npm、pnpm、yarn で同じ単語が通用します。package.jsonのoverridesフィールドは、親が要求したレンジに関係なく、ネストされた依存関係を特定のバージョンに強制します。だからこそ、まずは親の更新を試すべきです。npm ciは既存のpackage-lock.jsonを必要とし、node_modulesを削除して、ロックファイルが指定するものを厳密にインストールします。ロックファイルとpackage.jsonが食い違っている場合はエラーで終了します。
node_modules を削除すると証拠が失われるのはなぜ?
node_modules を削除して再インストールすると、予期しないパッケージがプロジェクトに入り込んだ経緯を示す唯一の記録が失われます。インストール済みツリーと package-lock.json は、npm が行ったすべての解決判断を合わせてエンコードしています。どの親がどのレンジを要求し、どのバージョンがそれを満たし、その結果がディスク上のどこに配置されたのか、です。
再インストールは、それらの判断を package.json とロックファイルから再生します。入力が変わっていなければ、同じツリーと同じ驚きが得られるだけです。入力が変わっていた場合(設定フラグ、レジストリ、レンジの編集など)、再インストールは比較対象として必要だった状態を上書きしてしまいます。いずれにしても、再構築する前にツリーを読むことです。それを読むための2つのコマンドが npm ls と npm explain です。
npm ls: パッケージはどこに、どのバージョンで入っているのか?
npm ls <package> は、インストール済みツリーを、指定したパッケージで終わるパスに絞り込み、各場所を name@version として、その上に親をインデント表示します。npm ls semver@^6 のようにバージョンレンジで絞り込むこともでき、特定のメジャーバージョンのコピーだけを気にしている場合に便利です。
# Every copy of semver, with the path down to each
npm ls semver
# The complete tree, not just direct dependencies
npm ls --all
# Cap the walk at two levels
npm ls --all --depth=2
# Only what ships to production
npm ls --all --omit=dev
depth の設定は、--all を渡さない場合はデフォルトで 0 になり、渡した場合は Infinity になります。このデフォルトは、パッケージ引数なしの素の npm ls に適用されます。パッケージ名を指定すると、npm は depth に関係なくすべてのコピーへのパスをたどります。だからこそ、ドキュメント自身の npm ls promzard の例では --all なしでネストされたヒットが表示されているのです。その走査を打ち切りたい場合は、--depth=<n> を明示的に渡してください。
npm が出力するのは、どのパッケージがどれに依存しているかのマップであり、ディスク上でフォルダが実際にどう配置されているかとは一致しません。重複排除(dedupe)されたパッケージは、それを必要とするすべての親の下に現れ、ファイルが実際に存在する1箇所だけに現れるわけではありません。出力はまた、extraneous(インストールされているが宣言されていない)、missing、または宣言されたレンジを満たさないバージョンになっているパッケージにもフラグを立てます。missing のパッケージには UNMET DEPENDENCY というラベルが付きます。--package-lock-only を追加すると、npm は現在の node_modules の内容を無視して、ロックファイルが生成するはずのツリーを報告します。
表記に関する注意が2点あります。現行のフィルタは --omit=dev と --include=dev です。--production は --omit=dev の非推奨エイリアス、--dev は --include=dev の非推奨エイリアスであり、--development はドキュメント化されたオプションではまったくありません。また、npm ls はパッケージが missing のとき、無効なバージョンのとき、あるいは指定したパッケージが何にもマッチしないときに非ゼロで終了するため、CI のチェックとして利用できます。なお、extraneous なパッケージだけでは失敗しません。
npm explain: 誰がそのパッケージを要求したのか?
npm explain <package> は、インストール済みの各コピーについて、それが存在することになった依存関係宣言の連鎖を、ルートプロジェクトに到達するまで上方向にたどって出力します。npm ls が「どこに」に答えるのに対し、npm explain は「誰が」に答えます。
npm explain semver
npm why semver # identical
npm explain semver --json # for jq
出力の各ブロックは、解決された name@version とその node_modules パスから始まり、ホップごとに1行をインデントします。親が宣言したレンジ、親自身のバージョン、親のパス、そして最後にルートプロジェクトを示す行で終わります。下から上に読むことで、自分の package.json から、予期しなかったコピーへとたどれます。重複したパッケージはコピーごとに1ブロックになるため、競合するレンジを並べて確認できます。また、npm explain node_modules/foo/node_modules/semver のようにフォルダを渡して、特定のネストされたコピー1つだけを説明させることもできます。
npm explain の概要には why がエイリアスとして記載されており、他の主要なパッケージマネージャーも同じ動詞を使っています。
| パッケージマネージャー | コマンド | 出力の形 |
|---|---|---|
| npm | npm explain <pkg> または npm why <pkg> | インストール済みコピーごとに1ブロック、ルートまでの連鎖 |
| pnpm | pnpm why <pkg> | 逆さのツリー、問い合わせたパッケージが最上部 |
| Yarn | yarn why <pkg> | ワークスペースごとの理由、pkg@range を受け付ける |
親を更新すべきか、override を追加すべきか?
npm explain で問題のあるレンジを要求している親が判明したら、最初の修正策は、その親をより良いレンジを要求しているリリースに移行させることです。npm outdated <parent> を実行して新しいバージョンが存在するかを確認するか、npm view <parent>@latest dependencies でレジストリ上の親の package.json を読みます。新しい親が許容できるレンジを宣言しているなら、それを更新して npm に子を再解決させましょう。
どの親のリリースでもレンジが修正されない場合にのみ、overrides に手を伸ばすべきです。
{
"overrides": {
"semver": "^7.5.4"
}
}
override は、親が宣言したレンジに関係なくネストされた依存関係のバージョンを置き換えるため、親はテストされたことのないバージョンに対して動作することになりかねません。それがトレードオフであり、overrides が最初ではなく2番目の手段である理由です。ドキュメントからのルールをいくつか挙げます。overrides はルートの package.json でのみ尊重されます。直接依存しているパッケージは、それ自身のスペックと同一のスペックでしかオーバーライドできず、そうでなければ npm は EOVERRIDE を投げます。そのケースのために $name という参照形式が用意されています。値には、厳密なバージョン、レンジ、dist-tag、あるいは npm:、file:、Git の指定子を使えます。ツリーの全体ではなく1つの枝にだけ適用したい場合は、親の名前の下に override をスコープしてください。
npm config list: 設定したことを忘れている設定
npm config list は、あなた自身、環境、あるいは .npmrc ファイルによって設定された値を出力します。npm config list -l は npm のデフォルトも出力し、--json は同じデータを JSON で返します。package.json だけでは説明できない形でツリーが解決されているとき、その原因は誰も書いた覚えのない設定値であることがよくあります。
npm config list
npm config list -l
出力はソース(コマンドライン、環境、プロジェクトの .npmrc、ユーザーの .npmrc、グローバル)ごとにグループ化されるので、どのファイルを編集すべきか分かります。まず確認する価値のあるキーが2つあります。デフォルト以外の registry は、バージョンがミラーやプライベートレジストリに対して解決されたことを意味し、その内容は公開レジストリより遅れている可能性があります。保存された legacy-peer-deps の設定は、バージョン6までの挙動と同じように、peerDependencies をまったく参照せずにツリーを構築するよう npm に指示します。そのため、現行のリゾルバーであれば拒否したはずの組み合わせになってしまうことがあります。これには波及効果があります。いったんそのフラグでロックファイルが構築されると、以降のすべての npm ci でもそのフラグが必要になり、そうでなければインストールが壊れます。プロジェクトの .npmrc にある1行の忘れ物が、奇妙なローカルツリーと CI の失敗の両方を説明してしまうこともあるのです。
npm ci と npm install: ロックファイルが食い違っているとどうなるか?
ロックファイルが package.json を満たしている場合、npm install はロックファイルの厳密なバージョンを使います。満たしていない場合、npm install は再解決して package-lock.json を更新します。npm ci はその代わりにエラーになります。
| 挙動 | npm install | npm ci |
|---|---|---|
package-lock.json を必須とする | いいえ | はい |
ロックファイルと package.json が食い違う | 再解決し、ロックファイルを書き換える | エラーで終了 |
既存の node_modules | 再利用 | 先に削除 |
package.json やロックファイルへの書き込み | あり | 決してしない |
| 単一パッケージの追加 | 可能 | 不可 |
npm install のドキュメントは、優先順位について明確です。package.json のレンジが真実の源であり、ロックファイルは固定されたバージョンがそのレンジに収まっている間だけそれを保持します。これはまさに CI では望ましくない挙動であり、ロックファイルが黙って書き換えられると、検出しようとしているドリフトが隠れてしまいます。npm ci は2つのファイルを調整することを拒否して大きな音を立てて失敗するので、パイプラインではこれを使い、npm install は依存関係を意図的に変更するマシン用に取っておきましょう。
まとめ
ツリー内の予期しないパッケージは、証跡を伴った解決判断の結果であり、npm ls と npm explain はその証跡を乱すことなく読み取ります。連鎖をたどってレンジを宣言した親を特定し、より良いリリースがあれば親を修正し、それがない場合にのみ override し、その後で npm config list を確認して、そもそも解決を歪めた設定がないか調べましょう。CI では npm ci を実行して、次回のミスマッチがロックファイルを静かに書き換えるのではなく、ビルドを失敗させるようにしてください。
FAQ
npm ls の出力でパッケージの横にある 'deduped' とは何を意味するのか?
'deduped' というラベルは、npm ls が論理的な依存グラフ上のその位置にパッケージを表示しているが、そこには別個のコピーが存在しないことを意味します。node_modules のより上位にある単一のインストール済みコピーが、その親のレンジを満たしているのです。これはエラーではありません。npm ls は論理ツリーを出力するため、同じパッケージはそれを必要とするすべての親の下に現れ、ラベルの付いていない行だけが物理的なフォルダに対応します。
npm ls が extraneous として報告するパッケージはどう削除すればよいか?
npm prune を実行します。node_modules にあって他の何からも依存されていないものを削除します。1つ以上のパッケージ名を指定すると、それらに限定できます。--omit=dev を追加するか NODE_ENV を production に設定すると、devDependencies も削除されます。まず計画を確認するには --dry-run を、変更を JSON で受け取るには --json を使います。インストール自体が extraneous なパッケージを自動的に片付けるため、これが必要になるのは主にクラッシュの後や中途半端に終わったインストールの後です。
npm ls が示す重複バージョンは npm dedupe で直せるのか、それとも overrides が必要なのか?
npm dedupe が統合できるのは、宣言されたレンジが既に許容しているコピーだけです。ツリーを走査して各依存をできるだけ上位に持ち上げるので、レンジが重なり合う親同士は単一のコピーを共有するようになり、レジストリから新しいものを取得することは決してありません。2つの親が共通のバージョンを持たないレンジを要求している場合、両方のコピーが残り、修正方法は親を更新するか overrides エントリを追加することになります。npm find-dupes は同じ処理をドライランとして実行するので、事前に結果を確認できます。
グローバルにインストールされた npm パッケージを一覧表示する方法は?
npm ls -g を実行します。--global フラグは npm ls をグローバル prefix に向け、現在のプロジェクトではなくそこにインストールされたパッケージを一覧します。depth のルールは同じです。--all なしではトップレベルのグローバルパッケージのみを出力し、npm ls -g --all はそれぞれを完全な依存ツリーに展開します。走査を打ち切るには明示的な --depth 値を、機械可読な出力には --json を追加してください。