Skip to main content

lib/mix/tasks/skua.setup.ex

defmodule Mix.Tasks.Skua.Setup do
  @shortdoc "Interactive one-command setup: theme + auth + pages + SEO"
  @moduledoc """
  A guided, one-command setup that wires up a Skua app end to end. Run it once
  inside a fresh Phoenix app:

      mix skua.setup

  It walks you through:

    * **Theme** — pick one of the 100 themes, or none (arrow keys on a real
      terminal; a numbered prompt under CI/pipes).
    * **Authentication** — `none` · `magic_link` · `otp` · `password_otp`.
    * **Features** — a checklist (defaults all on): starter pages, SEO files
      (robots.txt + llms.txt), strip daisyUI.

  Then it runs the generators in the one correct order — `skua.install` →
  `skua.gen.auth` (+ `deps.get` for Hammer) → `skua.gen.pages` → `skua.gen.seo`
  — so the ordering traps can't bite.

  ## Non-interactive

  Every choice maps to a flag, so the same task scripts cleanly:

      mix skua.setup --theme rose-pine --auth otp --yes
      mix skua.setup --no-pages --no-seo --auth none --yes

  Flags: `--theme <name>` · `--auth <flow>` · `--pages` / `--no-pages` ·
  `--seo` / `--no-seo` · `--strip-daisy` / `--no-strip-daisy` · `--yes` (skip
  the wizard + confirmation, using flags + defaults). With `--yes` and no
  `--auth`, auth defaults to `none`; the interactive wizard instead pre-selects
  `otp`. `--theme` is validated against `mix skua.themes` before anything runs.

  ## For AI agents / CI

  Runs head-less and **never blocks on input**. The canonical agent command is
  fully explicit:

      mix skua.setup --theme rose-pine --auth otp --pages --seo --yes

  With no TTY (a pipe, CI, or an agent subprocess) the wizard is skipped
  automatically and the task runs from flags + defaults — so even
  `mix skua.setup --auth otp` works unattended. The resolved plan is printed
  before anything runs.
  """
  use Mix.Task

  alias Skua.Setup.{Plan, Prompt}

  @switches [
    theme: :string,
    auth: :string,
    pages: :boolean,
    seo: :boolean,
    strip_daisy: :boolean,
    yes: :boolean
  ]

  @auths ~w(none magic_link otp)
  @features ["starter pages", "SEO files (robots.txt + llms.txt)", "strip daisyUI"]

  @impl Mix.Task
  def run(args) do
    # `strict:` so a typo'd flag (e.g. --theem) is reported, not silently dropped
    # — this task mutates the project, so a swallowed flag would scaffold wrong.
    {flags, _, invalid} =
      OptionParser.parse(args, strict: [help: :boolean] ++ @switches, aliases: [h: :help])

    cond do
      flags[:help] ->
        Mix.Task.run("help", ["skua.setup"])

      invalid != [] ->
        Mix.raise(
          "Unknown option(s): #{Enum.map_join(invalid, ", ", fn {f, _} -> f end)}. " <>
            "Run `mix help skua.setup` for the flags."
        )

      Mix.Project.umbrella?() ->
        Mix.raise("Run `mix skua.setup` from inside your *_web app, not the umbrella root.")

      true ->
        do_setup(flags)
    end
  end

  defp do_setup(flags) do
    # Three modes:
    #   --yes            -> non-interactive from flags (the canonical agent/CI path)
    #   interactive TTY  -> the arrow-key wizard + a confirm
    #   no TTY, no --yes -> can't prompt (agent/CI/pipe): run from flags + defaults
    #                       and auto-proceed, so it NEVER blocks on stdin.
    {opts, confirm?} =
      cond do
        flags[:yes] ->
          {from_flags(flags), false}

        Prompt.interactive?() ->
          {wizard(flags), true}

        true ->
          Mix.shell().info([
            :yellow,
            "  Non-interactive terminal — running from flags + defaults ",
            "(pass --theme/--auth/--pages/--seo/--strip-daisy to customise, or --yes to silence this).",
            :reset
          ])

          {from_flags(flags), false}
      end

    Mix.shell().info([:cyan, "\n  Plan: ", :reset, Plan.summary(opts), "\n"])

    if not confirm? or Mix.shell().yes?("  Proceed?") do
      run_plan(Plan.build(opts))
      finish()
    else
      Mix.shell().info([:yellow, "  Aborted — nothing was changed.", :reset])
    end
  end

  # === wizard ===============================================================
  defp wizard(flags) do
    themes = ["none" | Skua.Themes.names()]
    theme_default = index_of(themes, flags[:theme] || "greenfield")
    theme = Prompt.select("Theme", themes, theme_default)

    auth_default = index_of(@auths, to_string(flags[:auth] || "otp"))
    auth = Prompt.select("Authentication", @auths, auth_default)

    chosen = Prompt.multiselect("Features", @features, @features)

    %{
      theme: if(theme == "none", do: nil, else: theme),
      auth: String.to_atom(auth),
      pages: Enum.at(@features, 0) in chosen,
      seo: Enum.at(@features, 1) in chosen,
      strip_daisy: Enum.at(@features, 2) in chosen
    }
  end

  # === non-interactive (--yes) =============================================
  defp from_flags(flags) do
    auth = to_string(flags[:auth] || "none")

    unless auth in @auths do
      Mix.raise("Unknown --auth #{inspect(auth)}. Use one of: #{Enum.join(@auths, ", ")}.")
    end

    theme = flags[:theme]

    if theme && not Skua.Themes.exists?(theme) do
      Mix.raise(
        "Unknown --theme #{inspect(theme)}. Run `mix skua.themes` to list them, or omit for none."
      )
    end

    %{
      theme: theme,
      auth: String.to_atom(auth),
      pages: Keyword.get(flags, :pages, true),
      seo: Keyword.get(flags, :seo, true),
      strip_daisy: Keyword.get(flags, :strip_daisy, true)
    }
  end

  # === run ==================================================================
  # Each step runs as a fresh `mix` subprocess rather than in-process: it isolates
  # Mix/Hex state (an in-process `deps.get` after a mix.exs change crashes with
  # `Hex.Mix` undefined), and it lets a later step see a dep an earlier step just
  # added (Hammer, after gen.auth). This is exactly what typing the commands does.
  defp run_plan(steps) do
    mix = System.find_executable("mix") || "mix"

    Enum.each(steps, fn {task, args} ->
      Mix.shell().info([:cyan, "\n  ▸ ", Enum.join(["mix", task | args], " "), :reset])

      {_, status} =
        System.cmd(mix, [task | args], into: IO.stream(:stdio, :line), stderr_to_stdout: true)

      if status != 0 do
        Mix.raise(
          "`mix #{task}` exited with status #{status}. Setup stopped — fix the error above and re-run."
        )
      end
    end)
  end

  defp finish do
    Mix.shell().info([
      :green,
      "\n  ✓ ",
      :reset,
      "Setup complete. Start your app with ",
      :bright,
      "mix phx.server",
      :reset,
      ".\n"
    ])
  end

  defp index_of(list, item), do: Enum.find_index(list, &(&1 == item)) || 0
end