
はじめに
npmにもGitHub Packagesにも公開しない方針の社内限定ライブラリを、複数のリポジトリが vendor/ 配下に手でコピーして参照する構成は珍しくありません。ただし手コピーには弱点があります。どのコミットから持ってきたコードなのか、コピーしたあとに誰かが手を加えていないか、を追跡する手段がどこにもないのです。
この記事では、その手コピーを「取り込み元コミットとファイルごとのSHA-256を記録して書き出すツール」と「消費側リポジトリにコピーしてCIから検査する、依存パッケージゼロの検証スクリプト」の組み合わせに置き換える設計を紹介します。npm未公開のライブラリを複数リポジトリへvendoringで配っているプロジェクトであれば、そのまま応用できる内容です。
こんな人におすすめ
- npmやGitHub Packagesに公開しない前提の社内共通コードを、複数リポジトリへコピーで配っている
- vendor先のコードが改変されていないかをCIで機械的に検査したい
npm packの出力をそのまま消費側に持ち込んで、依存関係やフックまわりで事故った経験がある- Node.js製CLIツールで、ファイルシステムを安全に書き換える定番パターンを知りたい
vendor先のpackage.jsonは、npm packの出力をそのまま使わない
npm pack が生成する package.json は、そのまま消費側にコピーすると危険です。scripts(prepare フックなど)や devDependencies、publishConfig を含んだまま書き出すと、消費側の npm install や npm ci がフックの誤実行や不要パッケージの大量インストールで壊れることがあります。
安全な作り方は、ランタイムに必要なフィールドだけのallowlistから組み立て直すことです。
const VENDORED_PACKAGE_JSON_FIELDS = [
'name', 'version', 'type', 'main', 'module', 'types', 'exports',
'sideEffects', 'dependencies', 'peerDependencies',
'peerDependenciesMeta', 'optionalDependencies', 'engines',
] as const;
function buildVendoredPackageJson(
source: Record<string, unknown>,
version: string
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const key of VENDORED_PACKAGE_JSON_FIELDS) {
if (key === 'version') {
result.version = version;
continue;
}
if (Object.prototype.hasOwnProperty.call(source, key)) {
result[key] = source[key];
}
}
return result;
}
scripts や devDependencies はallowlistに含めないため、素通しでは絶対に入り込みません。何が起きるか分からない「消し忘れ」ではなく、何を残すかを明示するアプローチです。
worktreeのルートかどうかは、–show-toplevelとrealpathで判定する
「対象パスがgit worktreeのルートかどうか」を確認したいとき、git rev-parse --is-inside-work-tree の終了コードだけを見るのは不十分です。このコマンドは「どこかのworktreeの中にある」ことしか保証しておらず、別リポジトリの単なるサブディレクトリや .git ディレクトリ自体まで通してしまうケースが実測で見つかっています。
--show-toplevel の結果と対象パスのrealpathを突き合わせるところまでやって、初めて「ルートそのものか」を判定できます。
function assertTargetIsGitWorkTree(target: string): void {
let toplevel: string;
try {
toplevel = execFileSync('git', ['-C', target, 'rev-parse', '--show-toplevel'], {
encoding: 'utf8',
}).trim();
} catch {
throw new Error(`target が git worktree ではない(.git が見つからない): ${target}`);
}
if (realpathSync(target) !== realpathSync(toplevel)) {
throw new Error(
`target が git worktree のルートではない(別リポジトリのサブディレクトリ、または .git` +
` ディレクトリ自体である可能性がある): ${target}`
);
}
}
書き込み先を扱うツールほど、この手の「一見通っているように見えるが実は違う」判定は事故の温床になります。
ESMの「直接実行判定」は、realpathで正規化してから比較する
検証スクリプトを「モジュールとしてimportされたときは何もせず、CLIとして直接実行されたときだけ動く」ように作るとき、よく使われるのが import.meta.url と process.argv[1] の比較です。ただし単純な文字列比較にすると壊れます。
function isMainModule() {
if (typeof process.argv[1] !== 'string') {
return false;
}
try {
return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
} catch {
return false;
}
}
if (isMainModule()) {
main();
}
import.meta.url === \file://${process.argv[1]}`` のような素朴な比較は、パスにスペースや非ASCII文字を含む場合や、実行パスがシンボリックリンク経由の場合に一致しなくなります。厄介なのはその壊れ方で、main() が実行されないまま終了コード0で終わるため、「何も検査していないのに成功したように見える」という気づきにくい失敗になります。pathToFileURL と realpathSync で両辺を正規化してから比較することで防げます。
曖昧な状態を検知したら、警告ではなく即座にエラーにする
安全なツールを作るうえでもう一つ効くのが、「本当は安全と言い切れない状態」を検知したら黙らずにエラーにする、という方針です。今回の実装では2箇所で意識しました。
.gitignoreされたパスはgit status --porcelainに何も出てきません。「未コミットの変更が無ければ上書きOK」という安全チェックは、gitの管理下に入っていないパスに対しては無力で、--forceフラグを付けても救えません。git check-ignoreで明示的に検知して弾く必要があります。- 同名のタグとブランチが両方存在する場合、gitの暗黙の解決順(タグ優先など)に頼ると、呼び出し側の意図と食い違うことがあります。曖昧さを検知したら警告で済ませず、即座にエラーにする設計にしました。
つまづきやすいポイント
- ビルドを伴う重いテスト(実プロセスで
npm ci && npm run buildを走らせる再現性テストなど)は、通常のCIゲートから意図的に切り離すと運用しやすくなります。専用のvitest config・専用のnpm scriptに分離し、「速いテスト」と「メンテナが明示的に叩く重いテスト」を分けることで、CI予算を圧迫せずに実ビルドの回帰も担保できます。 - ファイルシステムを書き換えるCLIツールでは、一時ディレクトリで完全に組み立ててから
renameで一括差し替え、失敗時はロールバック、さらにSIGINT/SIGTERM時の後始末まで用意しておくと安全です。処理が中途半端な状態で中断しても、書き込み先が壊れたまま残りません。 - allowlist方式は安全な反面、リストに載っていない未知フィールドを黙って落とす挙動になりがちです。その挙動を許容するか、検知してエラーにするかは、運用しながら判断する余地が残ります。
まとめ
npm未公開の内部パッケージをvendoringで配る構成では、「取り込み元コミットとファイルごとのハッシュを記録する」「消費側でCIから検証する」の2点セットが効きます。実装の細部では、npm pack 出力のallowlist化、worktreeルート判定のrealpath突き合わせ、ESM直接実行判定の正規化など、一見地味な正規化処理がツールの信頼性を大きく左右します。曖昧な状態を警告で流さずエラーにする方針も含め、同種の課題を抱えるプロジェクトであれば部分的にでも取り入れやすい内容だと思います。

