golang-migrateを使っている環境でRailsのdb/structure.sql相当のファイルを作成する方法

仕事で携わっているウェブアプリケーションでは、DBのマイグレーションにgolang-migrateを使っています。

DBをマイグレーションするだけのツールとしてはこれで十分なのですが、現在のスキーマダンプ、Railsでいうところのdb/structure.sqlをダンプする機能がなくて困っていたので、それ相当のファイルを作成する方法を考えました。

スキーマダンプには

  • DBスキーマの信頼できる情報源となり、現在のスキーマを概観できる
  • ダンプにスキーマのバージョンが記録されていて、バージョン管理システムの異なるブランチで同時に作業したとき、両方をマージしようとするとコンフリクトするので不整合に気づきやすい

などの利点があり*1、Railsのようなフレームワークを使っていなくとも、これに相当するファイルがあると便利です。

ファイルを作成する手順としては、DBからスキーマとそのバージョンを取得し、ファイルに書き出すだけです。

仕事ではMySQLを使っているので、bashとMySQLのコマンドラインツールを使った例を示します。ツールは例示したものである必要はなく、スキーマとそのバージョンが取得できればやり方はなんでも構いません。他の言語・DBでも同じ考え方でスキーマダンプを作成できるでしょう。

DBからスキーマのバージョンを取得してファイルに書き出す

まずはスキーマのバージョンを取得します。golang-migrateはDBがMySQLの場合、デフォルトではschema_migrationsテーブルにバージョンを記録するので、それをmysqlコマンドで取得します*2

$ echo "/* Schema version: $(
mysql \
  --host 127.0.0.1 \
  --port 13306 \
  --user root \
  --password=root \
  --skip-column-names \
  --silent \
  --execute "SELECT version FROM database.schema_migrations"
) */" > structure.sql

mysqlコマンドは単に --execute オプションを指定するだけだと、罫線を使った表を出力しますが、今回はカラム名は必要なく、SELECTした値だけが欲しいので、 --silent も指定しています。

なお、パスワードを直接指定しているのはあくまで例示のためで、実際には環境変数などを介して渡すのが良いでしょう(次に紹介するmysqldumpも同様です)。

DBからスキーマを取得してファイルに追記する

次にDBスキーマをダンプします。mysqldumpを使って、先ほどバージョンを出力したファイルにスキーマを追記します。

mysqldump \
  --host 127.0.0.1 \
  --port 13306 \
  --user root \
  --password=root \
  --compact \
  --no-data \
  --single-transaction \
  --skip-dump-date \
  database \
  >> structure.sql

database はダンプしたいデータベースの名前です。

mysqldumpはデフォルトだとテーブルの行や様々なコメントなどを出力しますが、今回はスキーマだけ出力できればいいので、 --compact --no-data オプションを使って CREATE TABLE 文だけが出力されるようにします。

また、--single-transaction を指定してトランザクション内でダンプすることで、仮にダンプ中にスキーマが変更されても出力結果が変わらないようにします。

--skip-dump-date オプションは、スキーマの変更以外でダンプファイルが変更されないように、ダンプした日付の出力をスキップする設定です。

これらのコマンドをシェルスクリプトにしておけば、いつでも誰でも手元のスキーマをファイルにダンプできるようになります。

さらに、ダンプをソースコードリポジトリにコミットしておいて、CIでもスキーマダンプを生成し、コミット済みのものと差分があったら失敗するようにしておけば、マイグレーションの適用漏れなどのも不整合も発生しにくくなるでしょう。

*1:Active Record マイグレーション - Railsガイド

*2:migrateコマンドでも取得できるのですが、ダンプを出力する環境でmigrateとMySQLコマンドラインツールを両方インストールするのが面倒だったので、mysqlコマンドを使います

git diffにまだ一度もコミットされていないファイルを含めるにはgit add --intent-to-add

まだ一度もコミットしていないファイルを git diff で表示したい場合は、git add--intent-to-add オプションを使うと、コミットせずとも git diff に表示できます。

$ echo 'Hello' > example.txt
$ git add --intent-to-add .
$ git diff
diff --git a/example.txt b/example.txt
new file mode 100644
index 0000000..e965047
--- /dev/null
+++ b/example.txt
@@ -0,0 +1 @@
+Hello

CIでgo generateしてみて差分があるかチェックする仕組みを作りたかったので、addしていないファイルも含めてdiffに出す方法はないかと探してみたところ、 --intent-to-add オプションを見つけました。

