defmodule NPM.Config.Npmrc do
@moduledoc """
Parses and analyzes .npmrc configuration files.
Supports project, user, and global .npmrc locations.
"""
@doc """
Finds all .npmrc files for a project.
"""
@spec locate(String.t()) :: [String.t()]
def locate(project_dir \\ ".") do
candidates = [
Path.join(project_dir, ".npmrc"),
Path.expand("~/.npmrc"),
"/etc/npmrc"
]
Enum.filter(candidates, &File.exists?/1)
end
@doc """
Parses an .npmrc file into a map.
"""
@spec parse(String.t()) :: map()
def parse(content) when is_binary(content) do
content
|> String.split("\n")
|> Enum.reject(&blank_or_comment?/1)
|> Enum.flat_map(&parse_line/1)
|> Map.new()
end
@doc """
Reads and parses an .npmrc file from disk.
"""
@spec read(String.t()) :: {:ok, map()} | {:error, :not_found}
def read(path) do
case File.read(path) do
{:ok, content} -> {:ok, parse(content)}
_ -> {:error, :not_found}
end
end
@doc """
Merges multiple .npmrc configs (later overrides earlier).
"""
@spec merge([map()]) :: map()
def merge(configs), do: Enum.reduce(configs, %{}, &Map.merge(&2, &1))
@doc """
Checks if auth token is configured.
"""
@spec has_auth?(map()) :: boolean()
def has_auth?(config) do
Enum.any?(config, fn {key, _} ->
String.contains?(key, "_authToken") or key == "_auth"
end)
end
@doc """
Extracts scoped registry configurations.
"""
@spec scoped_registries(map()) :: [{String.t(), String.t()}]
def scoped_registries(config) do
config
|> Enum.filter(fn {key, _} -> String.match?(key, ~r/^@.+:registry$/) end)
|> Enum.map(fn {key, url} ->
scope = key |> String.replace(":registry", "") |> String.trim_leading("@")
{scope, url}
end)
|> Enum.sort_by(&elem(&1, 0))
end
@doc """
Formats .npmrc config for display.
"""
@spec format(map()) :: String.t()
def format(config) when map_size(config) == 0, do: "Empty .npmrc"
def format(config) do
config
|> Enum.sort_by(&elem(&1, 0))
|> Enum.map_join("\n", fn {k, v} ->
if String.contains?(k, "Token") or k == "_auth",
do: "#{k} = [REDACTED]",
else: "#{k} = #{v}"
end)
end
defp blank_or_comment?(line) do
trimmed = String.trim(line)
trimmed == "" or String.starts_with?(trimmed, "#") or String.starts_with?(trimmed, ";")
end
defp parse_line(line) do
case String.split(String.trim(line), "=", parts: 2) do
[key, value] -> [{String.trim(key), String.trim(value)}]
_ -> []
end
end
end