🐚

Node環境ならシェルではなくJavaScriptを書いてもいいんじゃね

に公開

最近、ビルド前のクリーンアップやデプロイ前処理を書くとき、毎回shbashの書き方を調べている自分に気づきました。

「このプロジェクトはNode環境があるのに、なぜわざわざshellで書いているんだろう?」

この問いから、JavaScriptでスクリプトを書くというアプローチを試してみたところ、意外なほど快適だったので共有します。


前提:shellとNode.js、どちらを選ぶべきか

まず最初に断っておくと、shellスクリプトを全面的に置き換えようという話ではありません

shellが向いているケース

  • パイプや標準入出力の操作が中心
  • システムコマンドを組み合わせるだけで済む
  • サーバー環境で素早く処理を書きたい
  • Unix系OSでしか動かない前提

Node.jsが向いているケース

  • 複雑なエラーハンドリングが必要
  • チーム全員がJavaScript/TypeScriptに慣れている
  • クロスプラットフォーム対応が必須(Windows含む)
  • 既存のnpmパッケージと連携したい
  • 条件分岐やループが複雑になりそう

この記事では、後者のケースに焦点を当てて解説します。


Node.jsでスクリプトを書くという選択肢

Node.jsは「サーバーサイドJavaScript」として知られていますが、実は汎用的なスクリプトランタイムとして使うこともできます。

たとえば、scripts/build.mjs のようなファイルを用意して、

node scripts/build.mjs

と実行すれば、それがそのままタスクスクリプトになります。


JavaScriptで書く利点

1. チーム内での可読性が高い

多くのフロントエンド・バックエンドプロジェクトでは、チームメンバー全員がJavaScriptを読める状況が多いです。

一方で:

  • shellスクリプトの文法は独特で、慣れていないと読みにくい
  • [[ ]]$( )、パイプ処理などは初見だと戸惑う
  • Windowsユーザーはそもそもshellに触れない環境も多い

Node.jsなら、既存のJavaScriptの知識がそのまま活きます。

2. 開発ツールがそのまま使える

.mjsファイルであれば、プロジェクト内の以下のツールがそのまま適用されます:

  • ESLint(静的解析)
  • Prettier(コード整形)
  • VS Code(補完・型推論)

「スクリプトだけコード品質がバラバラ」という状況を避けられます。

3. Node標準APIが実用的

Node.jsの標準モジュール(fs/promises, path, child_process, osなど)は、
ファイル操作からプロセス実行まで幅広くカバーしています。

処理 shell Node.js
ディレクトリ削除 rm -rf dist fs.rm("dist", { recursive: true, force: true })
ファイルコピー cp src.txt dest.txt fs.copyFile("src.txt", "dest.txt")
コマンド実行 npm run build child_process.exec("npm run build")
環境変数参照 $ENV_VAR process.env.ENV_VAR
ファイル存在確認 [ -f path ] fs.access("path")

Node APIの特徴

  • Promise/async-awaitベースで非同期処理が自然
  • クロスプラットフォームでOS差を吸収
  • エラーハンドリングが明示的で制御しやすい

実践例1:ビルド前クリーンアップスクリプト

shellで書く場合

#!/bin/bash
set -e  # エラーで即時終了

rm -rf dist
echo "🧹 cleaned dist"

vite build
echo "✅ build complete"

問題点

  • set -eを書き忘れると、エラーでも処理が続く
  • エラーメッセージの制御が難しい
  • Windows環境では動かない(WSL必須)

Node.js(ESM)で書く場合

// scripts/build.mjs
import { rm } from "fs/promises";
import { exec } from "child_process";
import { promisify } from "util";

const execAsync = promisify(exec);

async function main() {
  try {
    // ディレクトリ削除(存在しなくてもエラーにならない)
    await rm("dist", { recursive: true, force: true });
    console.log("🧹 cleaned dist");

    // ビルドコマンド実行
    const { stdout, stderr } = await execAsync("vite build");
    if (stderr) console.error(stderr);
    console.log(stdout);
    console.log("✅ build complete");
    
  } catch (error) {
    console.error("❌ ビルドに失敗しました");
    console.error(error.message);
    process.exit(1); // 終了コード1で終了
  }
}

main();

package.jsonへの登録

{
  "scripts": {
    "build": "node scripts/build.mjs"
  }
}

これでnpm run buildで実行できます。


実践例2:環境別デプロイスクリプト(複雑なケース)

次は、条件分岐とエラーハンドリングが複雑になる実用例です。

要件

  • 環境(staging/production)に応じて異なる処理を実行
  • .envファイルの存在チェック
  • ビルド → テスト → デプロイの順次実行
  • 途中で失敗したら即座に停止し、わかりやすいエラーメッセージを表示

shellで書く場合

#!/bin/bash
set -e

ENV=${1:-staging}

if [ "$ENV" != "staging" ] && [ "$ENV" != "production" ]; then
  echo "❌ 引数は staging または production を指定してください"
  exit 1
fi

if [ ! -f ".env.$ENV" ]; then
  echo "❌ .env.$ENV が見つかりません"
  exit 1
fi

echo "📦 Building for $ENV..."
npm run build

echo "🧪 Running tests..."
npm test

if [ "$ENV" = "production" ]; then
  echo "⚠️  本番環境へデプロイします。続行しますか? (y/n)"
  read -r response
  if [ "$response" != "y" ]; then
    echo "キャンセルしました"
    exit 0
  fi
