Skip to main content

lib/marea.ex

defmodule Marea do
  @moduledoc """
  Entry point for the `marea` escript.

  Marea is a thin, transparent helper around `mix`, `docker`, `helm`,
  `kubectl` and `aws`: it issues those commands consistently from a
  single `marea.yaml` and prints every one before running it. The
  current focus is Helm + Kubernetes, with optional plugins for the AWS
  pieces (ECR / Route53 / S3); other deploy targets, registries and
  providers are added as plugins, not by patching core.

  `main/1` is invoked by the escript wrapper. It reads `marea.yaml`,
  loads the configured `Malla.Plugin` modules on top of the base
  plugins ([`Setup`](plugins/setup.md), [`Build`](plugins/build.md),
  [`Run`](plugins/run.md), [`Base`](plugins/base.md)), starts `Marea.Service`,
  and dispatches the parsed command through the plugin chain.

  See the [Introduction](introduction.html) and
  [Architecture](architecture.html) guides for the bigger picture and
  the full startup/dispatch flow.
  """

  alias Marea.{Service, Lib}

  @base_plugins [
    Marea.Plugins.Setup,
    Marea.Plugins.Build,
    Marea.Plugins.Run,
    Marea.Plugins.Mcp,
    Marea.Plugins.Base
  ]

  @doc """
  Returns the full list of plugin modules for the current working
  directory: `@base_plugins` plus the modules listed under `plugins:`
  in `marea.yaml`.

  Reads the YAML but does not start the service, so callers (notably
  the MCP server) can introspect the plugin chain without committing
  to a service lifecycle.
  """
  @spec plugins() :: [module()] | no_return()
  def plugins() do
    {:ok, _} = Application.ensure_all_started(:marea)

    plugin_names =
      case Marea.Config.Yaml.find_config() do
        {:ok, {_path, terms}} -> Map.get(terms, "plugins", [])
        :not_found -> []
      end

    user_modules =
      for name <- plugin_names do
        mod = Module.safe_concat(String.split(name, "."))

        case Code.ensure_compiled(mod) do
          {:module, _} -> mod
          _ -> Lib.stop("Unknown plugin: #{name} (module not found)")
        end
      end

    @base_plugins ++ user_modules
  end

  @doc """
  Escript entry point.

  Sets up signal traps, clears any leftover `.marea/next_cmd`, starts the
  service, and runs the command. Any deferred shell command produced by
  the chain is written to `.marea/next_cmd` for the wrapper to exec.
  """
  @spec main([String.t()]) :: :ok | no_return()
  def main(args) do
    System.trap_signal(:sigstop, :stop, &signal_stop/0)
    System.trap_signal(:sigquit, :quit, &signal_quit/0)
    System.trap_signal(:sigusr1, :usr1, &signal_usr1/0)

    clear_next_cmd()
    start_service(args)
    Service.execute()
  end

  @doc """
  Entry point used by `mix marea`.

  Same as `main/1` but returns deferred commands as `{:exec, cmd}` so the
  Mix task can run them via Port instead of relying on the escript wrapper.
  """
  @spec main_inline([String.t()]) :: :ok | {:exec, String.t()} | no_return()
  def main_inline(args) do
    clear_next_cmd()
    start_service(args)
    Service.execute_inline()
  end

  defp start_service(args) do
    plugins = plugins()

    {path, terms, found?} =
      case Marea.Config.Yaml.find_config() do
        {:ok, {path, terms}} -> {path, terms, true}
        :not_found -> {nil, %{}, false}
      end

    {:ok, _} =
      Service.start_link(
        args: args,
        yaml_path: path,
        yaml_terms: terms,
        config_found: found?,
        plugins: plugins
      )
  end

  defp clear_next_cmd do
    path = Path.join(".marea", "next_cmd")
    if File.exists?(path), do: File.write!(path, "#!/bin/sh\n")
  end

  @doc false
  def signal_stop(), do: IO.puts("SIGNAL STOP")
  @doc false
  def signal_quit(), do: IO.puts("SIGNAL QUIT")
  @doc false
  def signal_usr1(), do: IO.puts("SIGNAL USR1")
end