すでにGit管理下にあるファイルであれば、差分を確認したい場合は単に git diff するだけでいいのですが、コミットされていないファイルは単にdiffするだけでは表示されないので、そのためのオプションとして --intent-to-add があるようです。

ちなみに、 --intent-to-add は空のファイルを add するようで、それによって git diff に差分が表示されるようになるようです*1

Goで手軽にコード生成したいときはgomplateを使うと楽

仕事でGo言語をよく書くのですが、Goは言語機能が多くはないためボイラープレートやコピペが増えがちです。

特に他の言語でいうところのgenericsがあれば解決するのに、と思うことがよくあるのですが、現在のGoにはそのような機能は存在しない*1ため、go generate を活用したコード生成で代用することが多いと思います。

一方で、目的のコードを生成できるツールが存在しない場合、ツールをゼロから自分で実装するのは手間なので、本来はコード生成したい場合でも、実際にはコピペで済ませてしまうことがよくあります。

何か良い手はないかと探していたのですが、gomplateを使うと手軽に目的を達成できたので紹介します。

gomplate とは

gomplateはGoで書かれたテンプレートレンダラーで、さまざまなデータソースからデータの入力を受け付け、それをGoのtemplateにレンダリングするコマンドラインツールです。

例えば以下のようにしてテンプレートをレンダリングできます。

$ gomplate -i 'Hello, {{ .Env.USER }}'
Hello, maku693

gomplateではGoのtemplateの標準的な機能に加えて、文字列を加工する関数や、YAMLや環境変数などをデータソースとして受け取る仕組みが提供されており、コード生成を含むさまざまな場面で活用できます。

例えば、enum風のconstを定義する時に使えます。

Goにはいわゆるenumの機能がないため、似たようなことを実現するには typeconst を組み合わせて実現することが多いと思います。

package main

import (
	"fmt"
	"strconv"
)

type Animal int

const (
	Dog Animal = iota
	Cat
	Bird
)

func (a Animal) Validate() error {
	switch a {
	case Dog,
		Cat,
		Bird:
		return nil
	}
	return fmt.Errorf("invalid animal: %d", a)
}

func (a Animal) String() string {
	switch a {
	case Dog:
		return "Dog"
	case Cat:
		return "Cat"
	case Bird:
		return "Bird"
	}
	return "%!Animal(" + strconv.Itoa(int(a)) + ")"
}

これくらいなら手で書けそうですが、enumが欲しくなるたびにこれらを毎回定義しなければならないとなると面倒です。

また、String() メソッドだけならgolang.org/x/tools/cmd/stringerを使えば十分ですが、ほかのメソッドも必要だったり、例示した以外の関数やメソッドが各enumに対して必要になったとき、それぞれの型に対していちいち実装を追加するのは手間です。

これらの問題を解消するには、ゼロからコード生成ツールを作ってもいいのですが、gomplateを使うと、テンプレートとデータソースを用意するだけで目的が達成できます。

まず、enumを定義するためのテンプレートを用意します。

{{- $source := datasource "source" -}}
// Code generated by go generate; DO NOT EDIT.
package {{ $source.package }}

import (
	"fmt"
	"strconv"
)

type {{ $source.type }} int

const (
{{- range $i, $value := $source.values }}
	{{ $value }} {{ $source.type }} = {{$i}}
{{- end }}
)

func (v {{$source.type}}) Validate() error {
	switch v {
{{- range $value := $source.values }}
	case {{ $value }}:
		return nil
{{- end }}
	}
	return fmt.Errorf("invalid {{ $source.type | strings.ToLower }}: %d", v)
}

func (v {{$source.type}}) String() string {
	switch v {
{{- range $value := $source.values }}
	case {{ $value }}:
		return "{{ $value }}"
{{- end }}
	}
	return "%!{{$source.type}}(" + strconv.Itoa(int(v)) + ")"
}

次に、このテンプレートに渡すデータソースを用意します。今回はYAMLを使います。

package: main
type: Animal
values:
  - Dog
  - Cat
  - Bird

最後に、これらをgomplateを使ってレンダリングするファイルを用意し、go generate コマンドでレンダリングします。

package main

//go:generate go run github.com/hairyhenderson/gomplate/v3/cmd/gomplate --datasource source=animal.yml --file enum.go.tmpl --out animal_gen.go
$ go generate .

