Skip to main content

lib/mix/tasks/npm.token.ex

defmodule Mix.Tasks.Npm.Token do
  @shortdoc "Manage npm auth tokens"

  @moduledoc """
  Display or verify npm authentication token status.

      mix npm.token           # Show token status
      mix npm.token --verify  # Verify token with registry

  The token is read from the `NPM_TOKEN` environment variable
  or from `.npmrc` auth configuration.
  """

  use Mix.Task

  @impl true
  def run(args) do
    Application.ensure_all_started(:req)
    {opts, _, _} = OptionParser.parse(args, strict: [verify: :boolean])

    token = resolve_token()

    case token do
      nil ->
        Mix.shell().info("No npm token configured.")
        Mix.shell().info("Set NPM_TOKEN or add authToken to .npmrc")

      token ->
        masked = mask_token(token)
        Mix.shell().info("Token: #{masked}")
        Mix.shell().info("Source: #{token_source()}")

        if opts[:verify], do: verify_token(token)
    end
  end

  defp resolve_token do
    NPM.Config.auth_token() || read_npmrc_token()
  end

  defp read_npmrc_token do
    case File.read(".npmrc") do
      {:ok, content} ->
        config = NPM.Config.parse_npmrc(content)
        config["//registry.npmjs.org/:_authToken"]

      {:error, _} ->
        nil
    end
  end

  defp token_source do
    cond do
      System.get_env("NPM_TOKEN") -> "NPM_TOKEN environment variable"
      Application.get_env(:duskmoon_npm, :token) -> "application config"
      true -> ".npmrc file"
    end
  end

  defp mask_token(token) when byte_size(token) <= 8, do: "****"

  defp mask_token(token) do
    String.slice(token, 0, 4) <> "****" <> String.slice(token, -4, 4)
  end

  defp verify_token(token) do
    url = "#{NPM.Registry.registry_url()}/-/whoami"

    case Req.get(url, headers: [authorization: "Bearer #{token}"]) do
      {:ok, %{status: 200, body: %{"username" => user}}} ->
        Mix.shell().info("Authenticated as: #{user}")

      {:ok, %{status: 401}} ->
        Mix.shell().error("Token is invalid or expired.")

      {:ok, %{status: status}} ->
        Mix.shell().error("Unexpected response: #{status}")

      {:error, reason} ->
        Mix.shell().error("Verification failed: #{inspect(reason)}")
    end
  end
end