defmodule Mix.Tasks.PhoenixKit.Update do use Mix.Task @moduledoc """ Updates PhoenixKit to the latest version. This task handles updating an existing PhoenixKit installation to the latest version by creating upgrade migrations that preserve existing data while adding new features. The update process also automatically: - Updates CSS configuration (enables daisyUI themes if disabled) - Rebuilds assets using the Phoenix asset pipeline - Applies database migrations (with optional interactive prompt) ## Usage $ mix phoenix_kit.update $ mix phoenix_kit.update --prefix=myapp $ mix phoenix_kit.update --status $ mix phoenix_kit.update --skip-assets $ mix phoenix_kit.update -y ## Options * `--prefix` - Database schema prefix (default: "public") * `--status` - Show current installation status and available updates * `--force` - Force update even if already up to date * `--skip-assets` - Skip automatic asset rebuild check * `--yes` / `-y` - Skip confirmation prompts and run migrations automatically ## Examples # Update PhoenixKit to latest version mix phoenix_kit.update # Check what version is installed and what updates are available mix phoenix_kit.update --status # Update with custom schema prefix mix phoenix_kit.update --prefix=auth # Update without prompts (useful for CI/CD) mix phoenix_kit.update -y # Force update with automatic migration mix phoenix_kit.update --force -y ## Version Management PhoenixKit uses a versioned migration system similar to Oban. Each version contains specific database schema changes that can be applied incrementally. Current version: V07 (latest version with comprehensive features) - V01: Basic authentication with role system - V02: Remove is_active column from role assignments (direct deletion) - V03-V07: Additional features and improvements (see migration files for details) ## Safe Updates All PhoenixKit updates are designed to be: - Non-destructive (existing data is preserved) - Backward compatible (existing code continues to work) - Idempotent (safe to run multiple times) - Rollback-capable (can be reverted if needed) """ alias PhoenixKit.Install.{AssetRebuild, Common, CssIntegration} alias PhoenixKit.Utils.Routes @shortdoc "Updates PhoenixKit to the latest version" @switches [ prefix: :string, status: :boolean, force: :boolean, skip_assets: :boolean, yes: :boolean ] @aliases [ p: :prefix, s: :status, f: :force, y: :yes ] @impl Mix.Task def run(argv) do # Ensure application is started for proper version detection Mix.Task.run("app.start") {opts, _argv, _errors} = OptionParser.parse(argv, switches: @switches, aliases: @aliases) if opts[:status] do show_status(opts) else perform_update(opts) end end # Show current installation status and available updates defp show_status(opts) do prefix = opts[:prefix] || "public" # Use the status command to show current status args = if prefix == "public", do: [], else: ["--prefix=#{prefix}"] Mix.Task.run("phoenix_kit.status", args) end # Handle not installed scenario defp handle_not_installed do Mix.shell().error(""" ❌ PhoenixKit is not installed. Please run: mix phoenix_kit.install """) end # Handle update check logic defp handle_update_check(prefix, current_version, force, skip_assets, yes) do target_version = Common.current_version() cond do current_version >= target_version && !force -> handle_already_up_to_date(current_version) current_version < target_version || force -> handle_update_needed(prefix, current_version, target_version, force, skip_assets, yes) true -> Mix.shell().info("No update needed.") end end # Handle already up to date scenario defp handle_already_up_to_date(current_version) do Mix.shell().info(""" ✅ PhoenixKit is already up to date (V#{pad_version(current_version)}). Use --force to regenerate the migration anyway. """) end # Handle update needed scenario defp handle_update_needed(prefix, current_version, target_version, force, skip_assets, yes) do migration_file = create_update_migration(prefix, current_version, target_version, force) # Update CSS integration (enables daisyUI themes if disabled) update_css_integration() # Always rebuild assets unless explicitly skipped unless skip_assets do AssetRebuild.check_and_rebuild(verbose: true) end # Run interactive migration execution run_update_migration_interactive(migration_file, yes) end # Update CSS integration during PhoenixKit updates defp update_css_integration do css_paths = [ "assets/css/app.css", "priv/static/css/app.css", "lib/#{Mix.Phoenix.otp_app()}_web/assets/css/app.css" ] case Enum.find(css_paths, &File.exists?/1) do nil -> # No app.css found - skip CSS integration :ok css_path -> # Update CSS file to enable daisyUI themes if disabled content = File.read!(css_path) existing = CssIntegration.check_existing_integration(content) if existing.daisyui_themes_disabled do # Use regex to update themes: false -> themes: all pattern = ~r/@plugin\s+(["'][^"']*daisyui["'])\s*\{([^}]*themes:\s*)false([^}]*)\}/ updated_content = String.replace(content, pattern, fn match -> String.replace(match, ~r/(themes:\s*)false/, "\\1all") end) File.write!(css_path, updated_content) Mix.shell().info(""" ✅ Updated daisyUI configuration to enable all themes! File: #{css_path} Changed: themes: false → themes: all """) end end rescue error -> # Non-critical error - log and continue Mix.shell().info("ℹ️ Could not update CSS integration: #{inspect(error)}") end # Perform the actual update defp perform_update(opts) do prefix = opts[:prefix] || "public" force = opts[:force] || false skip_assets = opts[:skip_assets] || false yes = opts[:yes] || false case Common.check_installation_status(prefix) do {:not_installed} -> handle_not_installed() {:current_version, current_version} -> handle_update_check(prefix, current_version, force, skip_assets, yes) end end # Create update migration from current to target version defp create_update_migration(prefix, current_version, target_version, force) do create_schema = prefix != "public" # Ensure migrations directory exists migrations_dir = "priv/repo/migrations" File.mkdir_p!(migrations_dir) # Generate timestamp and migration file name using Ecto format timestamp = generate_timestamp() action = if force, do: "force_update", else: "update" migration_name = "#{timestamp}_phoenix_kit_#{action}_v#{pad_version(current_version)}_to_v#{pad_version(target_version)}.exs" migration_file = Path.join(migrations_dir, migration_name) # Generate module name module_name = "PhoenixKit#{String.capitalize(action)}V#{pad_version(current_version)}ToV#{pad_version(target_version)}" # Create migration content migration_content = """ defmodule Ecto.Migrations.#{module_name} do @moduledoc false use Ecto.Migration def up do # PhoenixKit Update Migration: V#{pad_version(current_version)} -> V#{pad_version(target_version)} PhoenixKit.Migrations.up([ prefix: "#{prefix}", version: #{target_version}, create_schema: #{create_schema} ]) end def down do # Rollback PhoenixKit to V#{pad_version(current_version)} PhoenixKit.Migrations.down([ prefix: "#{prefix}", version: #{current_version} ]) end end """ # Write migration file File.write!(migration_file, migration_content) # Show brief success notice Mix.shell().info(""" 📦 PhoenixKit Update Migration Created: #{migration_name} - Updating from V#{pad_version(current_version)} to V#{pad_version(target_version)} """) # Return migration file for interactive execution migration_name end # Run interactive migration execution (similar to install command) defp run_update_migration_interactive(migration_file, yes) do # Check if we can run migrations safely case check_migration_conditions() do :ok -> run_interactive_migration_prompt(migration_file, yes) {:error, reason} -> if yes do # If -y flag is used but conditions aren't met, try to run migration anyway Mix.shell().info( "\n⚠️ Migration conditions not optimal (#{reason}), but running due to -y flag..." ) run_migration_with_feedback() else Mix.shell().info(""" 💡 Migration not run automatically (#{reason}). To run migration manually: mix ecto.migrate """) end end end # Check if migration can be run interactively defp check_migration_conditions do # Check if we have an app name case Mix.Project.config()[:app] do nil -> {:error, "No app name found"} _app -> # Check if we're in interactive environment if System.get_env("CI") || !System.get_env("TERM") do {:error, "Non-interactive environment"} else :ok end end rescue _ -> {:error, "Error checking conditions"} end # Prompt user for migration execution defp run_interactive_migration_prompt(_migration_file, yes) do if yes do # Skip prompt and run migration directly Mix.shell().info("\n🚀 Running database migration automatically (--yes flag)...") run_migration_with_feedback() else Mix.shell().info(""" 🚀 Would you like to run the database migration now? This will update your PhoenixKit installation. Options: - y/yes: Run 'mix ecto.migrate' now - n/no: Skip migration (you can run it manually later) """) case Mix.shell().prompt("Run migration? [Y/n]") |> String.trim() |> String.downcase() do response when response in ["", "y", "yes"] -> run_migration_with_feedback() _ -> Mix.shell().info(""" ⚠️ Migration skipped. To run it manually later: mix ecto.migrate """) end end end # Execute migration with feedback defp run_migration_with_feedback do Mix.shell().info("\n⏳ Running database migration...") try do case System.cmd("mix", ["ecto.migrate"], stderr_to_stdout: true) do {output, 0} -> Mix.shell().info("\n✅ Migration completed successfully!") Mix.shell().info(output) show_update_success_notice() {output, _} -> Mix.shell().info("\n❌ Migration failed:") Mix.shell().info(output) show_manual_migration_instructions() end rescue error -> Mix.shell().info("\n⚠️ Migration execution failed: #{inspect(error)}") show_manual_migration_instructions() end end # Show success notice after update defp show_update_success_notice do Mix.shell().info(""" 🎉 PhoenixKit updated successfully! Visit: #{Routes.path("/users/register")} """) end # Show manual migration instructions defp show_manual_migration_instructions do Mix.shell().info(""" Please run the migration manually: mix ecto.migrate Then start your server: mix phx.server """) end # Generate timestamp in Ecto migration format (same as phoenix_kit.install.ex) defp generate_timestamp do {{y, m, d}, {hh, mm, ss}} = :calendar.universal_time() "#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}" end defp pad(i) when i < 10, do: <> defp pad(i), do: to_string(i) # Pad version number for consistent naming defp pad_version(version) when version < 10, do: "0#{version}" defp pad_version(version), do: to_string(version) end