Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: update cli interface to be more elixir based #53

Merged
merged 8 commits into from
Aug 4, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ if config_env() == :prod do
config :jumar, Jumar.Repo,
# ssl: true,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
pool_size: "POOL_SIZE" |> System.get_env("10") |> String.to_integer(),
socket_options: maybe_ipv6

# The secret key base is used to sign/encrypt cookies and other secrets.
Expand All @@ -48,8 +48,8 @@ if config_env() == :prod do
You can generate one by calling: mix phx.gen.secret
"""

host = System.get_env("PHX_HOST") || "example.com"
port = String.to_integer(System.get_env("PORT") || "4000")
host = System.get_env("PHX_HOST", "example.com")
port = "PORT" |> System.get_env("4000") |> String.to_integer()

config :jumar, JumarWeb.Endpoint,
http: [port: port],
Expand Down
145 changes: 132 additions & 13 deletions lib/jumar_cli.ex
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
defmodule JumarCli do
@moduledoc """
The JumarCli module contains custom commands
that are used by `mix release` and the generated
Docker file. This allows us to have Elixir code for
running migrations, rollbacks, database seeds, web
servers, etc.
The JumarCli module contains custom commands that are
used by `mix release` and the generated Docker file. This
allows us to use the Elixir `OptionParser` for a more
advanced CLI interface.
"""

use Application
Expand All @@ -13,19 +12,139 @@
deps: [Jumar, JumarWeb],
exports: []

@impl true
def start(type, args) do
case System.get_env("JUMAR_CMD") do
"migrate" -> JumarCli.Migrate.start(type, args)
"rollback" -> JumarCli.Migrate.start(type, args)
"server" -> JumarCli.Migrate.start(type, args)
_ -> JumarCli.Application.start(type, args)
alias JumarCli.Command

@commands [
JumarCli.Migrate,
JumarCli.Rollback,
JumarCli.Seed,
JumarCli.Server
]

@elixir_commands [
{"iex", "Starts an interactive Elixir shell for Jumar"}
]

@options [
aliases: [
h: :help,
v: :version
],
switches: [
help: :boolean,
version: :boolean
]
]

@impl Application
def start(_type, _args) do

Check warning on line 40 in lib/jumar_cli.ex

View workflow job for this annotation

GitHub Actions / Lint (Credo)

Function is too complex (cyclomatic complexity is 11, max is 9).
# The easiest way to pass in command line args into an
# application on the Beam VM is with environment variables.
# This is set in `rel/overlays/bin/jumar`.
args = "JUMAR_CMD" |> System.get_env("") |> String.split(" ")

# We use the OptionParser to make a more "native" feeling
# CLI that can parse flags and values.
{options, rest, _} = OptionParser.parse(args, @options)

# We split up the command text for easier pattern matching
# and passing in the remainder to sub commands.
command = Enum.at(rest, 0, "")
args = Enum.drop(rest, 1)

case {command, Enum.into(options, %{})} do
# Root level args to mirror how most CLI applications work.
{subcommand, %{help: true}} ->
found_command =
Enum.find(@commands, fn command ->
Command.command_name(command) == subcommand
end)

if is_nil(found_command) do
IO.puts(Command.moduledoc(__MODULE__))
System.halt(0)
else
IO.puts(Command.moduledoc(found_command))
System.halt(0)
end

{_, %{version: true}} ->
IO.puts("Jumar #{Application.spec(:jumar, :vsn)}")
System.halt(0)

# If nothing is given as a subcommand, we run the application
# command which includes _everything_. This allows us to
# specify this module as the Elixir application in `mix.exs`,
# and have tests and other Elixir code start the application
# as expected.
{"", _} ->
JumarCli.Application.run("")

# For everything else, we help and exit.
{subcommand, _} ->
found_command =
Enum.find(@commands, fn command ->
Command.command_name(command) == subcommand
end)

if is_nil(found_command) do
IO.puts("Unknown command: #{subcommand}")
IO.puts("")
IO.puts(Command.moduledoc(__MODULE__))
System.halt(1)
end

case found_command.run(args) do
{:ok, nil} ->
System.halt(0)

{:ok, pid} ->
{:ok, pid}

{:error, :help_text} ->
IO.puts(Command.moduledoc(command))
System.halt(1)

{:error, message} ->
IO.puts("Error: #{message}")
System.halt(1)
end
end
end

@doc """
Returns help text for Jumar CLI. This is returned when doing
`--help` or `-h`. Note that this help text includes some
commands that are setup in the jumar shell script outside of Elixir.
"""
def moduledoc() do
command_text =
@commands
|> Enum.map(fn c -> {Command.command_name(c), Command.shortdoc(c)} end)
|> Enum.concat(@elixir_commands)
|> Enum.sort_by(&elem(&1, 0))
|> Enum.map(fn {name, doc} ->
String.pad_trailing(name, 15) <> doc
end)
|> Enum.join("\n ")

"""
Usage: jumar [options] command [args]

Options:

-h, --help Prints this help text
-v, --version Prints the Jumar version

Commands:

