Skip to main content

lib/ex_credstash/cli.ex

# Copyright 2026 Relay, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

defmodule ExCredstash.CLI do
  @moduledoc """
  Command-line interface for ExCredstash.

  Build with: `mix escript.build`
  Usage: `./credstash <command> [options]`

  ## Available Commands

  - `setup` - Create DynamoDB table
  - `put <name> [value]` - Store a secret (reads stdin if no value)
  - `get <name>` - Retrieve a secret
  - `getall` - Retrieve all secrets
  - `list` - List all credentials
  - `keys` - List unique credential names
  - `delete <name>` - Delete all versions of a secret

  ## Global Options

  - `-r, --region` - AWS region
  - `-t, --table` - DynamoDB table name
  - `-k, --key` - KMS key ID
  - `-h, --help` - Show help

  """

  @doc """
  Main entry point for the escript.
  """
  @spec main([String.t()]) :: :ok | no_return()
  def main(argv) do
    {opts, args, invalid} =
      OptionParser.parse(argv,
        strict: [
          region: :string,
          table: :string,
          version: :string,
          key: :string,
          autoversion: :boolean,
          digest: :string,
          comment: :string,
          noline: :boolean,
          format: :string,
          help: :boolean
        ],
        aliases: [
          r: :region,
          t: :table,
          v: :version,
          k: :key,
          a: :autoversion,
          d: :digest,
          c: :comment,
          n: :noline,
          f: :format,
          h: :help
        ]
      )

    # Handle invalid options
    if invalid != [] do
      Enum.each(invalid, fn {opt, _} ->
        IO.puts(:stderr, "Unknown option: #{opt}")
      end)

      print_help()
      System.halt(1)
    end

    # Handle --help flag
    if opts[:help] do
      print_help()
      System.halt(0)
    end

    run_command(args, opts)
  end

  # Command dispatch

  defp run_command(["setup" | _rest], opts) do
    result = ExCredstash.setup(build_opts(opts))

    case result do
      {:ok, :created} ->
        IO.puts("Credential store table created.")
        IO.puts("Go read the README about how to create your KMS key.")

      {:ok, :exists} ->
        IO.puts("Credential store table already exists.")

      {:error, reason} ->
        handle_error(reason)
    end
  end

  defp run_command(["put", name, value | context_args], opts) do
    context = parse_context(context_args)
    put_secret(name, value, context, opts)
  end

  defp run_command(["put", name | context_args], opts) do
    # Read value from stdin when no value provided
    context = parse_context(context_args)
    value = read_stdin()
    put_secret(name, value, context, opts)
  end

  defp run_command(["get", name | context_args], opts) do
    context = parse_context(context_args)

    get_opts =
      opts
      |> build_opts()
      |> maybe_add_context(context)
      |> maybe_add_version(opts[:version])

    case ExCredstash.get(name, get_opts) do
      {:ok, secret} ->
        output = format_output(secret, opts[:format])

        if opts[:noline] do
          IO.write(output)
        else
          IO.puts(output)
        end

      {:error, :not_found} ->
        IO.puts(:stderr, "Error: Secret '#{name}' not found")
        System.halt(1)

      {:error, reason} ->
        handle_error(reason)
    end
  end

  defp run_command(["getall" | context_args], opts) do
    context = parse_context(context_args)

    get_opts =
      opts
      |> build_opts()
      |> maybe_add_context(context)

    case ExCredstash.get_all(get_opts) do
      {:ok, secrets} ->
        output = format_output(secrets, opts[:format] || "json")
        IO.puts(output)

      {:error, reason} ->
        handle_error(reason)
    end
  end

  defp run_command(["list" | _rest], opts) do
    case ExCredstash.list(build_opts(opts)) do
      {:ok, credentials} ->
        if Enum.empty?(credentials) do
          IO.puts("No credentials found.")
        else
          max_name_len =
            credentials
            |> Enum.map(fn item -> String.length(item.name) end)
            |> Enum.max(fn -> 0 end)

          credentials
          |> Enum.sort_by(fn item -> {item.name, item.version} end)
          |> Enum.each(fn item ->
            comment = item[:comment] || ""

            IO.puts(
              "#{String.pad_trailing(item.name, max_name_len)} -- version #{item.version} -- comment #{comment}"
            )
          end)
        end

      {:error, reason} ->
        handle_error(reason)
    end
  end

  defp run_command(["keys" | _rest], opts) do
    case ExCredstash.keys(build_opts(opts)) do
      {:ok, names} ->
        Enum.each(names, &IO.puts/1)

      {:error, reason} ->
        handle_error(reason)
    end
  end

  defp run_command(["delete", name | _rest], opts) do
    case ExCredstash.delete(name, build_opts(opts)) do
      {:ok, count} ->
        IO.puts("Deleted #{count} version(s) of '#{name}'")

      {:error, reason} ->
        handle_error(reason)
    end
  end

  defp run_command(["help" | _rest], _opts), do: print_help()
  defp run_command([], _opts), do: print_help()

  defp run_command([unknown | _rest], _opts) do
    IO.puts(:stderr, "Unknown command: #{unknown}")
    print_help()
    System.halt(1)
  end

  # Helper functions

  @doc false
  def parse_context(args) do
    args
    |> Enum.filter(&String.contains?(&1, "="))
    |> Enum.map(fn str ->
      [key | rest] = String.split(str, "=", parts: 2)
      {key, Enum.join(rest, "=")}
    end)
    |> Map.new()
  end

  defp put_secret(name, value, context, opts) do
    # Handle special value formats
    actual_value = read_value(value)

    put_opts =
      opts
      |> build_opts()
      |> maybe_add_context(context)
      |> maybe_add_key_id(opts[:key])
      |> maybe_add_digest(opts[:digest])
      |> maybe_add_comment(opts[:comment])
      |> maybe_add_version_for_put(opts)

    case ExCredstash.put(name, actual_value, put_opts) do
      {:ok, version} ->
        IO.puts("#{name} has been stored (version #{version})")

      {:error, {:already_exists, version}} ->
        IO.puts(
          :stderr,
          "#{name} version #{version} is already in the credential store. " <>
            "Use the -v flag to specify a new version"
        )

        System.halt(1)

      {:error, :already_exists} ->
        # Fallback for legacy error format
        IO.puts(
          :stderr,
          "#{name} is already in the credential store. " <>
            "Use the -v flag to specify a new version"
        )

        System.halt(1)

      {:error, reason} ->
        handle_error(reason)
    end
  end

  defp read_value("-"), do: read_stdin()

  defp read_value("@" <> filename) do
    case File.read(Path.expand(filename)) do
      {:ok, content} ->
        content

      {:error, reason} ->
        IO.puts(:stderr, "Error reading file #{filename}: #{inspect(reason)}")
        System.halt(1)
    end
  end

  defp read_value(value), do: value

  defp read_stdin do
    IO.read(:stdio, :eof)
    |> String.trim_trailing("\n")
  end

  defp build_opts(opts) do
    []
    |> maybe_add_region(opts[:region])
    |> maybe_add_table(opts[:table])
  end

  defp maybe_add_region(opts, nil), do: opts
  defp maybe_add_region(opts, region), do: Keyword.put(opts, :region, region)

  defp maybe_add_table(opts, nil), do: opts
  defp maybe_add_table(opts, table), do: Keyword.put(opts, :table, table)

  defp maybe_add_context(opts, context) when map_size(context) == 0, do: opts
  defp maybe_add_context(opts, context), do: Keyword.put(opts, :context, context)

  defp maybe_add_key_id(opts, nil), do: opts
  defp maybe_add_key_id(opts, key_id), do: Keyword.put(opts, :key_id, key_id)

  defp maybe_add_digest(opts, nil), do: opts

  defp maybe_add_digest(opts, digest) do
    # Convert string digest to atom (e.g., "SHA256" -> :sha256)
    digest_atom =
      digest
      |> String.downcase()
      |> String.to_atom()

    Keyword.put(opts, :digest, digest_atom)
  end

  defp maybe_add_comment(opts, nil), do: opts
  defp maybe_add_comment(opts, comment), do: Keyword.put(opts, :comment, comment)

  defp maybe_add_version(opts, nil), do: opts

  defp maybe_add_version(opts, version) do
    # Try to parse as integer, otherwise keep as string
    parsed =
      case Integer.parse(version) do
        {int, ""} -> int
        _ -> version
      end

    Keyword.put(opts, :version, parsed)
  end

  defp maybe_add_version_for_put(opts, cli_opts) do
    cond do
      cli_opts[:autoversion] ->
        # Auto-increment: don't specify version, let ExCredstash auto-increment
        opts

      cli_opts[:version] ->
        # Parse the version string to integer
        case Integer.parse(cli_opts[:version]) do
          {int, ""} ->
            Keyword.put(opts, :version, int)

          _ ->
            IO.puts(:stderr, "Error: Version must be an integer")
            System.halt(1)
        end

      true ->
        # Default: use version 1 (matches Python credstash behavior)
        # This will error if version 1 already exists
        Keyword.put(opts, :version, 1)
    end
  end

  @doc false
  def format_output(data, "json") when is_map(data) do
    Jason.encode!(data, pretty: true)
  end

  def format_output(data, "csv") when is_map(data) do
    Enum.map_join(data, "\n", fn {k, v} -> "#{k},#{v}" end)
  end

  def format_output(data, "dotenv") when is_map(data) do
    Enum.map_join(data, "\n", fn {k, v} ->
      key = String.upcase(k)
      "#{key}='#{v}'"
    end)
  end

  def format_output(data, _format), do: to_string(data)

  defp handle_error(reason) do
    IO.puts(:stderr, "Error: #{inspect(reason)}")
    System.halt(1)
  end

  @doc false
  def print_help do
    IO.puts("""
    ExCredstash - Credential Management with AWS KMS & DynamoDB

    Usage: credstash <command> [options] [arguments]

    Commands:
      setup                Create the DynamoDB table
      put <name> [value]   Store a secret (reads stdin if no value)
      get <name>           Retrieve a secret
      getall               Retrieve all secrets
      list                 List all credentials with versions
      keys                 List unique credential names
      delete <name>        Delete all versions of a secret
      help                 Show this help

    Global Options:
      -r, --region <region>    AWS region
      -t, --table <table>      DynamoDB table name (default: credential-store)
      -h, --help               Show this help

    Put Options:
      -k, --key <key_id>       KMS key ID (default: alias/credstash)
      -v, --version <version>  Specify version (default: 1)
      -a, --autoversion        Auto-increment to next version
      -d, --digest <algo>      Hash algorithm (SHA256, SHA384, SHA512)
      -c, --comment <text>     Comment for the secret

    Get Options:
      -v, --version <version>  Get specific version (default: latest)
      -n, --noline             Don't print trailing newline
      -f, --format <format>    Output format: json, csv, dotenv (default: plain)

    Context:
      Additional arguments as key=value pairs are passed as KMS encryption context.

    Examples:
      credstash setup -r us-east-1
      credstash put db_password secret123 -r us-east-1
      credstash put api_key -k alias/mykey -c "API key for service"
      echo "secret" | credstash put my_secret
      credstash put file_secret @/path/to/file
      credstash get db_password -r us-east-1
      credstash get api_key environment=production
      credstash getall -f json
      credstash list
      credstash keys
      credstash delete old_secret
    """)
  end
end