defmodule NPM.Install.CI do @moduledoc """ Strict frozen install for CI environments. Implements `npm ci` behavior — validates lockfile matches package.json exactly, cleans node_modules, and installs from lockfile only. No lockfile modifications allowed. """ @type validation_error :: :lockfile_missing | :package_json_missing | {:missing_dep, String.t()} | {:extra_dep, String.t()} @doc """ Validates that the lockfile is in sync with package.json. """ @spec validate(String.t()) :: :ok | {:error, [validation_error()]} def validate(project_dir \\ ".") do pkg_path = Path.join(project_dir, "package.json") lock_path = Path.join(project_dir, NPM.Lockfile.default_path()) with {:pkg, {:ok, pkg_content}} <- {:pkg, File.read(pkg_path)}, {:lock, {:ok, _lock_content}} <- {:lock, File.read(lock_path)} do pkg_data = NPM.JSON.decode!(pkg_content) deps = Map.merge(pkg_data["dependencies"] || %{}, pkg_data["devDependencies"] || %{}) validate_deps(deps, lock_path) else {:pkg, _} -> {:error, [:package_json_missing]} {:lock, _} -> {:error, [:lockfile_missing]} end end @doc """ Checks if CI install is possible (all prerequisites met). """ @spec preflight(String.t()) :: :ok | {:error, [String.t()]} def preflight(project_dir \\ ".") do issues = [ check_file(project_dir, "package.json", "package.json is required"), check_file( project_dir, NPM.Lockfile.default_path(), "package-lock.json is required — run mix npm.install first" ) ] |> Enum.reject(&is_nil/1) if issues == [], do: :ok, else: {:error, issues} end @doc """ Determines if node_modules needs to be cleaned before install. """ @spec needs_clean?(String.t()) :: boolean() def needs_clean?(project_dir \\ ".") do nm_path = Path.join(project_dir, "node_modules") File.exists?(nm_path) and File.dir?(nm_path) end @doc """ Formats validation errors for display. """ @spec format_errors([validation_error()]) :: String.t() def format_errors(errors) do Enum.map_join(errors, "\n", &format_error/1) end defp validate_deps(deps, lock_path) do case NPM.Lockfile.read(lock_path) do {:ok, lockfile} -> missing = deps |> Map.keys() |> Enum.reject(&Map.has_key?(lockfile, &1)) |> Enum.map(&{:missing_dep, &1}) if missing == [], do: :ok, else: {:error, missing} {:error, _} -> {:error, [:lockfile_missing]} end end defp check_file(dir, name, message) do if File.exists?(Path.join(dir, name)), do: nil, else: message end defp format_error(:lockfile_missing), do: "package-lock.json is missing" defp format_error(:package_json_missing), do: "package.json is missing" defp format_error({:missing_dep, name}), do: "#{name} is in package.json but not in lockfile" defp format_error({:extra_dep, name}), do: "#{name} is in lockfile but not in package.json" end