#{command_text}
"""
end

# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
@impl true
@impl Application
def config_change(changed, _new, removed) do
JumarWeb.Endpoint.config_change(changed, removed)
:ok
Expand Down
23 changes: 0 additions & 23 deletions lib/jumar_cli/application.ex

This file was deleted.

104 changes: 104 additions & 0 deletions lib/jumar_cli/command.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
defmodule JumarCli.Command do
@moduledoc """
A module for CLI commands and subcommands that can
be executed by the Jumar CLI. This follows a very
similar pattern to `Mix.Task` in Elixir, but is used
for the released application CLI in Jumar Docker.
"""

@typedoc "The name of the command. This is the name used in the CLI."
@type command_name :: String.t()

@typedoc "Any module that implements the `JumarCli.Command` behaviour."
@type command_module :: module()

@typedoc """
Commands can return different values depending on
what the command does. They can return:

- `{:ok, nil}` which will exit the CLI with a status
code of 0.

- `{:ok, pid()}` which monitor the pid as the main
process of the CLI. This can be used for starting
a web server or other long running processes.

- `{:error, :help_text}` which will print the help
text for the command and exit the CLI with a status
code of 1.

- `{:error, any()}` which will print the error message
and exit the CLI with a status code of 1.
"""
@type return_value :: {:ok, nil} | {:ok, pid()} | {:error, :help_text} | {:error, any()}

@doc """
This is the command actual ran by the CLI.
"""
@callback run(command_line_args :: [binary]) :: return_value

@doc false
defmacro __using__(_opts) do
quote do
Enum.each(
[:shortdoc],
&Module.register_attribute(__MODULE__, &1, persist: true)
)

@behaviour JumarCli.Command
end
end

@doc """
Gets the moduledoc for the given command `module`.

Returns the moduledoc or `nil`.
"""
@spec moduledoc(command_module) :: String.t()
def moduledoc(module) when is_atom(module) do
if function_exported?(module, :moduledoc, 0) do
module.moduledoc()
else
case Code.fetch_docs(module) do
{:docs_v1, _, _, _, %{"en" => moduledoc}, _, _} -> moduledoc
{:docs_v1, _, _, _, :none, _, _} -> ""
_ -> ""
end
end
end

@doc """
Gets the shortdoc for the given command `module`.

Returns the shortdoc or `nil`.
"""
@spec shortdoc(command_module) :: String.t()
def shortdoc(module) when is_atom(module) do
if function_exported?(module, :shortdoc, 0) do
module.shortdoc()
else
case List.keyfind(module.__info__(:attributes), :shortdoc, 0) do
{:shortdoc, [shortdoc]} -> shortdoc
_ -> ""
end
end
end

@doc """
Returns the command name for the given `module`.

## Examples

iex> JumarCli.Command.command_name(JumarCli.Commands.Migrate)
"migrate"

"""
@spec command_name(command_module) :: command_name
def command_name(module) when is_atom(module) do
module
|> to_string()
|> String.split(".")
|> Enum.drop(2)
|> Enum.map_join(" ", &Macro.underscore/1)
end
end
19 changes: 19 additions & 0 deletions lib/jumar_cli/commands/application.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
defmodule JumarCli.Application do
@moduledoc """
Starts the everything in Jumar. This includes the
database, web server, background processing, and more.
"""
@shortdoc "Starts the everything in Jumar"

use JumarCli.Command

@impl JumarCli.Command
def run(_command_line_args) do
children = [
Jumar.Supervisor,
JumarWeb.Supervisor
]

Supervisor.start_link(children, strategy: :one_for_one)
end
end
55 changes: 55 additions & 0 deletions lib/jumar_cli/commands/migrate.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
defmodule JumarCli.Migrate do
@moduledoc """
Migrates the Jumar database to the latest version.
"""
@shortdoc "Migrates the Jumar database"

use JumarCli.Command

require Logger

@impl JumarCli.Command
def run(_command_line_args) do
children = [
Jumar.Repo
]

with {:ok, _pid} <- Supervisor.start_link(children, strategy: :one_for_one) do
for repo <- repos() do
# First we ensure the repo is created and accessable.
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &storage_up(&1))

# Then we have a short wait for the DB to catch up. This
# prevents an issue we've seen in production with standup and
# migrations happening too quickly.
Process.sleep(1_000)

# Finally we run the migrations.
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end

System.halt(0)
end
end

defp storage_up(repo) do
case repo.__adapter__.storage_up(repo.config) do
:ok ->
Logger.info("The database for #{inspect(repo)} has been created")

{:error, :already_up} ->
Logger.info("The databse for #{inspect(repo)} has already been created")

{:error, term} when is_binary(term) ->
Logger.error("The database for #{inspect(repo)} couldn't be created: #{term}")
raise RuntimeError, term

{:error, term} ->
Logger.error("The database for #{inspect(repo)} couldn't be created: #{inspect(term)}")
raise RuntimeError, inspect(term)
end
end

@spec repos() :: [module()]
defp repos, do: Application.fetch_env!(:jumar, :ecto_repos)
end
Loading
Loading