defmodule NPM.Install.Link do @moduledoc """ Manages local package linking via symlinks. Implements `npm link` functionality — creates symlinks in node_modules pointing to local package directories for development. """ @type link_info :: %{ name: String.t(), target: String.t(), link_path: String.t() } @doc """ Links a local package into node_modules. """ @spec link(String.t(), String.t()) :: {:ok, link_info()} | {:error, term()} def link(package_dir, node_modules_dir \\ "node_modules") do with {:ok, name} <- read_package_name(package_dir) do link_path = resolve_link_path(node_modules_dir, name) target = Path.expand(package_dir) File.mkdir_p!(Path.dirname(link_path)) File.rm_rf!(link_path) case File.ln_s(target, link_path) do :ok -> {:ok, %{name: name, target: target, link_path: link_path}} {:error, reason} -> {:error, reason} end end end @doc """ Unlinks a package from node_modules. """ @spec unlink(String.t(), String.t()) :: :ok | {:error, term()} def unlink(name, node_modules_dir \\ "node_modules") do link_path = resolve_link_path(node_modules_dir, name) case File.read_link(link_path) do {:ok, _target} -> File.rm(link_path) {:error, _} -> {:error, :not_linked} end end @doc """ Lists all linked packages in node_modules. """ @spec list(String.t()) :: [link_info()] def list(node_modules_dir \\ "node_modules") do case File.ls(node_modules_dir) do {:ok, entries} -> entries |> Enum.flat_map(&find_links(node_modules_dir, &1)) |> Enum.sort_by(& &1.name) _ -> [] end end @doc """ Checks if a package is linked (symlinked, not installed normally). """ @spec linked?(String.t(), String.t()) :: boolean() def linked?(name, node_modules_dir \\ "node_modules") do link_path = resolve_link_path(node_modules_dir, name) case File.read_link(link_path) do {:ok, _} -> true _ -> false end end defp read_package_name(dir) do pkg_path = Path.join(dir, "package.json") case File.read(pkg_path) do {:ok, content} -> data = NPM.JSON.decode!(content) {:ok, data["name"] || Path.basename(dir)} {:error, reason} -> {:error, reason} end end defp resolve_link_path(nm_dir, name) do Path.join(nm_dir, name) end defp find_links(nm_dir, entry) do if String.starts_with?(entry, "@") do find_scoped_links(nm_dir, entry) else check_link(nm_dir, entry, entry) end end defp find_scoped_links(nm_dir, scope) do scope_dir = Path.join(nm_dir, scope) case File.ls(scope_dir) do {:ok, subs} -> Enum.flat_map(subs, &check_link(scope_dir, &1, "#{scope}/#{&1}")) _ -> [] end end defp check_link(parent, entry, name) do path = Path.join(parent, entry) case File.read_link(path) do {:ok, target} -> [%{name: name, target: target, link_path: path}] _ -> [] end end end