lib/mix/tasks/appsignal.install.ex

defmodule Mix.Tasks.Appsignal.Install do
  use Mix.Task
  @shortdoc "Installs AppSignal into the current application"

  def run([]) do
    header()

    """
    We're missing an AppSignal Push API key and cannot continue.
    Please supply one as an argument to this command.

      mix appsignal.install push_api_key

    You can find your push_api_key on https://appsignal.com/accounts under 'Add app'
    Contact us at support@appsignal.com if you're stuck.
    """
    |> IO.puts()
  end

  def run([push_api_key]) do
    config = %{otp_app: otp_app(), active: true, push_api_key: push_api_key, request_headers: []}
    Application.put_env(:appsignal, :config, config)
    Appsignal.Config.initialize()

    header()
    validate_push_api_key()
    config = Map.put(config, :name, ask_for_app_name(config))

    case ask_kind_of_configuration() do
      :file ->
        write_config_file(config)
        link_config_file()
        activate_config_for_env("dev")
        activate_config_for_env("stag")
        activate_config_for_env("prod")

      :env ->
        output_config_environment_variables(config)
    end

    if Code.ensure_loaded?(Phoenix) do
      output_phoenix_instructions()
    end

    IO.puts("\nAppSignal installed! 🎉")

    Mix.Tasks.Appsignal.Demo.run([])
  end

  defp otp_app do
    config = Mix.Project.config()
    Keyword.get(config, :app)
  end

  defp header do
    """
    AppSignal install
    #{hr()}
    Website:       https://appsignal.com
    Documentation: http://docs.appsignal.com
    Support:       support@appsignal.com
    #{hr()}

    Welcome to AppSignal!

    This installer will guide you through setting up AppSignal in your application.
    We will perform some checks on your system and ask how you like AppSignal to be
    configured.

    #{hr()}

    """
    |> IO.puts()
  end

  defp hr, do: String.duplicate("=", 80)

  defp validate_push_api_key do
    IO.write("Validating Push API key: ")
    config = Application.get_env(:appsignal, :config)

    case Appsignal.Utils.PushApiKeyValidator.validate(config) do
      :ok ->
        IO.puts("Valid! 🎉")

      {:error, :invalid} ->
        print_invalid()
        exit(:shutdown)

      {:error, reason} ->
        print_validating_error(reason)
        exit(:shutdown)
    end
  end

  defp print_invalid do
    """
    Invalid
    Please make sure you're using the correct push api key from appsignal.com
    Contact us at support@appsignal.com if you're stuck.
    """
    |> IO.puts()
  end

  defp print_validating_error(reason) do
    """

    Validating failed, reason:

      #{inspect(reason)}
    """
    |> IO.puts()
  end

  defp ask_for_app_name(%{otp_app: default}) do
    name = ask_for_input("What is your application's name? [#{default}]")

    if String.length(name) < 1 do
      default
    else
      name
    end
  end

  defp ask_kind_of_configuration do
    """

    There are two methods of configuring AppSignal in your application.
      Option 1: Using a "config/appsignal.exs" file. (1)
      Option 2: Using system environment variables.  (2)
    """
    |> IO.puts()

    case ask_for_input("What is your preferred configuration method? [1]") do
      "1" ->
        :file

      "2" ->
        :env

      input ->
        if String.length(input) < 1 do
          :file
        else
          IO.puts("I'm sorry, I didn't quite get that. Please choose option 1 or 2.")
          ask_kind_of_configuration()
        end
    end
  end

  defp output_config_environment_variables(config) do
    """
    Configuring with environment variables.
    Please put the following variables in your environment to configure AppSignal.

      export APPSIGNAL_OTP_APP="#{config[:otp_app]}"
      export APPSIGNAL_APP_NAME="#{config[:name]}"
      export APPSIGNAL_APP_ENV="prod"
      export APPSIGNAL_PUSH_API_KEY="#{config[:push_api_key]}"
    """
    |> IO.puts()
  end

  defp write_config_file(config) do
    IO.write("Writing config file config/appsignal.exs: ")

    File.mkdir_p("config")

    case File.open(appsignal_config_file_path(), [:write]) do
      {:ok, file} ->
        case IO.binwrite(file, appsignal_config_file_contents(config)) do
          :ok ->
            IO.puts("Success!")

          {:error, reason} ->
            IO.puts("Failure! #{inspect(reason)}")
            exit(:shutdown)
        end

        File.close(file)

      {:error, reason} ->
        IO.puts("Failure! #{inspect(reason)}")
        exit(:shutdown)
    end
  end

  # Link the config/appsignal.exs config file to the config/config.exs file.
  # If already linked, it's ignored.
  defp link_config_file do
    IO.write("Linking config to config/config.exs: ")

    active_content = "\nimport_config \"#{appsignal_config_filename()}\"\n"

    cond do
      appsignal_config_linked?() ->
        IO.puts("Success! (Already linked?)")

      File.exists?(config_file_path()) ->
        case append_to_file(config_file_path(), active_content) do
          :ok ->
            IO.puts("Success!")

          {:error, reason} ->
            IO.puts("Failure! #{inspect(reason)}")
            exit(:shutdown)
        end

      true ->
        case File.write(config_file_path(), "use Mix.Config\n#{active_content}") do
          :ok ->
            IO.puts("Success!")

          {:error, reason} ->
            IO.puts("Failure! #{inspect(reason)}")
            exit(:shutdown)
        end
    end
  end

  # Checks if AppSignal was already linked in the main config/config.exs file.
  defp appsignal_config_linked? do
    case File.read(config_file_path()) do
      {:ok, contents} ->
        String.contains?(contents, ~s(import_config "#{appsignal_config_filename()})) ||
          String.contains?(contents, "import_config '#{appsignal_config_filename()}")

      {:error, :enoent} ->
        false
    end
  end

  # Contents for the config/appsignal.exs file.
  defp appsignal_config_file_contents(config) do
    options = """
      otp_app: #{inspect(config[:otp_app])},
      name: "#{config[:name]}",
      push_api_key: "#{config[:push_api_key]}",
      env: Mix.env
    """

    options_with_active =
      case has_environment_configuration_files?() do
        false -> "  active: true,\n" <> options
        true -> options
      end

    """
    use Mix.Config

    config :appsignal, :config,
    #{options_with_active}
    """
  end

  # Append a line to Mix configuration environment files which activate
  # AppSignal. This is done for development, staging and production
  # environments if they are present.
  defp activate_config_for_env(env) do
    env_file = config_path_for_env(env)

    if File.exists?(env_file) do
      IO.write("Activating #{env} environment: ")

      active_content = "\nconfig :appsignal, :config, active: true\n"

      case file_contains?(env_file, active_content) do
        :ok ->
          IO.puts("Success! (Already active?)")

        {:error, :not_found} ->
          case append_to_file(env_file, active_content) do
            :ok ->
              IO.puts("Success!")

            {:error, reason} ->
              IO.puts("Failure! #{inspect(reason)}")
              exit(:shutdown)
          end

        {:error, reason} ->
          IO.puts("Failure! #{inspect(reason)}")
          exit(:shutdown)
      end
    end
  end

  defp file_contains?(path, contents) do
    case File.read(path) do
      {:ok, file_contents} ->
        case String.contains?(file_contents, contents) do
          true -> :ok
          _ -> {:error, :not_found}
        end

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp append_to_file(path, contents) do
    case File.open(path, [:append]) do
      {:ok, file} ->
        result = IO.binwrite(file, contents)
        File.close(file)
        result

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp output_phoenix_instructions do
    """

    AppSignal detected a Phoenix app
      Please follow the following guide to integrate AppSignal in your
      Phoenix application.
      http://docs.appsignal.com/elixir/integrations/phoenix.html
    """
    |> IO.puts()
  end

  defp ask_for_input(prompt) do
    String.trim(IO.gets("#{prompt}: "))
  end

  defp has_environment_configuration_files? do
    "dev" |> config_path_for_env |> File.exists?() or
      "stag" |> config_path_for_env |> File.exists?() or
      "prod" |> config_path_for_env |> File.exists?()
  end

  defp appsignal_config_filename, do: "appsignal.exs"
  defp config_file_path, do: Path.join("config", "config.exs")
  defp appsignal_config_file_path, do: Path.join("config", appsignal_config_filename())
  defp config_path_for_env(env), do: Path.join("config", "#{env}.exs")
end