これで以下のようなファイルが生成されます。

// Code generated by go generate; DO NOT EDIT.
package main

import (
	"fmt"
	"strconv"
)

type Animal int

const (
	Dog Animal = 0
	Cat Animal = 1
	Bird Animal = 2
)

func (v Animal) Validate() error {
	switch v {
	case Dog:
		return nil
	case Cat:
		return nil
	case Bird:
		return nil
	}
	return fmt.Errorf("invalid animal: %d", v)
}

func (v Animal) String() string {
	switch v {
	case Dog:
		return "Dog"
	case Cat:
		return "Cat"
	case Bird:
		return "Bird"
	}
	return "%!Animal(" + strconv.Itoa(int(v)) + ")"
}

最初に示したコードとは若干異なりますが、実用上は変わりありません。

他のenumを生成したければ、あらたなYAMLを用意して、go:generateコメントを追加するだけですし、実装を追加したい場合は、テンプレートを変更して再度コードを生成しなおすだけなので簡単です。

この例以外にも、テンプレートとデータソースを工夫すれば、structの特定のフィールドをキーにしてソートする関数を生成したり、あるstructのsliceを別のstructのsliceに詰め替える関数を生成したりもできます。

gomplateにはGoのコード生成専用の高度な機能は存在しないので、リフレクションを使いたいなど要件が複雑な場合は自前でコード生成ツールを書いた方がよいかもしれませんが、貧者のgenericsとしてはこれくらいでも十分なのではないでしょうか。

この記事で紹介したコードは以下のリポジトリにも置いてあります。

github.com

*1:2021年3月現在、2022年初頭リリース予定のGo1.18での追加が予定されています。https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md

Node.jsアプリケーションのDockerイメージのサイズを削減する方法について調べた

Next.jsアプリケーションのDockerイメージのサイズが大きくて困っていたので調べていたところ、Next.jsの公式ドキュメントにDocker Imageというセクションがあり、おすすめ設定が記載されているのを見つけました。以前はここまで詳細な記述はなかったのですが、つい10日ほど前に追加されたようです。

ドキュメントには一番おすすめの方法だけ書かれているのですが、もともと「What is the best way to use NextJS with docker? · Discussion #16995 · vercel/next.js · GitHub」というdiscussionがあり、ドキュメントの記述はここでの議論が元になっているようです。Discussionではいくつか例が示されているのですが、それぞれさまざまな最適化テクニックが利用されており、どれくらいの容量になるか気になったので、Dockerfileを書いて調べてみました。

検証に使ったコードはこちらに置いてあります。

github.com

なお、各テクニックはNext.jsに限らずNode.jsのアプリケーション一般で通用するものだと思います。


まずはベースラインとして最適化なしのDockerfileです。

FROM node

WORKDIR /usr/src/app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

COPY . .
RUN yarn build

EXPOSE 3000
CMD ["yarn", "start"]


デフォルトのnodeイメージはDebianベースなので元からサイズが大きいです。イメージのサイズを小さくしたい場合は、可能であればまずはAlpine Linuxベースのnode:alpineに乗り換えるのがいいでしょう。

(追記)Alpineでは標準Cライブラリとしてglibcではなくmusl-libcが使われており、Node.jsでもネイティブモジュールを使っている場合に互換性の問題が起きることがあります。2021年時点では、alpineではなく、glibcが採用されておりかつサイズが小さいdistrolessやslimイメージを使うのがおすすめです(ブックマークコメントでの指摘ありがとうございます)。

blog.inductor.me

FROM node:alpine

WORKDIR /usr/src/app
COPY package.json yarn.lock /usr/src/app
RUN yarn install --frozen-lockfile

COPY . .
RUN yarn build

EXPOSE 3000
CMD ["yarn", "start"]

yarn installはデフォルトではdevDependenciesも含めて全ての依存パッケージをインストールします。アプリケーション実行用のイメージにLinterやテストフレームワークなどの開発用ツールを含める必要はないので、--productionオプションを利用して、devDependenciesをインストールしないようにすると、イメージのサイズを小さくできます。

FROM node:alpine

WORKDIR /usr/src/app
COPY package.json yarn.lock ./
RUN yarn install --production --frozen-lockfile

COPY . .
RUN yarn build

EXPOSE 3000
CMD ["yarn", "start"]

