Skip to main content

lib/npm/node/bin_resolver.ex

defmodule NPM.Node.BinResolver do
  @moduledoc """
  Resolve executable binaries from `node_modules/.bin/`.

  Provides lookup and listing of available npm binaries,
  matching the behavior of `npx` and `npm exec`.
  """

  @doc """
  List all available binaries in `node_modules/.bin/`.

  Returns a sorted list of `{command_name, target_path}` tuples.
  """
  @spec list(String.t()) :: [{String.t(), String.t()}]
  def list(node_modules_dir \\ "node_modules") do
    bin_dir = Path.join(node_modules_dir, ".bin")

    case File.ls(bin_dir) do
      {:ok, entries} ->
        entries
        |> Enum.map(fn name -> {name, resolve_target(bin_dir, name)} end)
        |> Enum.sort()

      {:error, _} ->
        []
    end
  end

  @doc """
  Find the path to a specific binary command.

  Returns `{:ok, path}` if found, `:error` if not.
  """
  @spec find(String.t(), String.t()) :: {:ok, String.t()} | :error
  def find(command, node_modules_dir \\ "node_modules") do
    path = Path.join([node_modules_dir, ".bin", command])

    if File.exists?(path) do
      {:ok, resolve_target(Path.join(node_modules_dir, ".bin"), command)}
    else
      :error
    end
  end

  @doc """
  Check if a binary command is available.
  """
  @spec available?(String.t(), String.t()) :: boolean()
  def available?(command, node_modules_dir \\ "node_modules") do
    Path.join([node_modules_dir, ".bin", command]) |> File.exists?()
  end

  defp resolve_target(bin_dir, name) do
    link = Path.join(bin_dir, name)

    case File.read_link(link) do
      {:ok, target} -> Path.expand(target, bin_dir)
      {:error, _} -> link
    end
  end
end