defmodule Mix.Tasks.PhoenixKit.Status do @moduledoc """ Shows comprehensive status of PhoenixKit installation. This task provides a detailed overview of your PhoenixKit installation status, including version information, database connectivity, assets status, and suggested next actions. ## Usage $ mix phoenix_kit.status $ mix phoenix_kit.status --prefix=myapp ## Options * `--prefix` - Database schema prefix. When omitted, resolves from `config :phoenix_kit, :prefix`, then defaults to "public". * `--verbose` - Show detailed diagnostic information ## Examples # Show status for default installation mix phoenix_kit.status # Show status for custom schema prefix mix phoenix_kit.status --prefix=auth # Show detailed diagnostic information mix phoenix_kit.status --verbose ## Sample Output PhoenixKit v1.7.216 ├── Installed: V159 ✅ ├── Database: Connected ✅ ├── Modules: 2 modules, all up to date ✅ │ ├── Boards: V01 ✅ │ └── Inbox: V01 ✅ └── Next: Ready The `Modules` row covers PhoenixKit modules that own their migrations (`c:PhoenixKit.Module.migration_module/0`) — each reports the schema version installed in *your* database against the version its code expects. When one is behind, the report says so and `Next` points at the fix: PhoenixKit v1.7.216 ├── Installed: V159 ✅ ├── Database: Connected ✅ ├── Modules: 2 modules, 1 update available ⬆ │ ├── Boards: V01 ✅ │ └── Inbox: V01 → V02 available ⬆ └── Next: mix phoenix_kit.update (module schema behind: Inbox) The row is omitted entirely when no installed module owns migrations, so a core-only install keeps the compact tree. """ use Mix.Task alias Ecto.Adapters.SQL alias PhoenixKit.Config alias PhoenixKit.Install.Common alias PhoenixKit.Install.PrefixConfig alias PhoenixKit.Install.StatusTree alias PhoenixKit.Migrations.Modules, as: MigrationModules alias PhoenixKit.Migrations.Postgres @impl Mix.Task @spec run([String.t()]) :: :ok @shortdoc "Shows comprehensive PhoenixKit installation status" @switches [ prefix: :string, verbose: :boolean ] @aliases [ p: :prefix, v: :verbose ] def run(argv) do # Load configuration and compile without starting the full application. # Using --no-start avoids port conflicts when the app is already running. Mix.Task.run("app.start", ["--no-start"]) # Start only the dependencies needed for database queries {:ok, _} = Application.ensure_all_started(:ssl) {:ok, _} = Application.ensure_all_started(:postgrex) {:ok, _} = Application.ensure_all_started(:ecto_sql) {:ok, _} = Application.ensure_all_started(:phoenix_kit) {opts, _argv, _errors} = OptionParser.parse(argv, switches: @switches, aliases: @aliases) # --prefix option → config :phoenix_kit, :prefix → "public", so a # prefixed install doesn't report "Not installed" when the flag is # omitted. prefix = PrefixConfig.resolve_prefix(opts) verbose = opts[:verbose] || false show_comprehensive_status(prefix, verbose) end # Main status display function defp show_comprehensive_status(prefix, verbose) do # Get all status information phoenix_kit_version = get_phoenix_kit_version() installation_status = get_installation_status(prefix) database_status = get_database_status(prefix) # Modules that own their migrations report their own schema version. Only # queried when the database answered — otherwise every coordinator would # time out one after another producing a wall of identical errors. modules = module_entries(database_status, prefix) next_action = determine_next_action(installation_status, modules, prefix) # Display header IO.puts("\n#{IO.ANSI.bright()}PhoenixKit v#{phoenix_kit_version}#{IO.ANSI.reset()}") # Display status tree display_status_tree( [ {"Installed", format_installation_status(installation_status)}, {"Database", format_database_status(database_status)} ] ++ module_tree_rows(modules) ++ [{"Next", format_next_action(next_action)}] ) # Show verbose information if requested if verbose do show_verbose_diagnostics(prefix, installation_status, database_status, modules) end IO.puts("") end # ── Module schema versions ────────────────────────────────────────────────── # `:not_queried` is deliberately distinct from `[]`. Both used to collapse to # an empty list, so a host with modules installed but an unreachable database # was told "No installed module owns migrations" — a flat falsehood that # sends whoever is debugging a missing table down the wrong path. defp module_entries({:connected_with_tables, _version}, prefix), do: MigrationModules.list(prefix: prefix) defp module_entries(_database_status, _prefix), do: :not_queried # Renders as a labelled row with one child line per module: # # ├── Modules: 2 installed, 1 update available # │ ├── Boards: V01 ✅ # │ └── Inbox: V01 → V02 ⬆ # # Omitted entirely when no module owns migrations, so the common # core-only install keeps the compact three-line tree it had before — and # when the database never answered, since "we couldn't look" is already # covered by the Database row above. defp module_tree_rows(:not_queried), do: [] defp module_tree_rows([]), do: [] defp module_tree_rows(modules) do [{"Modules", format_modules_summary(modules), Enum.map(modules, &format_module_entry/1)}] end defp format_modules_summary(modules) do pending = MigrationModules.pending(modules) failed = MigrationModules.failed(modules) count = "#{length(modules)} #{pluralize(length(modules), "module", "modules")}" cond do failed != [] -> "#{IO.ANSI.red()}#{count}, #{length(failed)} unreadable ❌#{IO.ANSI.reset()}" pending != [] -> "#{IO.ANSI.yellow()}#{count}, #{length(pending)} #{pluralize(length(pending), "update", "updates")} available ⬆#{IO.ANSI.reset()}" true -> "#{IO.ANSI.green()}#{count}, all up to date ✅#{IO.ANSI.reset()}" end end defp format_module_entry(%{status: :up_to_date} = entry) do "#{entry.name}: #{IO.ANSI.green()}V#{pad_version(entry.installed)} ✅#{IO.ANSI.reset()}" end defp format_module_entry(%{status: :needs_update} = entry) do "#{entry.name}: #{IO.ANSI.yellow()}V#{pad_version(entry.installed)} → V#{pad_version(entry.target)} available ⬆#{IO.ANSI.reset()}" end defp format_module_entry(%{status: :not_installed} = entry) do "#{entry.name}: #{IO.ANSI.yellow()}not installed → V#{pad_version(entry.target)} available ⬆#{IO.ANSI.reset()}" end defp format_module_entry(%{status: :error} = entry) do "#{entry.name}: #{IO.ANSI.red()}unreadable ❌#{IO.ANSI.reset()} (#{entry.error})" end defp pluralize(1, singular, _plural), do: singular defp pluralize(_count, _singular, plural), do: plural # Get PhoenixKit module version defp get_phoenix_kit_version do case :application.get_key(:phoenix_kit, :vsn) do {:ok, vsn} when is_list(vsn) -> List.to_string(vsn) {:ok, vsn} -> to_string(vsn) :undefined -> "unknown" end end # Get installation status, with self-healing for version comment bugs defp get_installation_status(prefix) do case Common.check_installation_status(prefix) do {:not_installed} -> {:not_installed} {:unreachable, reason} -> {:unreachable, reason} {:current_version, version} -> target_version = Postgres.current_version() if version >= target_version do {:up_to_date, version} else maybe_heal_and_check(version, target_version, prefix) end end end # Attempt to heal version comment if migrations ran but comment was not updated defp maybe_heal_and_check(version, target_version, prefix) do opts = %{prefix: prefix, escaped_prefix: String.replace(prefix, "'", "\\'")} case Postgres.heal_version_comment(version, opts) do {:healed, healed_version} -> IO.puts( "#{IO.ANSI.yellow()}[healed] Version comment corrected from V#{pad_version(version)} to V#{pad_version(healed_version)}#{IO.ANSI.reset()}" ) compare_versions(healed_version, target_version) :ok -> {:needs_update, version, target_version} end end defp compare_versions(version, target_version) do if version >= target_version do {:up_to_date, version} else {:needs_update, version, target_version} end end # Get database connectivity status with hybrid repo detection defp get_database_status(prefix) do case get_repo_with_fallback() do nil -> {:no_repo_configured} repo -> test_repo_and_tables(repo, prefix) end rescue error -> {:error, error} end # Test repository connection and check for PhoenixKit tables defp test_repo_and_tables(repo, prefix) do case ensure_repo_started(repo) do :ok -> test_database_connection(repo, prefix) {:error, reason} -> {:connection_error, reason} end end # Test database connection and check tables defp test_database_connection(repo, prefix) do case SQL.query(repo, "SELECT 1", []) do {:ok, _result} -> check_phoenix_kit_tables(prefix) {:error, reason} -> {:connection_error, reason} end end # Helper function to check if PhoenixKit tables exist defp check_phoenix_kit_tables(prefix) do opts = %{prefix: prefix, escaped_prefix: String.replace(prefix, "'", "\\'")} version = Postgres.migrated_version_runtime(opts) if version > 0 do {:connected_with_tables, version} else {:connected_no_tables} end end # Determine next recommended action. # # Module schema versions participate: a host whose core is current but whose # `phoenix_kit_inbox` tables are a version behind still needs to run # `mix phoenix_kit.update`, and used to be told "Ready". defp determine_next_action({:not_installed}, _modules, _prefix) do {:install, "mix igniter.install phoenix_kit"} end defp determine_next_action({:unreachable, _reason}, _modules, _prefix) do {:fix_connection, "Fix the database connection, then re-run mix phoenix_kit.status"} end defp determine_next_action({:needs_update, _current, _target}, _modules, prefix) do {:update, update_command(prefix)} end defp determine_next_action({:up_to_date, _version}, :not_queried, _prefix) do {:ready, "Ready"} end defp determine_next_action({:up_to_date, _version}, modules, prefix) do pending = MigrationModules.pending(modules) failed = MigrationModules.failed(modules) cond do # An unreadable module is NOT pending — migrating a module whose version # we cannot read would be worse than not touching it. But it must not # report "Ready" either: its tables may well be behind, and a tree that # says "1 unreadable ❌" one line above "Next: Ready" tells the operator # there is nothing to do. failed != [] -> {:check_modules, Enum.map(failed, & &1.name)} pending != [] -> {:update_modules, update_command(prefix), Enum.map(pending, & &1.name)} true -> {:ready, "Ready"} end end defp update_command("public"), do: "mix phoenix_kit.update" defp update_command(prefix), do: "mix phoenix_kit.update --prefix=#{prefix}" # Format installation status for display defp format_installation_status({:not_installed}) do "#{IO.ANSI.red()}Not installed#{IO.ANSI.reset()}" end defp format_installation_status({:unreachable, _reason}) do "#{IO.ANSI.yellow()}Unknown — database unreachable#{IO.ANSI.reset()}" end defp format_installation_status({:up_to_date, version}) do "#{IO.ANSI.green()}V#{pad_version(version)} ✅#{IO.ANSI.reset()}" end defp format_installation_status({:needs_update, current, target}) do "#{IO.ANSI.yellow()}V#{pad_version(current)} (needs update to V#{pad_version(target)})#{IO.ANSI.reset()}" end # Format database status for display defp format_database_status({:connected_with_tables, _version}) do "#{IO.ANSI.green()}Connected ✅#{IO.ANSI.reset()}" end defp format_database_status({:connected_no_tables}) do "#{IO.ANSI.yellow()}Connected (no tables)#{IO.ANSI.reset()}" end defp format_database_status({:connection_error, _reason}) do "#{IO.ANSI.red()}Connection failed ❌#{IO.ANSI.reset()}" end defp format_database_status({:no_repo_configured}) do "#{IO.ANSI.red()}No repo configured ❌#{IO.ANSI.reset()}" end defp format_database_status({:error, _error}) do "#{IO.ANSI.red()}Error ❌#{IO.ANSI.reset()}" end # Format next action for display defp format_next_action({:install, command}) do "#{IO.ANSI.cyan()}#{command}#{IO.ANSI.reset()}" end defp format_next_action({:update, command}) do "#{IO.ANSI.cyan()}#{command}#{IO.ANSI.reset()}" end defp format_next_action({:update_modules, command, names}) do "#{IO.ANSI.cyan()}#{command}#{IO.ANSI.reset()} #{IO.ANSI.faint()}(module schema behind: #{Enum.join(names, ", ")})#{IO.ANSI.reset()}" end defp format_next_action({:check_modules, names}) do "#{IO.ANSI.red()}Check the unreadable module(s): #{Enum.join(names, ", ")}#{IO.ANSI.reset()} " <> "#{IO.ANSI.faint()}(schema version unknown — run with --verbose)#{IO.ANSI.reset()}" end defp format_next_action({:ready, message}) do "#{IO.ANSI.green()}#{message}#{IO.ANSI.reset()}" end defp format_next_action({:fix_connection, message}) do "#{IO.ANSI.yellow()}#{message}#{IO.ANSI.reset()}" end # Layout lives in PhoenixKit.Install.StatusTree so it can be unit tested # without a database; this only supplies the ANSI label styling and prints. defp display_status_tree(items) do items |> StatusTree.render(label_format: &"#{IO.ANSI.bright()}#{&1}#{IO.ANSI.reset()}") |> IO.puts() end # Show detailed diagnostic information defp show_verbose_diagnostics(prefix, installation_status, database_status, modules) do IO.puts("\n#{IO.ANSI.bright()}═══ Detailed Diagnostics ═══#{IO.ANSI.reset()}") show_installation_diagnostics(installation_status, prefix) show_module_diagnostics(modules) show_database_diagnostics(database_status, prefix) show_configuration_diagnostics() end # Per-module detail: which coordinator reports the version, and the exact # numbers behind the summary line. defp show_module_diagnostics(:not_queried) do IO.puts("\n#{IO.ANSI.bright()}Modules:#{IO.ANSI.reset()}") IO.puts(" Not queried — the database did not answer, so module schema") IO.puts(" versions are unknown. This is not the same as having none.") end defp show_module_diagnostics([]) do IO.puts("\n#{IO.ANSI.bright()}Modules:#{IO.ANSI.reset()}") IO.puts(" No installed module owns migrations.") end defp show_module_diagnostics(modules) do IO.puts("\n#{IO.ANSI.bright()}Modules:#{IO.ANSI.reset()}") Enum.each(modules, fn entry -> IO.puts(" #{entry.name} (#{inspect(entry.module)})") IO.puts(" Coordinator: #{inspect(entry.migration_module)}") case entry.status do :error -> IO.puts(" Version: unreadable — #{entry.error}") _ -> IO.puts(" Installed: V#{pad_version(entry.installed)}") IO.puts(" Target: V#{pad_version(entry.target)}") end end) end # Show installation diagnostics defp show_installation_diagnostics(status, prefix) do IO.puts("\n#{IO.ANSI.bright()}Installation:#{IO.ANSI.reset()}") IO.puts(" Schema prefix: #{prefix}") case status do {:not_installed} -> IO.puts(" Migration files: #{inspect(Common.find_existing_phoenix_kit_migrations())}") {:unreachable, reason} -> IO.puts(" Database unreachable: #{inspect(reason)}") {:up_to_date, version} -> IO.puts(" Current version: V#{pad_version(version)}") IO.puts(" Target version: V#{pad_version(Postgres.current_version())}") {:needs_update, current, target} -> IO.puts(" Current version: V#{pad_version(current)}") IO.puts(" Target version: V#{pad_version(target)}") changes = Common.describe_version_changes(current, target) IO.puts(" Available changes:") String.split(changes, "\n") |> Enum.each(&IO.puts(" #{&1}")) end end # Show database diagnostics defp show_database_diagnostics(status, _prefix) do IO.puts("\n#{IO.ANSI.bright()}Database:#{IO.ANSI.reset()}") # Show how repo was detected show_repo_detection_info() case status do {:connected_with_tables, version} -> IO.puts(" Connection: OK") IO.puts(" PhoenixKit tables: Present (V#{pad_version(version)})") {:connected_no_tables} -> IO.puts(" Connection: OK") IO.puts(" PhoenixKit tables: Missing") {:connection_error, reason} -> IO.puts(" Connection: Failed") IO.puts(" Error: #{inspect(reason)}") {:no_repo_configured} -> IO.puts(" Connection: No repo configured") show_repo_fallback_attempts() {:error, error} -> IO.puts(" Connection: Error") IO.puts(" Details: #{inspect(error)}") end end # Show detailed repo detection information defp show_repo_detection_info do phoenix_kit_repo = Config.get(:repo, nil) IO.puts(" PhoenixKit repo config: #{inspect(phoenix_kit_repo)}") if phoenix_kit_repo do show_configured_repo_info(phoenix_kit_repo) else show_fallback_repo_info() end end # Show information about configured repo defp show_configured_repo_info(repo) do IO.puts(" Detection method: PhoenixKit application config") IO.puts(" Testing repo connection...") test_and_report_repo_connection(repo) end # Show information about fallback repo detection defp show_fallback_repo_info do started_repo = ensure_phoenix_kit_started() IO.puts(" After starting PhoenixKit app: #{inspect(started_repo)}") detected_repo = detect_repo_from_project() if detected_repo do show_detected_repo_info(detected_repo) else IO.puts(" No repo detected via fallback methods") end end # Show information about detected repo defp show_detected_repo_info(repo) do parent_app_name = Mix.Project.config()[:app] IO.puts(" Fallback repo detected: #{inspect(repo)}") # Show which method worked if try_ecto_repos_config(parent_app_name) do IO.puts(" Detection method: :ecto_repos config") else IO.puts(" Detection method: Naming pattern") end IO.puts(" Testing repo connection...") test_and_report_repo_connection(repo) end # Test repo connection and report results defp test_and_report_repo_connection(repo) do case test_repo_connection(repo) do :ok -> IO.puts(" Repo connection test: PASSED") {:error, reason} -> IO.puts(" Repo connection test: FAILED - #{inspect(reason)}") end end # Test repo connection defp test_repo_connection(repo) do # Ensure repo is started first case ensure_repo_started(repo) do :ok -> case SQL.query(repo, "SELECT 1", []) do {:ok, _result} -> :ok {:error, reason} -> {:error, reason} end {:error, reason} -> {:error, reason} end rescue error -> {:error, error} end # Show fallback attempts when repo is not configured defp show_repo_fallback_attempts do parent_app_name = Mix.Project.config()[:app] IO.puts(" Fallback attempts:") IO.puts(" - PhoenixKit config: #{inspect(Config.get(:repo, nil))}") IO.puts( " - App :ecto_repos: #{inspect(Application.get_env(parent_app_name, :ecto_repos, []))}" ) if parent_app_name do expected_repo = Module.concat([Macro.camelize(to_string(parent_app_name)), "Repo"]) IO.puts(" - Naming pattern (#{expected_repo}): #{ensure_repo_loaded?(expected_repo)}") end end # Show configuration diagnostics defp show_configuration_diagnostics do IO.puts("\n#{IO.ANSI.bright()}Configuration:#{IO.ANSI.reset()}") # Check layout configuration layout_config = PhoenixKit.Config.get(:layout) IO.puts( " Layout integration: #{if layout_config != :not_found, do: "Configured", else: "Using defaults"}" ) # Check mailer configuration mailer_config = PhoenixKit.Config.get(PhoenixKit.Mailer) IO.puts( " Mailer: #{if mailer_config != :not_found, do: "Configured", else: "Not configured"}" ) end # Hybrid repo detection with fallback strategies defp get_repo_with_fallback do # Strategy 1: Try to get from PhoenixKit application config case Config.get(:repo, nil) do nil -> # Strategy 2: Try to ensure PhoenixKit application is started case ensure_phoenix_kit_started() do repo when not is_nil(repo) -> repo nil -> # Strategy 3: Auto-detect from project configuration detect_repo_from_project() end repo -> repo end end # Try to start PhoenixKit application and get repo config defp ensure_phoenix_kit_started do Application.ensure_all_started(:phoenix_kit) Config.get(:repo, nil) rescue _ -> nil end # Auto-detect repository from project configuration (similar to RepoDetection) defp detect_repo_from_project do parent_app_name = Mix.Project.config()[:app] # Try :ecto_repos config first case try_ecto_repos_config(parent_app_name) do nil -> try_naming_patterns(parent_app_name) repo -> repo end end # Try to get repo from :ecto_repos application config defp try_ecto_repos_config(nil), do: nil defp try_ecto_repos_config(app_name) do case Application.get_env(app_name, :ecto_repos, []) do [repo | _] when is_atom(repo) -> if ensure_repo_loaded?(repo), do: repo, else: nil [] -> nil end rescue _ -> nil end # Try common naming patterns defp try_naming_patterns(nil), do: nil defp try_naming_patterns(app_name) do # Try most common pattern: AppName.Repo repo_module = Module.concat([Macro.camelize(to_string(app_name)), "Repo"]) if ensure_repo_loaded?(repo_module) do repo_module else nil end end # Check if repo module exists and is loaded defp ensure_repo_loaded?(repo) when is_atom(repo) and not is_nil(repo) do Code.ensure_loaded?(repo) && function_exported?(repo, :__adapter__, 0) rescue _ -> false end defp ensure_repo_loaded?(_), do: false # Ensure repo is properly started for database operations. # Since we use --no-start, the repo may not be running yet. # We start repo with its application config so it gets the connection params. defp ensure_repo_started(repo) do if repo_available?(repo) do :ok else parent_app = Mix.Project.config()[:app] config = Application.get_env(parent_app, repo, []) case repo.start_link(config) do {:ok, _pid} -> :ok {:error, {:already_started, _pid}} -> :ok {:error, reason} -> {:error, "Failed to start #{inspect(repo)}: #{inspect(reason)}"} end end end # Check if repo module is available and started defp repo_available?(repo) do Code.ensure_loaded?(repo) && function_exported?(repo, :__adapter__, 0) && Process.whereis(repo) != nil rescue _ -> false end # Pad version number for consistent display defp pad_version(version) when version < 10, do: "0#{version}" defp pad_version(version), do: to_string(version) end