さらに、Yarnのダウンロードキャッシュを削除すると、その分イメージのサイズを小さくできます。先程のdiscussionでは二つ方法が紹介されていました。

一つはyarn cache cleanコマンドを使う方法です。

FROM node:alpine

WORKDIR /usr/src/app
COPY package.json yarn.lock ./
RUN yarn install --production --frozen-lockfile \
  && yarn cache clean

COPY . .
RUN yarn build

EXPOSE 3000
CMD ["yarn", "start"]

もう一つは、キャッシュディレクトリを/dev/shm以下にする方法です。/dev/shmはLinuxにおけるRAMディスクのマウント先で、キャッシュディレクトリをここに指定すると、手で明示的にキャッシュを消さずとも、イメージのレイヤーにキャッシュが含まれなくなります。

ただし、この方法を利用する場合は、たいてい/dev/shmのサイズが足りなくなる*1ため、docker buildコマンドの--shm-sizeオプションで十分なサイズを指定してやる必要があります。一つめの方法を採用する方が面倒が少なくて楽でしょう。

FROM node:alpine

WORKDIR /usr/src/app
COPY package.json yarn.lock ./
ENV YARN_CACHE_FOLDER=/dev/shm/yarn_cache
RUN yarn install --production --frozen-lockfile

COPY . .
RUN yarn build

EXPOSE 3000
CMD ["yarn", "start"]
$ docker build --shm-size 1gb -f Dockerfile.dev-shm-yarn-cache

さらにイメージサイズを小さくするには、アプリケーションの実行に不要なコードをイメージに含めないようにするとよいです。テストコードは実行に関係ないので削除できますし、Next.jsのようにソースコードをトランスパイル・ビルドしている場合は、ビルド元のコードは不要です。

必要なファイルのみイメージに含める方法はいくつかありますが、ファイルの削除し忘れなどのミスを避けるには、multi-stage buildを使うとよいです。これはNext.jsのドキュメントでおすすめされている方法と同様です。この方法だと、Yarnのパッケージキャッシュを手で削除する必要がないのも楽です。

FROM node:alpine AS deps
WORKDIR /usr/src/app
COPY package.json yarn.lock ./
RUN yarn install --production --frozen-lockfile

FROM node:alpine AS builder
WORKDIR /usr/src/app
COPY . .
COPY --from=deps /usr/src/app/node_modules ./node_modules
ENV NODE_ENV=production
RUN yarn build

FROM node:alpine AS runner
WORKDIR /usr/src/app
COPY --from=builder /usr/src/app/public ./public
COPY --from=builder /usr/src/app/.next ./.next
COPY --from=builder /usr/src/app/node_modules ./node_modules
EXPOSE 3000
CMD ["yarn", "start"]

では、それぞれのイメージのサイズを比較してみます。

$ docker images nextjs-docker --format "table {{.Tag}}\t{{.Size}}" | sed -e '1d' | sort -k2 -h -r 
simple               1.17GB # Debianベース
alpine               399MB # Alpineベース
install-production   315MB # yarn install --production
dev-shm-yarn-cache   157MB # キャッシュディレクトリを /dev/shm 以下にする
clean-cache          157MB # yarn cache clean
staged               156MB # Multi-stage build

結果、Next.jsのドキュメントでおすすめされている方法でビルドしたイメージが一番小さくなりました。全く最適化していないものに比べて1/8以下のサイズです。ベースイメージを変更したのが一番影響がありますが、他の手法もMB単位でサイズが小さくなっています。Multi-stage buildはこの比較だとあまり意味がないように見えますが、実際のアプリケーションでは実行時に不要なファイルがこのサンプルより多いでしょうから、ここで示したよりも効果が出るでしょう。

*1:デフォルトは64MB

ランダムな英単語を組み合わせた文字列が欲しいときのワンライナー

Githubのリポジトリ名サジェスト機能のように、ランダムな英単語をふたつ組み合わせて出力したくなったので、シェルスクリプトでワンライナーを書きました(macOS以外では動かないかも)。

$ cat /usr/share/dict/words | sort -R | head -2 | tr '[:upper:]' '[:lower:]' | paste -d '-' - -
multilocation-misallotment

2単語以上出力したい場合は、headコマンドのオプションを変えてpasteコマンドの - をその数だけ繰り返します。

$ cat /usr/share/dict/words | sort -R | head -5 | tr '[:upper:]' '[:lower:]' | paste -d '-' - - - - -
peribronchiolar-telltalely-broom-tribracteolate-benzoylformic

