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 lockfile_covers_dependencies?(lockfile, deps) and lockfile_dependency_records_complete?(lockfile) end defp lockfile_covers_dependencies?(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 lockfile_dependency_records_complete?(lockfile) do package_names = MapSet.new(Map.keys(lockfile)) lockfile |> Enum.flat_map(fn {_name, entry} -> lockfile_entry_dependency_names(entry) end) |> Enum.all?(&MapSet.member?(package_names, &1)) end defp lockfile_entry_dependency_names(entry) do Map.keys(entry.dependencies) ++ Map.keys(Map.get(entry, :optional_dependencies, %{})) 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