Skip to main content

lib/npm/install/script_install.ex

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

  @moduledoc """
  Implements `NPM.install/2` for script-style usage outside Mix projects.

  The public `NPM.install/2` API works like `Mix.install/2`: dependencies are
  resolved once per BEAM VM, installed into a content-addressed directory, and
  exposed through `NPM.install_dir!/0` and `NPM.node_modules_dir!/0`.

  Installs are cached by dependency map and guarded with `:persistent_term` so a
  running VM cannot accidentally switch to a different npm dependency set after
  code has already loaded packages from the first install.
  """

  @state_key :npm_script_installed

  @spec install(map(), keyword()) :: :ok
  def install(deps, opts) when is_map(deps) do
    Application.ensure_all_started(:req)

    unless Keyword.get(opts, :__skip_project_check__, false) do
      if Mix.Project.get() do
        Mix.raise(
          "NPM.install/2 cannot be used inside a Mix project. Use mix npm.install instead."
        )
      end
    end

    id = cache_id(deps)
    force? = Keyword.get(opts, :force, false)

    case :persistent_term.get(@state_key, nil) do
      nil ->
        do_install(deps, id, force?)

      {^id, _dir} when not force? ->
        :ok

      {^id, _dir} ->
        do_install(deps, id, true)

      _ ->
        Mix.raise("NPM.install/2 can only be called with the same dependencies in the given VM")
    end
  end

  defp do_install(deps, id, force?) do
    validate_direct_exotic_deps!(deps)
    dir = install_dir(id)

    if force?, do: File.rm_rf!(dir)

    nm_dir = Path.join(dir, "node_modules")
    lockfile_path = Path.join(dir, "npm.lock")

    if not force? and File.exists?(lockfile_path) and lockfile_policy_current?(lockfile_path) and
         node_modules_intact?(lockfile_path, nm_dir) do
      :persistent_term.put(@state_key, {id, dir})
      :ok
    else
      File.mkdir_p!(dir)
      resolve_and_link(deps, dir, nm_dir, lockfile_path)
      :persistent_term.put(@state_key, {id, dir})
      :ok
    end
  end

  defp resolve_and_link(deps, _dir, nm_dir, lockfile_path) do
    NPM.Resolver.clear_cache()

    case NPM.Resolver.resolve(deps) do
      {:ok, resolved} ->
        {_nested, flat} = Map.pop(resolved, :nested, %{})
        lockfile = build_lockfile(flat)
        NPM.Lockfile.write(lockfile, lockfile_path)
        Linker.link(lockfile, nm_dir)

      {:error, message} ->
        Mix.raise("NPM.install/2 resolution failed:\n#{message}")
    end
  end

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

  defp lockfile_policy_current?(lockfile_path) do
    case NPM.Lockfile.read_policy(lockfile_path) do
      {:ok, policy} -> NPM.Lockfile.policy_matches?(policy)
      _ -> false
    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 node_modules_intact?(lockfile_path, nm_dir) do
    case NPM.Lockfile.read(lockfile_path) do
      {:ok, lockfile} when lockfile != %{} ->
        Enum.all?(lockfile, fn {name, _} ->
          File.exists?(Path.join([nm_dir, name, "package.json"]))
        end)

      _ ->
        false
    end
  end

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

  defp cache_id(deps) do
    deps
    |> :erlang.term_to_binary()
    |> :erlang.md5()
    |> Base.encode16(case: :lower)
  end

  defp install_dir(id) do
    NPM.Config.install_dir(id)
  end

  @spec installed? :: boolean()
  def installed? do
    :persistent_term.get(@state_key, nil) != nil
  end

  @spec install_dir! :: String.t()
  def install_dir! do
    case :persistent_term.get(@state_key, nil) do
      {_id, dir} -> dir
      nil -> Mix.raise("NPM.install/2 has not been called")
    end
  end

  @spec node_modules_dir! :: String.t()
  def node_modules_dir! do
    Path.join(install_dir!(), "node_modules")
  end
end