Skip to main content

lib/mix/tasks/npm.exec.ex

defmodule Mix.Tasks.Npm.Exec do
  alias NPM.Node.Exec
  alias NPM.Node.Runner

  @shortdoc "Execute a package binary"

  @moduledoc """
  Execute a binary from `node_modules/.bin/`.

      mix npm.exec eslint .
      mix npm.exec tsc --version

  Similar to `npx` but only runs locally installed binaries.
  """

  use Mix.Task

  @impl true
  def run([command | args]) do
    Application.ensure_all_started(:req)

    case Exec.which(command, "node_modules") do
      {:ok, bin_path} ->
        execute(bin_path, args)

      {:error, :not_found} ->
        Mix.shell().error("Binary #{command} not found in node_modules/.bin/")
        Mix.shell().info("Run `mix npm.install` to install packages.")
    end
  end

  def run([]) do
    bin_dir = "node_modules/.bin"

    if File.exists?(bin_dir) do
      case File.ls(bin_dir) do
        {:ok, entries} ->
          Mix.shell().info("Available binaries:")
          Enum.each(Enum.sort(entries), &Mix.shell().info("  #{&1}"))

        {:error, _} ->
          Mix.shell().info("No binaries found.")
      end
    else
      Mix.shell().info("No node_modules/.bin/ directory. Run `mix npm.install` first.")
    end
  end

  defp execute(bin_path, args) do
    {output, status} =
      Runner.run(Path.expand(bin_path), args,
        node_modules_dir: "node_modules",
        cd: File.cwd!()
      )

    if output != "", do: IO.write(output)

    case status do
      0 ->
        :ok

      code ->
        Mix.shell().error("Exited with code #{code}")
        exit({:shutdown, code})
    end
  end
end