fi

echo "🚀 Deploying to $ENV..."
rsync -avz dist/ user@server:/var/www/$ENV/

echo "✅ デプロイ完了: $ENV"

問題点

  • 条件分岐の構文が独特([ ]&&の使い方)
  • 対話的入力(read)の処理が複雑
  • エラーメッセージの一元管理が難しい
  • Windows環境では動かない

Node.js(ESM)で書く場合

// scripts/deploy.mjs
import { access } from "fs/promises";
import { exec } from "child_process";
import { promisify } from "util";
import * as readline from "readline";

const execAsync = promisify(exec);

// 対話的入力を取得する関数
function askQuestion(query) {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });

  return new Promise((resolve) => {
    rl.question(query, (answer) => {
      rl.close();
      resolve(answer);
    });
  });
}

async function main() {
  const env = process.argv[2] || "staging";

  try {
    // 環境の検証
    if (!["staging", "production"].includes(env)) {
      throw new Error("引数は staging または production を指定してください");
    }

    // .envファイルの存在確認
    const envFile = `.env.${env}`;
    try {
      await access(envFile);
    } catch {
      throw new Error(`${envFile} が見つかりません`);
    }

    // ビルド
    console.log(`📦 Building for ${env}...`);
    await execAsync("npm run build");

    // テスト実行
    console.log("🧪 Running tests...");
    await execAsync("npm test");

    // 本番環境の場合は確認
    if (env === "production") {
      const answer = await askQuestion(
        "⚠️  本番環境へデプロイします。続行しますか? (y/n): "
      );
      if (answer.toLowerCase() !== "y") {
        console.log("キャンセルしました");
        process.exit(0);
      }
    }

    // デプロイ実行
    console.log(`🚀 Deploying to ${env}...`);
    await execAsync(`rsync -avz dist/ user@server:/var/www/${env}/`);

    console.log(`✅ デプロイ完了: ${env}`);
    
  } catch (error) {
    console.error("❌ デプロイに失敗しました");
    console.error(error.message);
    process.exit(1);
  }
}

main();

package.jsonへの登録

{
  "scripts": {
    "deploy:staging": "node scripts/deploy.mjs staging",
    "deploy:prod": "node scripts/deploy.mjs production"
  }
}

この例で分かること

観点 shell Node.js
条件分岐 if [ "$ENV" != "staging" ] if (!["staging", "production"].includes(env))
ファイル存在確認 [ ! -f ".env.$ENV" ] await access(envFile) でtry-catch
対話的入力 read -r response readlineモジュール(Promise化できる)
エラーメッセージ echoで個別出力 try-catchで一箇所にまとめられる
可読性 shell独特の構文 JavaScriptの標準的な書き方

特に**「ファイル存在確認 → ビルド → テスト → 確認 → デプロイ」のような多段階処理**では、Node.jsの方が圧倒的に見通しが良くなります。


比較まとめ

観点 shell (.sh) Node.js (.mjs)
エラー時停止 set -e が必要 try/catchで明示的に制御
エラーメッセージ 標準エラー出力に依存 整形・カスタマイズ可能
クロスプラットフォーム Linux/macOS中心 Windows含め統一動作
非同期処理 複雑 async/awaitで自然
型補完 なし VS Code等で効く
再利用性 関数化しづらい モジュール化しやすい
条件分岐 独特の構文 JavaScript標準の構文

よくある疑問:「結局shellコマンドを呼んでるだけでは?」

その通りです。Node.jsスクリプトでもexecで外部コマンドを呼ぶ場合が多いです。

ただし、以下の点で価値があります:

  1. エラーハンドリングの一元化
    shellで複数コマンドを実行するとエラー処理が煩雑になりますが、Node.jsならtry/catchで一箇所にまとめられます。

  2. 条件分岐や繰り返しが書きやすい
    shellのifforは独特ですが、JavaScriptなら慣れた構文で書けます。

  3. 既存のnpmパッケージと統合できる
    例:.envの読み込み、YAML/JSONの解析、HTTPリクエストなど。


発展:DenoやBunならさらに快適

Node.jsでも十分実用的ですが、DenoBunを使うとさらにモダンになります。

Deno/Bunの利点

  • TypeScriptがそのまま動く(トランスパイル不要)
  • 標準でfetchが使える
  • 高レベルなファイルAPIが充実
  • 起動が高速

ただし、NodeのAPIとの互換性が完全ではない点に注意が必要です。
既存のNodeプロジェクトに導入する場合は、段階的に試すことをお勧めします。


まとめ:「小さなプログラム」としてのスクリプト

shellスクリプトを書く代わりに、
**「JavaScriptで小さなプログラムを書く」**という視点に変えると、

  • 読みやすさ(チーム全員が読める)
  • 保守性(linter/formatterが効く)
  • クロスプラットフォーム性(Windows対応)

など、多くのメリットがあります。

もちろん、すべてのshellスクリプトをNode.jsに置き換える必要はありません
しかし、複雑なエラーハンドリングが必要な場合や、チーム全員がJavaScriptに慣れている場合は、
.mjsファイルを一枚置いてみる価値があります。


「NodeをCLIランタイムとして使う」時代が、すでに始まっているのかもしれません。

Discussion