Skip to main content

lib/npm.ex

defmodule NPM do
  alias NPM.Install.Linker
  alias NPM.Install.LockfileBuilder
  alias NPM.Install.ScriptInstall
  alias NPM.Package.JSON
  alias NPM.Security.Age
  alias NPM.Security.ExoticDeps

  @moduledoc """
  npm package manager for Elixir.

  Resolves, fetches, and installs npm packages using Mix tasks.
  Dependencies are declared in `package.json` files and locked in `npm.lock`.

  ## Mix tasks

      mix npm.install              # Install all deps from package.json/workspaces
      mix npm.install lodash       # Add latest version
      mix npm.install lodash@^4.0  # Add with specific range
      mix npm.install --frozen     # Fail if lockfile is stale (CI mode)
      mix npm.get                  # Fetch locked deps without resolving
      mix npm.remove lodash        # Remove a package
      mix npm.list                 # List installed packages

  Packages are cached globally in `~/.npm_ex/cache/` and linked into
  `node_modules/` via symlinks (macOS/Linux) or copies (Windows).
  """

  @node_modules "node_modules"

  @doc """
  Install npm packages in a script context, without a Mix project.

  Works like `Mix.install/2` — installs to a content-addressed cache directory,
  is idempotent, and can only be called once per VM (raises on different deps).

      NPM.install(%{"tailwindcss" => "^4.2.2"})

  After installation, use `NPM.install_dir!/0` and `NPM.node_modules_dir!/0`
  to locate the installed packages.

  ## Options

    * `:force` — reinstall even if cached (default: `false`)
  """
  @spec install(map(), keyword()) :: :ok
  def install(deps, opts) when is_map(deps) do
    ScriptInstall.install(deps, opts)
  end

  @doc """
  Returns whether `NPM.install/2` has been called in this VM.
  """
  @spec installed? :: boolean()
  defdelegate installed?, to: ScriptInstall

  @doc """
  Returns the root directory of the current `NPM.install/2` installation.

  Raises if `NPM.install/2` has not been called.
  """
  @spec install_dir! :: String.t()
  defdelegate install_dir!, to: ScriptInstall

  @doc """
  Returns the `node_modules` path of the current `NPM.install/2` installation.

  Raises if `NPM.install/2` has not been called.
  """
  @spec node_modules_dir! :: String.t()
  defdelegate node_modules_dir!, to: ScriptInstall

  @doc """
  Install all dependencies from `package.json` and configured workspaces.

  ## Options

    * `:frozen` - when `true`, fails if `npm.lock` doesn't match
      `package.json` instead of re-resolving. Useful for CI.
    * `:production` - when `true`, skips `devDependencies`.
  """
  @spec install(keyword()) :: :ok | {:error, term()}
  def install(opts \\ []) when is_list(opts) do
    with {:ok, project} <- NPM.Workspace.read_all(),
         {:ok, deps} <- NPM.Workspace.install_dependencies(project, opts) do
      do_install(deps, Keyword.put(opts, :local_links, project.local_links))
    end
  end

  @doc """
  Add a package to `package.json` and install all dependencies.

  ## Options

    * `:dev` - when `true`, adds to `devDependencies` instead of `dependencies`
  """
  @spec add(String.t(), String.t(), keyword()) :: :ok | {:error, term()}
  def add(name, range \\ "latest", opts \\ []) do
    range = if range == "latest", do: resolve_latest(name, opts), else: range

    with range_str when is_binary(range_str) <- range,
         :ok <- JSON.add_dep(name, range_str, "package.json", opts) do
      install([])
    end
  end

  @doc """
  Remove a package from `package.json` and re-install.
  """
  @spec remove(String.t()) :: :ok | {:error, term()}
  def remove(name) do
    with :ok <- JSON.remove_dep(name) do
      install([])
    end
  end

  @doc """
  Update all packages to the latest versions matching their ranges.

  Clears the resolver cache and re-resolves from scratch.
  """
  @spec update :: :ok | {:error, term()}
  def update do
    with {:ok, project} <- NPM.Workspace.read_all(),
         {:ok, deps} <- NPM.Workspace.install_dependencies(project) do
      do_install(deps, local_links: project.local_links)
    end
  end

  @doc """
  Update a specific package to the latest version matching its range.

  Only re-resolves the named package; other locked versions are preserved.
  """
  @spec update(String.t()) :: :ok | {:error, term()}
  def update(name) do
    with {:ok, project} <- NPM.Workspace.read_all(),
         {:ok, all_deps} <- NPM.Workspace.install_dependencies(project),
         {:ok, lockfile} <- NPM.Lockfile.read() do
      if Map.has_key?(all_deps, name) do
        updated_lock = Map.delete(lockfile, name)
        NPM.Lockfile.write(updated_lock)
        do_install(all_deps, local_links: project.local_links)
      else
        Mix.shell().error("Package #{name} not found in package.json.")
        {:error, {:not_found, name}}
      end
    end
  end

  @doc """
  Fetch locked dependencies without re-resolving.

  Reads `npm.lock` and populates the global cache and `node_modules/`
  for any missing packages.
  """
  @spec get :: :ok | {:error, term()}
  def get do
    case NPM.Lockfile.read() do
      {:ok, lockfile} when lockfile == %{} ->
        Mix.shell().info("No npm.lock found, run `mix npm.install` first.")
        :ok

      {:ok, lockfile} ->
        with {:ok, project} <- NPM.Workspace.read_all() do
          link_from_lockfile(lockfile, project.local_links)
        end

      error ->
        error
    end
  end

  @doc """
  List installed packages with versions.

  Returns a list of `{name, version}` tuples.
  """
  @spec list :: {:ok, [{String.t(), String.t()}]} | {:error, term()}
  def list do
    case NPM.Lockfile.read() do
      {:ok, lockfile} when lockfile == %{} ->
        {:ok, []}

      {:ok, lockfile} ->
        packages =
          lockfile
          |> Enum.map(fn {name, entry} -> {name, entry.version} end)
          |> Enum.sort_by(&elem(&1, 0))

        {:ok, packages}

      error ->
        error
    end
  end

  # --- Private ---

  defp do_install(deps, opts) when map_size(deps) == 0 do
    local_links = Keyword.get(opts, :local_links, %{})

    if local_links == %{} do
      Mix.shell().info("No npm dependencies found in package.json.")
      :ok
    else
      with :ok <- Linker.link(%{}, @node_modules),
           :ok <- Linker.link_local_packages(local_links, @node_modules) do
        Mix.shell().info(
          "Linked #{map_size(local_links)} local npm package#{plural(local_links)}."
        )

        :ok
      end
    end
  end

  defp do_install(deps, opts) do
    if opts[:frozen] do
      frozen_install(deps, Keyword.get(opts, :local_links, %{}))
    else
      full_install(deps, Keyword.get(opts, :local_links, %{}))
    end
  end

  defp frozen_install(deps, local_links) do
    case NPM.Lockfile.read() do
      {:ok, lockfile} when lockfile == %{} ->
        Mix.shell().error("npm.lock not found. Run `mix npm.install` first.")
        {:error, :no_lockfile}

      {:ok, lockfile} ->
        if lockfile_matches?(lockfile, deps) and lockfile_policy_current?() do
          link_from_lockfile(lockfile, local_links)
        else
          Mix.shell().error(
            "npm.lock is out of date with package.json or current security policy.\n" <>
              "Run `mix npm.install` to update the lockfile."
          )

          {:error, :frozen_lockfile}
        end

      error ->
        error
    end
  end

  defp lockfile_policy_current? do
    case NPM.Lockfile.read_policy() do
      {:ok, nil} -> true
      {:ok, policy} -> NPM.Lockfile.policy_matches?(policy)
      _ -> false
    end
  end

  defp lockfile_matches?(lockfile, deps) do
    Enum.all?(deps, fn {name, _range} ->
      Map.has_key?(lockfile, name)
    end) and
      Enum.all?(lockfile, fn {name, _entry} ->
        Map.has_key?(deps, name) or
          Enum.any?(lockfile, fn {_, e} ->
            Map.has_key?(e.dependencies, name) or
              Map.has_key?(Map.get(e, :optional_dependencies, %{}), name)
          end)
      end)
  end

  defp full_install(deps, local_links) do
    validate_direct_exotic_deps!(deps)
    {:ok, old_lockfile} = NPM.Lockfile.read()

    cond do
      old_lockfile == %{} ->
        resolve_and_install(deps, old_lockfile, local_links)

      lockfile_matches?(old_lockfile, deps) and lockfile_policy_current?() and
          node_modules_intact?(old_lockfile, local_links) ->
        Mix.shell().info("Already up to date.")
        :ok

      lockfile_matches?(old_lockfile, deps) and lockfile_policy_current?() ->
        Mix.shell().info("Installing from current npm.lock.")
        link_from_lockfile(old_lockfile, local_links)

      true ->
        resolve_and_install(deps, old_lockfile, local_links)
    end
  end

  defp validate_direct_exotic_deps!(deps) do
    Enum.each(deps, fn {name, spec} -> ExoticDeps.validate_direct!(name, spec) end)
  end

  defp node_modules_intact?(lockfile, local_links) do
    skipped = Linker.skipped_packages(lockfile)
    linkable_lockfile = linkable_lockfile(lockfile, skipped)

    lockfile_intact? =
      Enum.all?(linkable_lockfile, fn {name, _entry} ->
        Path.join([@node_modules, name, "package.json"]) |> File.exists?()
      end)

    local_links_intact? =
      Enum.all?(local_links, fn {name, _path} ->
        Path.join([@node_modules, name, "package.json"]) |> File.exists?()
      end)

    lockfile_intact? and local_links_intact?
  end

  defp resolve_and_install(deps, old_lockfile, local_links) do
    {:ok, overrides} = JSON.read_overrides()

    {resolve_us, result} =
      :timer.tc(fn ->
        NPM.Resolver.clear_cache()
        NPM.Resolver.resolve(deps, overrides: overrides)
      end)

    case result do
      {:ok, resolved} ->
        {nested_info, flat} = Map.pop(resolved, :nested, %{})
        pkg_count = map_size(flat)
        Mix.shell().info("Resolved #{pkg_count} packages in #{format_ms(resolve_us)}")

        if nested_info != %{} do
          Mix.shell().info("  (#{map_size(nested_info)} packages with nested versions)")
        end

        lockfile = build_lockfile(flat)
        lockfile = expand_all_optional_deps(lockfile)
        print_lockfile_diff(old_lockfile, lockfile)
        NPM.Lockfile.write(lockfile)
        link_and_nest(lockfile, nested_info, flat, local_links)

      {:error, message} ->
        Mix.shell().error("Resolution failed:\n#{message}")
        {:error, :resolution_failed}
    end
  end

  defp link_and_nest(lockfile, nested_info, flat, local_links) do
    with :ok <- link_from_lockfile(lockfile, local_links) do
      if nested_info != %{}, do: Linker.link_nested(nested_info, flat, @node_modules)
      :ok
    end
  end

  defp link_from_lockfile(lockfile, local_links) do
    skipped = Linker.skipped_packages(lockfile)
    linkable_lockfile = linkable_lockfile(lockfile, skipped)
    total = length(linkable_lockfile)

    cached =
      Enum.count(linkable_lockfile, fn {name, entry} -> NPM.Cache.cached?(name, entry.version) end)

    to_fetch = total - cached

    if to_fetch > 0 do
      Mix.shell().info("Fetching #{to_fetch} package#{if to_fetch != 1, do: "s", else: ""}...")
    end

    {link_us, result} =
      :timer.tc(fn ->
        with :ok <- Linker.link(lockfile, @node_modules, :symlink, skipped) do
          NPM.Install.Linker.link_local_packages(local_links, @node_modules)
        end
      end)

    case result do
      :ok ->
        ms = div(link_us, 1000)
        Mix.shell().info(NPM.DepsOutput.format_summary(total, ms))
        warn_ignored_install_scripts(lockfile)
        Mix.shell().info(NPM.DepsOutput.format_lockfile(lockfile))
        :ok

      error ->
        error
    end
  end

  defp linkable_lockfile(lockfile, skipped) do
    Enum.reject(lockfile, fn {name, _entry} -> MapSet.member?(skipped, name) end)
  end

  defp plural(map) do
    if map_size(map) == 1, do: "", else: "s"
  end

  defp build_lockfile(resolved) do
    lockfile = LockfileBuilder.build(resolved, &warn_age_heuristics/3)

    warn_unmet_peers(resolved)
    lockfile
  end

  defp expand_all_optional_deps(lockfile) do
    Enum.reduce(lockfile, lockfile, fn {_name, entry}, acc ->
      entry
      |> Map.get(:optional_dependencies, %{})
      |> Enum.reduce(acc, &maybe_add_optional_dep/2)
    end)
  end

  defp maybe_add_optional_dep({name, _range}, acc) when is_map_key(acc, name), do: acc

  defp maybe_add_optional_dep({name, range}, acc) do
    case resolve_version(name, range) do
      {:ok, version_str, info} ->
        warn_age_heuristics(name, version_str, info)

        Map.put(acc, name, %{
          version: version_str,
          integrity: info.dist.integrity,
          tarball: info.dist.tarball,
          dependencies: info.dependencies,
          optional_dependencies: Map.get(info, :optional_dependencies, %{}),
          has_install_script: Map.get(info, :has_install_script, false)
        })

      :error ->
        acc
    end
  end

  defp resolve_version(name, range) do
    case NPM.Registry.get_packument(name) do
      {:ok, packument} ->
        packument.versions
        |> Enum.filter(fn {v, _} ->
          NPMSemver.matches?(v, range) and match?({:ok, _}, Version.parse(v))
        end)
        |> Enum.sort_by(fn {v, _} -> Version.parse!(v) end, {:desc, Version})
        |> case do
          [{version_str, info} | _] -> {:ok, version_str, info}
          [] -> :error
        end

      _ ->
        :error
    end
  end

  defp warn_age_heuristics(name, version, info) do
    info
    |> Age.warnings()
    |> Enum.each(fn warning ->
      Mix.shell().info("Warning: #{Age.format(name, version, warning)}")
    end)
  end

  defp warn_unmet_peers(resolved) do
    Enum.each(resolved, fn {name, version_str} ->
      case NPM.Registry.get_packument(name) do
        {:ok, packument} ->
          info = Map.fetch!(packument.versions, version_str)
          check_peers(name, info, resolved)
          check_deprecated(name, version_str, info)

        _ ->
          :ok
      end
    end)
  end

  defp warn_ignored_install_scripts(lockfile) do
    packages =
      lockfile
      |> Enum.filter(fn {_name, entry} -> Map.get(entry, :has_install_script, false) end)
      |> Enum.map_join(", ", fn {name, entry} -> "#{name}@#{entry.version}" end)

    if packages != "" do
      Mix.shell().info(
        "npm WARN ignored lifecycle scripts for #{packages}. " <>
          "npm_ex never runs preinstall/install/postinstall hooks automatically."
      )
    end
  end

  defp check_deprecated(name, version, info) do
    case Map.get(info, :deprecated) do
      nil ->
        :ok

      false ->
        :ok

      msg when is_binary(msg) ->
        Mix.shell().info("npm WARN #{name}@#{version} is deprecated: #{msg}")

      _ ->
        :ok
    end
  end

  defp check_peers(name, info, resolved) do
    peers = Map.get(info, :peer_dependencies, %{})
    meta = Map.get(info, :peer_dependencies_meta, %{})

    Enum.each(peers, fn {peer_name, peer_range} ->
      optional? = get_in(meta, [peer_name, "optional"]) == true

      case Map.get(resolved, peer_name) do
        nil when not optional? ->
          Mix.shell().info(
            "npm WARN #{name} requires peer #{peer_name}@#{peer_range} — not installed"
          )

        _ ->
          :ok
      end
    end)
  end

  defp resolve_latest(name, opts) do
    case NPM.Registry.get_packument(name) do
      {:ok, packument} -> latest_stable_range(packument, opts)
      {:error, reason} -> {:error, reason}
    end
  end

  defp latest_stable_range(packument, opts) do
    packument.versions
    |> Map.keys()
    |> Enum.flat_map(&parse_stable_version/1)
    |> Enum.sort(Version)
    |> List.last()
    |> case do
      nil -> {:error, :no_versions}
      v -> if opts[:exact], do: "#{v}", else: "^#{v}"
    end
  end

  defp parse_stable_version(v) do
    case Version.parse(v) do
      {:ok, ver} -> if ver.pre == [], do: [ver], else: []
      :error -> []
    end
  end

  defp print_lockfile_diff(old, new) when old == %{}, do: new
  defp print_lockfile_diff(old, new) when old == new, do: :ok

  defp print_lockfile_diff(old, new) do
    diff = NPM.DepsOutput.format_diff(old, new)
    if diff != "", do: Mix.shell().info(diff)
  end

  defp format_ms(microseconds) do
    ms = div(microseconds, 1000)
    if ms < 1000, do: "#{ms}ms", else: "#{Float.round(ms / 1000, 1)}s"
  end
end