Skip to main content

lib/mix/tasks/npm.run.ex

defmodule Mix.Tasks.Npm.Run do
  alias NPM.Package.JSON

  @shortdoc "Run an npm script"

  @moduledoc """
  Run a script defined in `package.json`.

      mix npm.run build
      mix npm.run test
      mix npm.run              # List available scripts

  Scripts are executed with `node_modules/.bin` prepended to `PATH`.
  """

  use Mix.Task

  @impl true
  def run([]) do
    Application.ensure_all_started(:req)
    list_scripts()
  end

  def run([script_name | extra_args]) do
    Application.ensure_all_started(:req)
    run_script(script_name, extra_args)
  end

  defp list_scripts do
    case JSON.read_scripts() do
      {:ok, scripts} when scripts == %{} ->
        Mix.shell().info("No scripts found in package.json.")

      {:ok, scripts} ->
        Mix.shell().info("Available scripts:")

        Enum.each(Enum.sort(scripts), fn {name, command} ->
          Mix.shell().info("  #{name}: #{command}")
        end)

      {:error, reason} ->
        Mix.shell().error("Failed to read package.json: #{inspect(reason)}")
    end
  end

  defp run_script(name, extra_args) do
    case JSON.read_scripts() do
      {:ok, scripts} ->
        case Map.fetch(scripts, name) do
          {:ok, command} ->
            execute(command, extra_args)

          :error ->
            Mix.shell().error("Script \"#{name}\" not found in package.json.")
            Mix.shell().info("Available: #{scripts |> Map.keys() |> Enum.join(", ")}")
        end

      {:error, reason} ->
        Mix.shell().error("Failed to read package.json: #{inspect(reason)}")
    end
  end

  defp execute(command, extra_args) do
    full_command =
      case extra_args do
        [] -> command
        args -> command <> " " <> Enum.join(args, " ")
      end

    bin_path = Path.join(File.cwd!(), "node_modules/.bin")
    current_path = System.get_env("PATH", "")
    env = [{"PATH", "#{bin_path}:#{current_path}"}]

    port =
      Port.open({:spawn, full_command}, [
        :binary,
        :exit_status,
        :stderr_to_stdout,
        env: Enum.map(env, fn {k, v} -> {~c"#{k}", ~c"#{v}"} end)
      ])

    stream_port(port)
  end

  defp stream_port(port) do
    receive do
      {^port, {:data, data}} ->
        IO.write(data)
        stream_port(port)

      {^port, {:exit_status, 0}} ->
        :ok

      {^port, {:exit_status, code}} ->
        Mix.shell().error("Script exited with code #{code}")
        exit({:shutdown, code})
    end
  end
end