Skip to main content

lib/mix/tasks/npm.doctor.ex

defmodule Mix.Tasks.Npm.Doctor do
  alias NPM.Install.Lifecycle

  @shortdoc "Diagnose npm installation issues"

  @moduledoc """
  Run diagnostics on the npm installation.

      mix npm.doctor

  Checks:
  - Package.json validity
  - Lockfile freshness
  - node_modules completeness
  - Platform compatibility
  - Lifecycle scripts
  - Deprecated packages
  """

  use Mix.Task

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

    checks = [
      {"package.json", check_package_json()},
      {"npm.lock", check_lockfile()},
      {"node_modules", check_node_modules()},
      {"lifecycle scripts", check_lifecycle()},
      {"platform compat", check_platform()}
    ]

    Enum.each(checks, fn {label, result} ->
      icon = if result == :ok, do: "✓", else: "✗"
      msg = if result == :ok, do: "ok", else: elem(result, 1)
      Mix.shell().info("  #{icon} #{label}: #{msg}")
    end)
  end

  def run(_) do
    Mix.shell().error("Usage: mix npm.doctor")
  end

  defp check_package_json do
    if File.exists?("package.json"), do: :ok, else: {:warn, "not found"}
  end

  defp check_lockfile do
    if File.exists?("npm.lock"), do: :ok, else: {:warn, "not found — run mix npm.install"}
  end

  defp check_node_modules do
    if File.exists?("node_modules"), do: :ok, else: {:warn, "not found — run mix npm.install"}
  end

  defp check_lifecycle do
    if File.exists?("node_modules") do
      scripts = Lifecycle.detect_all("node_modules")

      if map_size(scripts) > 0 do
        names = Map.keys(scripts) |> Enum.join(", ")
        {:warn, "#{map_size(scripts)} packages have install scripts: #{names}"}
      else
        :ok
      end
    else
      :ok
    end
  end

  defp check_platform do
    Mix.shell().info("    OS: #{NPM.Platform.current_os()}, CPU: #{NPM.Platform.current_cpu()}")
    :ok
  end
end