ハイフンではなく直接単語同士をつなぎたい場合は '-''\0' に置き換えます。

$ cat /usr/share/dict/words | sort -R | head -2 | tr '[:upper:]' '[:lower:]' | paste -d '\0' - -
anthracotheriidaexenelasia

Githubのようにあらかじめ単語を選んでるわけではないですが、何度か実行しているとなかなかかっこいい文字列が出てきます。

$ cat /usr/share/dict/words | sort -R | head -10 | tr '[:upper:]' '[:lower:]' | paste -d '-' - -
physicalness-deliquescent
biceps-foamily
tralatitious-trimetrical
trichiuroid-sewerless
nonfamous-schedar

見たことも聞いたこともない単語ばかり出てきますが、辞書で意味を調べてみると公の場で使うのは憚られそうな単語が入っていることがあるので、チェックしてから使うとよさそうです。

Github Actionsでyesコマンドを使いたい時は代わりにecho yするといい

こんばんは、id:maku693です。

タイトルでほぼ全部説明しましたが、Github Actionsでちょっとハマりました。

Github Actionsのワークフローでは工夫しないとyesコマンドを使えないので、代わりにecho yを使うと手っ取り早いです。

これに気づいたのは、[y/N]どちらかの入力を待つことで処理を続行していいか聞いてくるプログラムをワークフロー中で使いたかったので、yesコマンドを使ってyを入力させようとしたところ、yes: standard output: Broken pipeというエラーが出て正常にワークフローが終了しなかったためです。

手元 (macOS) で実行してもエラーにならないので不思議に思って調べてみると、bashにpipefailオプションが指定されている*1のと、yesコマンドがSIGPIPEを受け取って終了する前提で使われているのが原因でした。

詳しく説明すると、例えば

$ yes | head -10

のようなコマンドを実行したとき、“y”と10行表示されyesとheadが同時に終了するように見えますが、実際には同時に終了するわけではなく

  • headが10行読み終わったので終了する(パイプを読み取るプロセスがなくなる)
  • yesがパイプに書き込もうとするが、読み取り先がないのでOSがyesにSIGPIPEを送る
  • SIGPIPEを受け取ってyesコマンドがエラー終了(exit status が0以外)する

という流れで処理が進むようです*2*3*4

ここでbashにpipefailオプションが指定されていると、yesがエラー終了することによりパイプの途中で処理が失敗したとみなされます。このため、シェルのexit statusが0以外になり、ワークフローのステップも失敗します。手元でうまく動いたのはpipefailオプションが指定されていなかったからでした。

これを回避するには、set +o pipefailなどとしてbashのオプションを上書きする手もあったのですが、今回の用途では“y”を何度も出力しなくてもよかったため、よりお手軽な対応としてecho yで一度だけ“y”を出力させることにしました。

一応yesを使っているコードがないかGithubで検索してみましたが、ざっとみた限り無理やり使っている例もあまりなさそうです。

Asana APIのNode.jsクライアントを使ってタスクを作る

仕事で自分が所属しているチームではプロジェクト管理にAsanaを使っています。

先日、大量のタスクを一気に登録する必要が出てきたのですが、単にタスクを追加するだけではなく、同時に説明もまとめて入力したかったため、いちいち手でやるのも面倒だったのでAPIを使って登録してみました。

Asana APIを利用するにはアクセストークンが必要です。今回は書き捨てのスクリプトなので、Developer ConsoleからPersonal Access Tokenを取得して利用しました。

アクセストークンが準備できたらコードを書いていきます。

まずはライブラリをインストールします。

$ mkdir create-asana-tasks
$ cd create-asana-tasks
$ yarn init -y
$ yarn add asana

JSを書いてindex.jsとして保存します(ACCESS_TOKEN, PROJECT_IDは適宜差し替えてください)。

const asana = require("asana");

const client = asana.Client.create().useAccessToken("ACCESS_TOKEN");

(async () => {
  const project = await client.tasks.getProject("PROJECT_ID");
  console.log(project);

  const tasks = [
    { name: "タスク1", notes: "タスク1です" },
    { name: "タスク2", notes: "タスク2です" },
    { name: "タスク3", notes: "タスク3です" },
  ].reverse();
  for (task of tasks) {
    const result = await client.tasks.create({
      ...task,
      projects: ["PROJECT_ID"],
    });
    console.log(result);
  }
})();

