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