Problems with starting umbrella with a CLI app within the umbrella

I have an umbrella app that I have just added a CLI application to. I want to include the CLI app as part of the umbrella so that I can use some of the common library applications from the umbrella.

I am using bakeware in order to build a stand alone executable for the CLI. Thus, I have to include a module entry point in the mix.exs file:

  def application do
    [
      mod: {Cli, []},
      extra_applications: [
        :logger
      ]
    ]
  end

This is because bakeware implements its own start/2 callback from the Application module to achieve its functionality:

  defmacro __using__(_opts) do
    quote location: :keep do
      @behaviour Bakeware.Script
      import Bakeware.Script, only: [get_argc!: 0, get_args: 1, result_to_halt: 1]

      use Application

      def start(_type, _args) do
        children = [
          %{id: Task, restart: :temporary, start: {Task, :start_link, [&__MODULE__._main/0]}}
        ]

        opts = [strategy: :one_for_all, name: __MODULE__.Supervisor]
        Supervisor.start_link(children, opts)
      end

      @doc false
      def _main() do
        get_argc!()
        |> get_args()
        |> main()
        |> result_to_halt()
        |> :erlang.halt()
      catch
        error, reason ->
          IO.warn(
            "Caught exception in #{__MODULE__}.main/1: #{inspect(error)} => #{inspect(reason, pretty: true)}",
            __STACKTRACE__
          )

          :erlang.halt(1)
      end
    end
  end

I have tried implementing the start/2 callback within my CLI module and switch possibly on the start type or args coming in, but of course, they are the same no matter the start strategy.

Is there a way I can conditionally start this application based on CLI executable vs umbrella start?

Thanks!

I’ve built one umbrella project with a Bakeware CLI app; here’s how I remember solving this. Not the cleanest approach, but it essentially boils down to using mix targets to change this behaviour at compile-time. Let me know if it works for you, or if you find a better way!

  1. Capture the Config.config_target() in your :cli_app’s compile-time application environment:

    # config/config.exs
    import Config
    config :cli_app, :config_target, config_target()
    
  2. Meta-program an alternate no-op Application.start/2 callback in your Bakeware entrypoint if you are not using the :cli compile target:

    # apps/cli_app/cli.ex
    defmodule CLI do
      if Application.compile_env(:cli_app, :config_target) == :cli do
        use Bakeware
      else
        import Bakeware.Script, only: [get_argc!: 0, get_args: 1, result_to_halt: 1]
        use Application
        @impl Application
        def start(_type, _args), do: :ignore
     end
      # Callbacks go here
    end
    

Now you can sanely mix compile, iex, and other things across your umbrella app, but swap out the target when building a release just of the CLI app.

The main downside is that you lose out on warnings/type inference during development if your CLI breaks the Bakeware contract, but I think that can be solved by adding a per-project default target:

# apps/cli_app/mix.exs
#...
def cli, do: [
  default_target: :cli
]