defmodule NPM.Install.Lifecycle do
@moduledoc """
Detect and manage npm lifecycle scripts.
npm packages can define `preinstall`, `install`, `postinstall`,
`prepare`, and other scripts in their `package.json`.
By default, npm_ex does NOT run lifecycle scripts for security.
This module provides detection and opt-in execution.
"""
@install_hooks ["preinstall", "install", "postinstall", "prepare"]
@doc """
Detect lifecycle scripts in a package's `package.json`.
Returns a list of `{hook_name, command}` tuples for install-related hooks.
"""
@spec detect(String.t()) :: [{String.t(), String.t()}]
def detect(package_json_path) do
case File.read(package_json_path) do
{:ok, content} ->
content |> NPM.JSON.decode!() |> Map.get("scripts", %{}) |> extract_hooks()
{:error, _} ->
[]
end
end
defp extract_hooks(scripts) do
@install_hooks
|> Enum.filter(&Map.has_key?(scripts, &1))
|> Enum.map(&{&1, scripts[&1]})
end
@doc """
Detect lifecycle scripts across all packages in `node_modules`.
Returns a map of `package_name => [{hook, command}]` for packages
that have install-related scripts.
"""
@spec detect_all(String.t()) :: %{String.t() => [{String.t(), String.t()}]}
def detect_all(node_modules_dir) do
node_modules_dir
|> list_packages()
|> Enum.flat_map(fn name ->
pkg_json = Path.join([node_modules_dir, name, "package.json"])
hooks = detect(pkg_json)
if hooks == [], do: [], else: [{name, hooks}]
end)
|> Map.new()
end
@doc "List the install hook names."
@spec hook_names :: [String.t()]
def hook_names, do: @install_hooks
defp list_packages(dir) do
dir
|> list_dir()
|> Enum.reject(&String.starts_with?(&1, "."))
|> Enum.flat_map(&expand_entry(dir, &1))
end
defp expand_entry(dir, "@" <> _ = scope) do
dir |> Path.join(scope) |> list_dir() |> Enum.map(&"#{scope}/#{&1}")
end
defp expand_entry(_dir, entry), do: [entry]
defp list_dir(path) do
case File.ls(path) do
{:ok, entries} -> entries
{:error, _} -> []
end
end
end