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