Skip to main content

lib/apero/pkg.ex

defmodule Apero.Pkg do
  @moduledoc """
  Cross-platform package manager abstraction.

  Detects the available system package manager and provides a consistent
  interface for installing, updating, querying, searching, removing, and
  getting info about packages.

  Supported managers: apt, apt-get, dnf, yum, pacman, brew, apk, zypper,
  choco (Chocolatey), winget, pkg (FreeBSD), emerge (Gentoo).
  """

  @type package_manager ::
          :apt
          | :apt_get
          | :dnf
          | :yum
          | :pacman
          | :brew
          | :apk
          | :zypper
          | :choco
          | :winget
          | :pkg
          | :emerge
          | :unknown

  @managers [
    {:apt, "apt"},
    {:apt_get, "apt-get"},
    {:dnf, "dnf"},
    {:yum, "yum"},
    {:pacman, "pacman"},
    {:brew, "brew"},
    {:apk, "apk"},
    {:zypper, "zypper"},
    {:choco, "choco"},
    {:winget, "winget"},
    {:pkg, "pkg"},
    {:emerge, "emerge"}
  ]

  @doc "Detects the first available package manager on the system."
  @spec detect() :: package_manager()
  def detect do
    Enum.find_value(@managers, :unknown, fn {pm, cmd} ->
      if System.find_executable(cmd), do: pm, else: nil
    end)
  end

  @doc """
  Installs a package using the detected package manager.
  Runs non-interactively.

  ## Options
    * `:manager` — override detected package manager
  """
  @spec install(binary(), keyword()) :: {:ok, binary()} | {:error, binary()}
  def install(package, opts \\ []) do
    pm = Keyword.get(opts, :manager, detect())
    {cmd, args} = install_cmd(pm, package)
    run_cmd(cmd, args)
  end

  @doc "Updates the package index/cache."
  @spec update(keyword()) :: {:ok, binary()} | {:error, binary()}
  def update(opts \\ []) do
    pm = Keyword.get(opts, :manager, detect())
    {cmd, args} = update_cmd(pm)
    run_cmd(cmd, args)
  end

  @doc "Returns `true` if the package is installed."
  @spec installed?(binary(), keyword()) :: boolean()
  def installed?(package, opts \\ []) do
    pm = Keyword.get(opts, :manager, detect())

    case check_installed_cmd(pm, package) do
      {cmd, args} ->
        try do
          match?({_out, 0}, System.cmd(cmd, args, stderr_to_stdout: true))
        rescue
          _ -> false
        end

      :unsupported ->
        false
    end
  end

  @doc """
  Removes/uninstalls a package.

  ## Options
    * `:manager` — override detected package manager
    * `:purge` — remove config files too (apt only)
  """
  @spec remove(binary(), keyword()) :: {:ok, binary()} | {:error, binary()}
  def remove(package, opts \\ []) do
    pm = Keyword.get(opts, :manager, detect())
    purge = Keyword.get(opts, :purge, false)
    {cmd, args} = remove_cmd(pm, package, purge)
    run_cmd(cmd, args)
  end

  @doc """
  Searches for a package by name or keyword.

  ## Options
    * `:manager` — override detected package manager
    * `:exact` — exact name match (apt-cache show vs apt-cache search)
  """
  @spec search(binary(), keyword()) :: {:ok, binary()} | {:error, binary()}
  def search(query, opts \\ []) do
    pm = Keyword.get(opts, :manager, detect())
    {cmd, args} = search_cmd(pm, query)
    run_cmd(cmd, args)
  end

  @doc """
  Gets detailed info about an installed package.

  ## Options
    * `:manager` — override detected package manager
  """
  @spec info(binary(), keyword()) :: {:ok, binary()} | {:error, binary()}
  def info(package, opts \\ []) do
    pm = Keyword.get(opts, :manager, detect())

    case info_cmd(pm, package) do
      {cmd, args} -> run_cmd(cmd, args)
      :unsupported -> {:error, "info not supported for #{pm}"}
    end
  end

  # ── Install commands ─────────────────────────────────────────

  defp install_cmd(:apt, pkg), do: {"apt", ["install", "-y", pkg]}
  defp install_cmd(:apt_get, pkg), do: {"apt-get", ["install", "-y", pkg]}
  defp install_cmd(:dnf, pkg), do: {"dnf", ["install", "-y", pkg]}
  defp install_cmd(:yum, pkg), do: {"yum", ["install", "-y", pkg]}
  defp install_cmd(:pacman, pkg), do: {"pacman", ["--noconfirm", "-S", pkg]}
  defp install_cmd(:brew, pkg), do: {"brew", ["install", pkg]}
  defp install_cmd(:apk, pkg), do: {"apk", ["add", "--no-interaction", pkg]}
  defp install_cmd(:zypper, pkg), do: {"zypper", ["--non-interactive", "install", pkg]}
  defp install_cmd(:choco, pkg), do: {"choco", ["install", pkg, "-y"]}
  defp install_cmd(:winget, pkg), do: {"winget", ["install", pkg, "--silent"]}
  defp install_cmd(:pkg, pkg), do: {"pkg", ["install", "-y", pkg]}
  defp install_cmd(:emerge, pkg), do: {"emerge", ["--noreplace", pkg]}
  defp install_cmd(:unknown, pkg), do: {"echo", ["No", "package", "manager", "found", "for", pkg]}

  # ── Update commands ──────────────────────────────────────────

  defp update_cmd(:apt), do: {"apt", ["update"]}
  defp update_cmd(:apt_get), do: {"apt-get", ["update"]}
  defp update_cmd(:dnf), do: {"dnf", ["check-update"]}
  defp update_cmd(:yum), do: {"yum", ["check-update"]}
  defp update_cmd(:pacman), do: {"pacman", ["-Sy"]}
  defp update_cmd(:brew), do: {"brew", ["update"]}
  defp update_cmd(:apk), do: {"apk", ["update"]}
  defp update_cmd(:zypper), do: {"zypper", ["refresh"]}
  defp update_cmd(:choco), do: {"choco", ["upgrade", "all", "-y"]}
  defp update_cmd(:winget), do: {"winget", ["upgrade"]}
  defp update_cmd(:pkg), do: {"pkg", ["update"]}
  defp update_cmd(:emerge), do: {"emerge", ["--sync"]}
  defp update_cmd(:unknown), do: {"echo", ["No", "package", "manager", "detected"]}

  # ── Check-installed commands ─────────────────────────────────

  defp check_installed_cmd(:apt, pkg), do: {"dpkg", ["-s", pkg]}
  defp check_installed_cmd(:apt_get, pkg), do: {"dpkg", ["-s", pkg]}
  defp check_installed_cmd(:dnf, pkg), do: {"rpm", ["-q", pkg]}
  defp check_installed_cmd(:yum, pkg), do: {"rpm", ["-q", pkg]}
  defp check_installed_cmd(:pacman, pkg), do: {"pacman", ["-Qi", pkg]}
  defp check_installed_cmd(:brew, pkg), do: {"brew", ["list", pkg]}
  defp check_installed_cmd(:apk, pkg), do: {"apk", ["info", "-e", pkg]}
  defp check_installed_cmd(:zypper, pkg), do: {"rpm", ["-q", pkg]}
  defp check_installed_cmd(:choco, pkg), do: {"choco", ["list", "--local-only", pkg]}
  defp check_installed_cmd(:winget, pkg), do: {"winget", ["list", pkg]}
  defp check_installed_cmd(:pkg, pkg), do: {"pkg", ["info", pkg]}
  defp check_installed_cmd(:emerge, pkg), do: {"equery", ["list", pkg]}
  defp check_installed_cmd(:unknown, _), do: :unsupported

  # ── Remove commands ──────────────────────────────────────────

  defp remove_cmd(:apt, pkg, true), do: {"apt", ["purge", "-y", pkg]}
  defp remove_cmd(:apt, pkg, _), do: {"apt", ["remove", "-y", pkg]}
  defp remove_cmd(:apt_get, pkg, true), do: {"apt-get", ["purge", "-y", pkg]}
  defp remove_cmd(:apt_get, pkg, _), do: {"apt-get", ["remove", "-y", pkg]}
  defp remove_cmd(:dnf, pkg, _), do: {"dnf", ["remove", "-y", pkg]}
  defp remove_cmd(:yum, pkg, _), do: {"yum", ["remove", "-y", pkg]}
  defp remove_cmd(:pacman, pkg, _), do: {"pacman", ["--noconfirm", "-R", pkg]}
  defp remove_cmd(:brew, pkg, _), do: {"brew", ["uninstall", pkg]}
  defp remove_cmd(:apk, pkg, _), do: {"apk", ["del", pkg]}
  defp remove_cmd(:zypper, pkg, _), do: {"zypper", ["--non-interactive", "remove", pkg]}
  defp remove_cmd(:choco, pkg, _), do: {"choco", ["uninstall", pkg, "-y"]}
  defp remove_cmd(:winget, pkg, _), do: {"winget", ["uninstall", pkg, "--silent"]}
  defp remove_cmd(:pkg, pkg, _), do: {"pkg", ["remove", "-y", pkg]}
  defp remove_cmd(:emerge, pkg, _), do: {"emerge", ["--unmerge", pkg]}
  defp remove_cmd(:unknown, _, _), do: {"echo", ["No", "package", "manager", "detected"]}

  # ── Search commands ──────────────────────────────────────────

  defp search_cmd(:apt, q), do: {"apt-cache", ["search", q]}
  defp search_cmd(:apt_get, q), do: {"apt-cache", ["search", q]}
  defp search_cmd(:dnf, q), do: {"dnf", ["search", q]}
  defp search_cmd(:yum, q), do: {"yum", ["search", q]}
  defp search_cmd(:pacman, q), do: {"pacman", ["-Ss", q]}
  defp search_cmd(:brew, q), do: {"brew", ["search", q]}
  defp search_cmd(:apk, q), do: {"apk", ["search", q]}
  defp search_cmd(:zypper, q), do: {"zypper", ["search", q]}
  defp search_cmd(:choco, q), do: {"choco", ["search", q]}
  defp search_cmd(:winget, q), do: {"winget", ["search", q]}
  defp search_cmd(:pkg, q), do: {"pkg", ["search", q]}
  defp search_cmd(:emerge, q), do: {"emerge", ["--search", q]}
  defp search_cmd(:unknown, _), do: {"echo", ["No", "package", "manager", "detected"]}

  # ── Info commands ────────────────────────────────────────────

  defp info_cmd(:apt, pkg), do: {"apt-cache", ["show", pkg]}
  defp info_cmd(:apt_get, pkg), do: {"apt-cache", ["show", pkg]}
  defp info_cmd(:dnf, pkg), do: {"dnf", ["info", pkg]}
  defp info_cmd(:yum, pkg), do: {"yum", ["info", pkg]}
  defp info_cmd(:pacman, pkg), do: {"pacman", ["-Qi", pkg]}
  defp info_cmd(:brew, pkg), do: {"brew", ["info", pkg]}
  defp info_cmd(:apk, pkg), do: {"apk", ["info", pkg]}
  defp info_cmd(:zypper, pkg), do: {"zypper", ["info", pkg]}
  defp info_cmd(:choco, pkg), do: {"choco", ["info", pkg]}
  defp info_cmd(:winget, pkg), do: {"winget", ["show", pkg]}
  defp info_cmd(:pkg, pkg), do: {"pkg", ["info", pkg]}
  defp info_cmd(:emerge, pkg), do: {"equery", ["metadata", pkg]}
  defp info_cmd(:unknown, _), do: :unsupported

  # ── Helper ───────────────────────────────────────────────────

  defp run_cmd(cmd, args) when is_list(args) do
    case System.cmd(cmd, args, stderr_to_stdout: true) do
      {out, 0} -> {:ok, String.trim(out)}
      {err, _} -> {:error, String.trim(err)}
    end
  rescue
    e -> {:error, inspect(e)}
  end
end