defmodule NPM.Lockfile.Shrinkwrap do
@moduledoc """
Implements npm shrinkwrap lockfile freezing.
Creates a `npm-shrinkwrap.json` that locks the entire dependency tree,
including transitive dependencies. Unlike `package-lock.json`, shrinkwrap
files are published with the package.
"""
@shrinkwrap_file "npm-shrinkwrap.json"
@lockfile "package-lock.json"
@doc """
Creates a shrinkwrap file from the current lockfile.
"""
@spec create(String.t()) :: :ok | {:error, term()}
def create(project_dir \\ ".") do
lock_path = Path.join(project_dir, @lockfile)
shrink_path = Path.join(project_dir, @shrinkwrap_file)
case File.read(lock_path) do
{:ok, content} ->
data = NPM.JSON.decode!(content)
shrinkwrap = Map.put(data, "lockfileVersion", Map.get(data, "lockfileVersion", 3))
File.write(shrink_path, :json.encode(shrinkwrap))
{:error, reason} ->
{:error, {:no_lockfile, reason}}
end
end
@doc """
Checks if a shrinkwrap file exists.
"""
@spec exists?(String.t()) :: boolean()
def exists?(project_dir \\ ".") do
project_dir |> Path.join(@shrinkwrap_file) |> File.exists?()
end
@doc """
Reads and parses the shrinkwrap file.
"""
@spec read(String.t()) :: {:ok, map()} | {:error, term()}
def read(project_dir \\ ".") do
path = Path.join(project_dir, @shrinkwrap_file)
case File.read(path) do
{:ok, content} -> {:ok, NPM.JSON.decode!(content)}
error -> error
end
end
@doc """
Verifies that installed packages match the shrinkwrap exactly.
Returns a list of mismatches.
"""
@spec verify(map(), map()) :: [mismatch()]
def verify(shrinkwrap_deps, installed) do
Enum.flat_map(shrinkwrap_deps, fn {name, expected_version} ->
case Map.get(installed, name) do
nil ->
[%{name: name, expected: expected_version, actual: nil, type: :missing}]
%{version: actual} when actual != expected_version ->
[%{name: name, expected: expected_version, actual: actual, type: :version_mismatch}]
_ ->
[]
end
end)
|> Enum.sort_by(& &1.name)
end
@typep mismatch :: %{
name: String.t(),
expected: String.t(),
actual: String.t() | nil,
type: :missing | :version_mismatch
}
@doc """
Checks if the shrinkwrap is outdated compared to the lockfile.
"""
@spec outdated?(String.t()) :: boolean()
def outdated?(project_dir \\ ".") do
lock_path = Path.join(project_dir, @lockfile)
shrink_path = Path.join(project_dir, @shrinkwrap_file)
with {:ok, lock_content} <- File.read(lock_path),
{:ok, shrink_content} <- File.read(shrink_path) do
lock_data = NPM.JSON.decode!(lock_content)
shrink_data = NPM.JSON.decode!(shrink_content)
lock_packages = lock_data["packages"] || lock_data["dependencies"] || %{}
shrink_packages = shrink_data["packages"] || shrink_data["dependencies"] || %{}
lock_packages != shrink_packages
else
_ -> true
end
end
@doc """
Removes the shrinkwrap file.
"""
@spec remove(String.t()) :: :ok | {:error, term()}
def remove(project_dir \\ ".") do
path = Path.join(project_dir, @shrinkwrap_file)
case File.rm(path) do
:ok -> :ok
{:error, :enoent} -> :ok
error -> error
end
end
end