このソースコードに書いてあるタスクはダミーですが、実際にはタスクの配列は元々Googleスプレッドシートに載せていたものをテキストエディターにコピペしてJSの配列に加工しました。

配列をArray#reverse()で逆順にしているのは、APIで作成したタスクはプロジェクトのリストの一番上に順に追加されていくので、最終的に配列の順番とは逆になってしまうのを避けるためです(逆順で追加するとリスト表示にしたとき配列リテラルで定義した順に並びます)。

ちなみに、AsanaのURLはhttps://app.asana.com/0/{ProjectID}/{TaskID}という構造になっているので、タスクかプロジェクトであればURLがわかればAPIを叩かなくても対象のIDがわかります。

実行するとタスクが追加されます。

$ node ./index.js

AsanaのAPIクライアントはNode.js以外にもPython, Java, PHP, Rubyのライブラリがあるようです。慣れている言語でAPIが叩けると便利ですね。

TypeScriptである型の特定のプロパティの型を得る方法

type I = {
  func: (arg: string) => void;
};

という型があった時、Iの func プロパティの型を得るには、

const f: I["func"] = (arg) => {
  console.log(arg);
};

のように I["func"] とすると取得できます。

これはGenericsを使った型でも利用可能です。

type I<T> = {
  func: (arg: T) => void;
};

const f: I<string>["func"] = (arg) => {
  // arg は string
  console.log(arg);
};

デフォルト型引数が存在する場合は変数側での型引数の指定は必須ではなく、型推論も効きます。

type I<T = Record<string, unknown>> = {
  func: (arg: T) => void;
};

const f1: I["func"] = (arg) => {
  // arg は Record<string, unknown> として扱われるので `[]` で要素にアクセスできる
  console.log(arg["foo"]);
};

ライブラリにこういった複雑な型を渡す必要があるが、ライブラリ側からプロパティの型がexportされていない場合などに便利です。

TypeScriptでは Partial<T>Pick<T, K> などである型を変換した型 (Mapped Types) を作成できるので、特定のプロパティを抜き出すのもこういったUtility Typesでやるのかと思い込んでいたのですが、そうではなく T["property"] と書く、ということになかなか気付けませんでした。

VSCodeでTS/JSの静的解析を強制的に再実行したいときはTSファイルを開いてTypeScript: Restart TS Serverコマンド

タイトルで説明終わってますが知らなかったという日記です。

VSCodeにはデフォルトでTS/JS拡張が含まれており、TS/JSファイルを開くと静的解析を実行してくれますが、たまにファイルの更新に追従できていなかったり、npm installしたあとに暴走したりと挙動が怪しくなる場合があります。

今まではおかしくなったときはVSCodeを丸ごとリロード(Developer: Reload Windowコマンド)していたのですが、TSとは関係ない拡張も再読み込みされてしまったり、エディタ全体の再描画が終わるのを待つ必要があったりと、起動に時間がかかるのが不満でした。

なんとかならないものかと思って「reload」とか「language server」などとコマンドパレットに入力してみたのですが、何も出てこなかったのでググってみたところ、普通にコマンドがあったというオチでした。

TS Serverって呼び方が全然思いつかなかったし、TS/JSファイルを開くなど特定の操作を実行しないと拡張が有効化されずにコマンドが出てこない*1*2のが難しかった。

Catalina node-gyp インストールできない 直し方

こんにちは、id:maku693 です。

先日私物の Mac の OS を Catalina にアップグレードしたせいか、node-gyp を使う npm モジュール(fsevents など)がインストールできなくなっていました。
特に fsevents が利用できないと困る*1ので、直す方法はないかと調べていたところ、バッチリ対応方法を記したドキュメントがありました

自分の手元では↑のドキュメントに記載のテスト

/usr/sbin/pkgutil --packages | grep CL

を実行してみて何も表示されなかった(テストに通らなかった)ので、

sudo rm -rf $(xcode-select -print-path)
sudo rm -rf /Library/Developer/CommandLineTools
xcode-select --install

の3コマンドを実行したところ、node-gyp を使うモジュールがインストールできるようになりました。

*1:webpack や TypeScript コンパイラなどのツールは fsevents を使ってファイルの更新を監視します。fsevents が使えない場合はポーリング実装にフォールバックされるので、使える場合に比べてリソースを余計に持っていかれます