lib/mix/tasks/joby_kit.new.ex

defmodule Mix.Tasks.JobyKit.New do
  @shortdoc "Create a new Phoenix app with JobyKit baked in from minute one"

  @moduledoc """
  Wraps `mix phx.new` and replaces Phoenix's default HTML scaffolding
  with the JobyKit HTML layer.

  Why we don't pass `--no-html`: that flag also removes the asset
  pipeline (esbuild, tailwind, watchers, `Plug.Static`), which we still
  need. We let Phoenix generate everything and then overwrite the few
  files the kit owns (`<app>_web.ex`, layouts, router, and the
  PageController/PageHTML modules that go away in favor of `HomeLive`).

  ## Usage

      mix joby_kit.new <app_name> [phx.new flags] [joby_kit flags]

  ## Running from anywhere (Mix archive install)

  Install the kit as a global Mix archive so the task works from any
  directory:

      mix archive.install hex joby_kit

  After that, from anywhere:

      mix joby_kit.new my_app

  The wrapper writes `{:joby_kit, "~> X.Y"}` into the new app's
  `mix.exs`. To use a local checkout (e.g. when developing the kit
  itself), pass `--joby-kit-path /path/to/joby_kit` to write a path
  dep instead.

  ## Phoenix flags forwarded

  Pass-through to `mix phx.new`: `--database`, `--binary-id`,
  `--module`, `--app`, `--no-ecto`, `--no-mailer`, `--no-dashboard`,
  `--no-gettext`, `--verbose`. The wrapper always forces `--no-install`
  (we add `:joby_kit` and run `mix deps.get` ourselves so the kit is in
  the dep list before the fetch).

  ## JobyKit-specific flags

    * `--joby-kit-path <path>` — absolute path to a local JobyKit
      checkout. Writes `{:joby_kit, path: "..."}` into the new app's
      mix.exs. Useful when developing the kit itself. Defaults to
      detecting from the calling project's `Mix.Project.deps_paths()`,
      then falls back to a hex dep if no local checkout is found.

  ## What the task does

    1. Shells out: `mix phx.new <app_name> [forwarded flags] --no-install`.
    2. Adds `{:joby_kit, ...}` to the new app's mix.exs (hex dep by
       default, path dep when `--joby-kit-path` is given).
    3. Runs `mix deps.get` inside the new app.
    4. Replaces the kit-owned HTML files:
        * `lib/<app>_web.ex` — JobyKit-flavored, imports `JobyKit.CoreComponents`.
        * `lib/<app>_web/components/layouts.ex` — uses `JobyKit.NavComponent.simple_nav`
          and `JobyKit.CoreComponents.flash_group`.
        * `lib/<app>_web/components/layouts/root.html.heex`.
    5. Deletes the now-redundant Phoenix scaffolding: `<app>_web/components/core_components.ex`,
       `<app>_web/controllers/page_controller.ex`, `<app>_web/controllers/page_html.ex`,
       and `<app>_web/controllers/page_html/`.
    6. Runs `mix joby_kit.install` (manifest, previews, design pages, AGENTS.md patches).
    7. Generates `HomeLive` and replaces `router.ex` with a JobyKit-wired version.
    8. Runs `mix assets.setup` and `mix assets.build` so `priv/static/assets/`
       is populated before the first `mix phx.server`.

  After this task completes:

      cd <app_name>
      mix phx.server

  Then visit `http://localhost:4000/`.
  """

  use Mix.Task

  @switches [
    joby_kit_path: :string,
    # Phoenix pass-through flags. --install / --no-install are
    # intentionally absent: we always force --no-install on phx.new and
    # run deps.get ourselves so :joby_kit is in the list before the
    # fetch.
    database: :string,
    binary_id: :boolean,
    module: :string,
    app: :string,
    no_ecto: :boolean,
    no_mailer: :boolean,
    no_dashboard: :boolean,
    no_gettext: :boolean,
    verbose: :boolean
  ]

  @impl Mix.Task
  def run(argv) do
    {opts, positional, _invalid} = OptionParser.parse(argv, switches: @switches)

    app_name = parse_app_name(positional)
    dep_spec = resolve_dep_spec(opts)

    shell_out_phx_new(app_name, opts)

    File.cd!(app_name, fn ->
      app_camel = Macro.camelize(app_name)
      web_module = opts[:module] || (app_camel <> "Web")
      web_path = Macro.underscore(web_module)

      assigns = [
        app: app_name,
        app_camel: app_camel,
        web_module: web_module,
        web_path: web_path,
        web_module_anchor: String.replace(web_path, "/", "")
      ]

      add_joby_kit_dep(dep_spec)
      run_subcmd("mix", ["deps.get"])

      generate_html_layer(assigns)
      delete_redundant_phoenix_scaffolding(assigns)

      Mix.Tasks.JobyKit.Install.run(["--web", web_module])

      # Path-dep mode only: rewrite the @source line in app.css to the
      # absolute kit path. For hex deps the install task's default
      # `@source "../../deps/joby_kit/lib"` resolves correctly because
      # Mix unpacks hex deps into deps/joby_kit/.
      case dep_spec do
        {:path, joby_kit_path} -> patch_app_css_for_path_dep(joby_kit_path)
        :hex -> :ok
      end

      generate_home_live(assigns)
      replace_router(assigns)

      # Install and build assets so `mix phx.server` works immediately
      # without a separate `mix setup`. Without this, the first request
      # 404s on /assets/app.css (the watchers haven't compiled yet).
      run_subcmd("mix", ["assets.setup"])
      run_subcmd("mix", ["assets.build"])

      print_summary(app_name, opts)
    end)
  end

  # -------------------------------------------------------- input parsing

  defp parse_app_name([app_name | _]) when is_binary(app_name) do
    if Regex.match?(~r/^[a-z][a-z0-9_]*$/, app_name) do
      app_name
    else
      Mix.raise(
        "app name must be snake_case (lowercase, digits, underscores). Got: #{inspect(app_name)}"
      )
    end
  end

  defp parse_app_name(_),
    do: Mix.raise("app name is required. Usage: mix joby_kit.new <app_name>")

  defp resolve_dep_spec(opts) do
    cond do
      opts[:joby_kit_path] ->
        {:path, Path.expand(opts[:joby_kit_path])}

      path = local_joby_kit_path() ->
        {:path, Path.expand(path)}

      true ->
        :hex
    end
  end

  defp local_joby_kit_path do
    # Mix.Project.deps_paths() raises when there's no surrounding Mix
    # project (e.g. running from an installed archive). Catch and fall
    # back to nil so the resolver returns :hex.
    try do
      Mix.Project.deps_paths() |> Map.get(:joby_kit)
    rescue
      _ -> nil
    end
  end

  # ----------------------------------------------------------- phx.new

  defp shell_out_phx_new(app_name, opts) do
    # We let Phoenix generate everything (including HTML scaffolding
    # and the asset pipeline) and overwrite/delete the kit-owned files
    # afterward. `--no-html` would also strip esbuild/tailwind/Plug.Static
    # which we need to keep. Forcing `--no-install` so :joby_kit ends up
    # in the dep list before deps.get runs.
    args = ["phx.new", app_name, "--no-install"] ++ phx_pass_through(opts)
    cmd = "mix " <> Enum.join(args, " ")
    Mix.shell().info([:cyan, "* running ", :reset, cmd])

    # Use Mix.shell().cmd/1 so the subprocess inherits the user's TTY.
    case Mix.shell().cmd(cmd) do
      0 -> :ok
      code -> Mix.raise("mix phx.new failed with exit code #{code}")
    end
  end

  defp phx_pass_through(opts) do
    flag = fn key, type ->
      case {Keyword.get(opts, key), type} do
        {nil, _} -> []
        {true, :boolean} -> ["--#{kebab(key)}"]
        {false, :boolean} -> []
        {value, :string} when is_binary(value) -> ["--#{kebab(key)}", value]
      end
    end

    # Note: --install and --no-install are intentionally NOT forwarded.
    # We always pass --no-install to phx.new and run deps.get ourselves.
    [
      flag.(:database, :string),
      flag.(:binary_id, :boolean),
      flag.(:module, :string),
      flag.(:app, :string),
      flag.(:no_ecto, :boolean),
      flag.(:no_mailer, :boolean),
      flag.(:no_dashboard, :boolean),
      flag.(:no_gettext, :boolean),
      flag.(:verbose, :boolean)
    ]
    |> List.flatten()
  end

  defp kebab(key), do: key |> Atom.to_string() |> String.replace("_", "-")

  # --------------------------------------------------- post-phx.new steps

  defp add_joby_kit_dep(dep_spec) do
    path = "mix.exs"
    source = File.read!(path)

    if String.contains?(source, ":joby_kit") do
      :ok
    else
      {dep_line, summary} = dep_line_and_summary(dep_spec)

      patched =
        Regex.replace(
          ~r/(defp deps do\s*\n\s*\[\s*\n)/,
          source,
          "\\1      #{dep_line}\n",
          global: false
        )

      File.write!(path, patched)
      Mix.shell().info([:green, "* updating ", :reset, "mix.exs (added :joby_kit #{summary})"])
    end
  end

  defp dep_line_and_summary({:path, joby_kit_path}),
    do: {~s|{:joby_kit, path: "#{joby_kit_path}"},|, "path dep"}

  defp dep_line_and_summary(:hex),
    do: {~s|{:joby_kit, "~> #{kit_version()}"},|, "hex dep ~> #{kit_version()}"}

  defp kit_version do
    # Read the kit's own version from its application spec. Fallback to
    # a known-stable major if loading fails for any reason.
    case Application.spec(:joby_kit, :vsn) do
      nil -> "0.1"
      vsn -> vsn |> to_string() |> minor_version()
    end
  end

  defp minor_version(version_string) do
    case String.split(version_string, ".") do
      [major, minor | _] -> "#{major}.#{minor}"
      _ -> "0.1"
    end
  end

  defp run_subcmd(cmd, args) do
    full = "#{cmd} #{Enum.join(args, " ")}"
    Mix.shell().info([:cyan, "* running ", :reset, full])

    # Use Mix.shell().cmd/1 so prompts (e.g. first-time hex/rebar
    # install) reach the user's TTY instead of hanging silently.
    case Mix.shell().cmd(full) do
      0 -> :ok
      code -> Mix.raise("#{full} failed with exit code #{code}")
    end
  end

  defp generate_html_layer(assigns) do
    web_path = assigns[:web_path]

    File.mkdir_p!("lib/#{web_path}/components/layouts")

    copy_template("web_module.ex", "lib/#{web_path}.ex", assigns)
    copy_template("layouts.ex", "lib/#{web_path}/components/layouts.ex", assigns)
    copy_template("layouts/root.html.heex", "lib/#{web_path}/components/layouts/root.html.heex", assigns)
  end

  defp patch_app_css_for_path_dep(joby_kit_path) do
    path = "assets/css/app.css"
    source = File.read!(path)
    absolute_source_line = ~s|@source "#{joby_kit_path}/lib";|

    cond do
      String.contains?(source, absolute_source_line) ->
        :ok

      String.contains?(source, ~s|@source "../../deps/joby_kit/lib";|) ->
        patched =
          String.replace(
            source,
            ~s|@source "../../deps/joby_kit/lib";|,
            absolute_source_line
          )

        File.write!(path, patched)

        Mix.shell().info([
          :green,
          "* updating ",
          :reset,
          "#{path} (rewrote @source to absolute kit path for path-dep mode)"
        ])

      true ->
        # Install hadn't patched (unusual). Add the absolute line directly.
        Mix.shell().error(
          "could not find the JobyKit @source line in #{path}; please add `#{absolute_source_line}` manually."
        )
    end
  end

  defp delete_redundant_phoenix_scaffolding(assigns) do
    web_path = assigns[:web_path]

    # core_components.ex — kit owns these now via JobyKit.CoreComponents.
    # The host's :html macro imports the kit module, not this file.
    File.rm("lib/#{web_path}/components/core_components.ex")

    # PageController + PageHTML — replaced by HomeLive at "/".
    File.rm("lib/#{web_path}/controllers/page_controller.ex")
    File.rm("lib/#{web_path}/controllers/page_html.ex")
    File.rm_rf!("lib/#{web_path}/controllers/page_html")

    # Test files for the deleted modules.
    File.rm("test/#{web_path}/controllers/page_controller_test.exs")

    Mix.shell().info([
      :red,
      "* removing ",
      :reset,
      "Phoenix scaffolding superseded by JobyKit (core_components, page_controller, page_html)"
    ])
  end

  defp generate_home_live(assigns) do
    web_path = assigns[:web_path]
    template = template_path("home_live.ex")
    dest = "lib/#{web_path}/live/home_live.ex"
    File.mkdir_p!(Path.dirname(dest))
    Mix.Generator.copy_template(template, dest, assigns, force: true)
  end

  defp replace_router(assigns) do
    web_path = assigns[:web_path]
    copy_template("router.ex", "lib/#{web_path}/router.ex", assigns)
  end

  defp copy_template(name, dest, assigns) do
    Mix.Generator.copy_template(template_path(name), dest, assigns, force: true)
  end

  defp template_path(name) do
    Application.app_dir(:joby_kit, ["priv", "templates", "joby_kit.new", name])
  end

  # ------------------------------------------------------------ summary

  defp print_summary(app_name, opts) do
    db_step =
      if Keyword.get(opts, :no_ecto, false) do
        ""
      else
        """
            mix ecto.setup       # creates the dev DB and runs migrations
        """
      end

    Mix.shell().info("""

    JobyKit app generated.

    Next:

        cd #{app_name}
    #{String.trim_trailing(db_step, "\n")}
        mix phx.server

    Then visit http://localhost:4000/.

    ── For the AI agent picking up this app ──────────────────────────

    Two files at the project root are loaded automatically by your
    coding agent — read them before any UI work:

      • CLAUDE.md  — the wrapper-contract diagnostics (auto-loaded by
        Claude Code)
      • AGENTS.md  — the full JobyKit guidelines, build order, and rationale

    Then consume these in order:

      1. curl http://localhost:4000/design.json
         (component manifest — what's already registered)
      2. Open http://localhost:4000/design
         (kit-curated wrappers: <.button>, <.input>, <.icon>, <.card>, …)
      3. Open http://localhost:4000/custom-designs
         (this app's composites — see <.empty_state> for a worked example)

    After writing UI:

        mix joby_kit.lint    # verifies the wrapper contract end-to-end

    ── Symptoms you skipped step 1 ───────────────────────────────────

    If any of these are true after writing UI, you bypassed the wrapper
    contract — stop and lift the offending markup into a wrapper:

      • You wrote `<button class="…">`/`<input>`/`<textarea>` in `.heex`
        when `<.button>`/`<.input>` exists.
      • You styled a private function component as if it were a primitive.
      • You added a new component without `data-component`, without
        `attr :rest, :global`, or without a `DesignManifest` entry.

    `mix joby_kit.lint` will catch all three. Run it before claiming done.
    """)
  end
end