Packages

phoenix_kit

1.7.85
1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib mix tasks phoenix_kit.update.ex
Raw

lib/mix/tasks/phoenix_kit.update.ex

# dialyzer: no_missing_calls
if Code.ensure_loaded?(Igniter.Mix.Task) do
defmodule Mix.Tasks.PhoenixKit.Update do
@moduledoc """
Igniter-based updater for PhoenixKit.
This task handles updating an existing PhoenixKit installation to the latest version
by creating upgrade migrations that preserve existing data while adding new features.
## Two-Pass Update Strategy
To prevent configuration timing issues, the update process uses a two-pass strategy:
1. **First Pass** (if configuration is missing): Adds required configuration (e.g.,
Ueberauth settings) via Igniter and prompts you to run the command again.
2. **Second Pass** (configuration present): Safely starts the application and
completes the update process.
This ensures that the application always starts with all required configuration
present, avoiding runtime errors from missing dependencies.
## Automatic Updates
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. Each version contains specific
database schema changes that can be applied incrementally.
Current version: V17 (latest version with comprehensive features)
- V01: Basic authentication with role system
- V02: Remove is_active column from role assignments (direct deletion)
- V03-V17: 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)
"""
use Igniter.Mix.Task
alias Igniter.Project.Config
alias PhoenixKit.Install.{
ApplicationSupervisor,
AssetRebuild,
BasicConfiguration,
Common,
CssIntegration,
DbConnectionCheck,
IgniterHelpers,
ObanConfig,
RateLimiterConfig
}
alias PhoenixKit.Migrations.Postgres, as: MigrationsPostgres
alias PhoenixKit.Migrations.UUIDRepair
alias PhoenixKit.Utils.Routes
@shortdoc "Updates PhoenixKit to the latest version"
@impl Igniter.Mix.Task
def info(_argv, _composing_task) do
%Igniter.Mix.Task.Info{
group: :phoenix_kit,
example: "mix phoenix_kit.update --prefix auth --force",
positional: [],
schema: [
prefix: :string,
status: :boolean,
force: :boolean,
skip_assets: :boolean,
yes: :boolean
],
aliases: [
p: :prefix,
s: :status,
f: :force,
y: :yes
]
}
end
@impl Igniter.Mix.Task
def igniter(igniter) do
opts = igniter.args.options
# Handle --status flag
if opts[:status] do
show_status(opts)
igniter
else
igniter
|> BasicConfiguration.add_basic_config()
|> ApplicationSupervisor.add_supervisor()
|> perform_igniter_update(opts)
end
end
# Override run/1 to handle post-igniter interactive migration and asset rebuild
def run(argv) do
# Handle --help flag
if "--help" in argv or "-h" in argv do
show_help()
:ok
else
# Store options in process dictionary for later use
opts =
OptionParser.parse(argv,
switches: [
prefix: :string,
status: :boolean,
force: :boolean,
skip_assets: :boolean,
yes: :boolean
],
aliases: [
p: :prefix,
s: :status,
f: :force,
y: :yes
]
)
# If --status flag, handle directly and exit
if Keyword.get(elem(opts, 0), :status) do
show_status(elem(opts, 0))
:ok
else
# CRITICAL: Check if required configuration exists BEFORE starting app
# This prevents configuration timing issues where config is added via Igniter
# but the app has already started with cached (missing) configuration
# Check if this is a retry pass (automatic restart after adding config)
is_retry = Process.get(:phoenix_kit_retry_pass, false)
config_status = check_required_configuration()
case {config_status, is_retry} do
{:missing, false} ->
# First pass: Add configuration via Igniter without starting app
# Store config status in Process dictionary for igniter/1 to read
Process.put(:phoenix_kit_config_status, :missing)
show_missing_config_message(argv)
super(argv)
# Automatic restart instead of manual prompt
Mix.shell().info("""
✅ Configuration added successfully!
🔄 Automatically restarting to complete the update...
""")
# Clean Process dictionary for fresh state
Process.delete(:phoenix_kit_config_status)
Process.put(:phoenix_kit_retry_pass, true)
# Recursive call with same arguments
run(argv)
{:ok, _} ->
# Second pass (automatic or manual): Configuration exists, safe to start app
# Store config status in Process dictionary for igniter/1 to read
Process.put(:phoenix_kit_config_status, :ok)
# Cap the Ecto pool to 2 connections so we don't saturate PgBouncer
# when the production app is already running.
#
# The sequencing is critical:
# 1. Run app.config first — this evaluates config/runtime.exs (which
# reads POOL_SIZE env and sets pool_size: N in Application env).
# 2. THEN override pool_size to 2 via Application.put_env, after
# runtime.exs has already run and can no longer overwrite us.
# 3. THEN start app — app.config won't run again (Mix tracks ran tasks),
# so Ecto initialises the pool with our capped pool_size: 2.
Mix.Task.run("app.config")
cap_repo_pool_size_for_update(2)
# Tell PhoenixKit.Supervisor to skip Dashboard.Registry,
# OAuthConfigLoader, and module workers so they don't compete
# for the 2 available DB connections during startup.
Application.put_env(:phoenix_kit, :update_mode, true)
Mix.Task.run("app.start")
# Verify database is reachable before running update
DbConnectionCheck.ensure_connected!()
result = super(argv)
post_igniter_tasks(elem(opts, 0))
# Clean retry flag
Process.delete(:phoenix_kit_retry_pass)
result
{:missing, true} ->
# Safety: Configuration still missing after retry
Mix.shell().error("""
❌ Configuration was not added successfully after automatic retry.
This may indicate a problem with your config/config.exs file.
Please check the file manually and ensure it's writable.
Then run manually:
mix phoenix_kit.update #{Enum.join(argv, " ")}
""")
Process.delete(:phoenix_kit_retry_pass)
:error
end
end
end
end
# Display message about missing configuration
defp show_missing_config_message(argv) do
Mix.shell().info("""
⚠️ Required configuration is missing from config/config.exs
PhoenixKit requires configuration for:
- Ueberauth (OAuth authentication)
- Hammer (rate limiting)
- Oban (background jobs for file processing)
This configuration will be added now.
After this completes, please run the update command again:
mix phoenix_kit.update #{Enum.join(argv, " ")}
""")
end
# Check if all required configuration exists
# Returns :ok if all config present, :missing if any config is missing
defp check_required_configuration do
config_file = "config/config.exs"
if File.exists?(config_file) do
content = File.read!(config_file)
lines = String.split(content, "\n")
cond do
# Missing Ueberauth configuration entirely
!String.contains?(content, "config :ueberauth") ->
:missing
# Incorrect Ueberauth configuration (providers: [] instead of providers: %{})
String.contains?(content, "config :ueberauth, Ueberauth") &&
Regex.match?(~r/providers:\s*\[\s*\]/, content) ->
:missing
# Missing Hammer configuration (check for active, non-commented config)
!has_active_hammer_config?(lines) ->
:missing
# Missing Oban configuration (check for active, non-commented config)
!has_active_oban_config?(lines) ->
:missing
# All required configuration present
true ->
:ok
end
else
# config.exs doesn't exist - let normal flow handle this error
:ok
end
rescue
# If we can't read config, proceed with normal flow
_ -> :ok
end
# Check if active (non-commented) Hammer configuration exists
defp has_active_hammer_config?(lines) do
has_hammer_config =
Enum.any?(lines, fn line ->
trimmed = String.trim(line)
# Not a comment and contains config :hammer
!String.starts_with?(trimmed, "#") and String.starts_with?(trimmed, "config :hammer")
end)
has_expiry_ms =
Enum.any?(lines, fn line ->
trimmed = String.trim(line)
# Not a comment and contains expiry_ms
!String.starts_with?(trimmed, "#") and String.contains?(line, "expiry_ms")
end)
has_hammer_config and has_expiry_ms
end
# Check if active (non-commented) Oban configuration exists
defp has_active_oban_config?(lines) do
has_oban_config =
Enum.any?(lines, fn line ->
trimmed = String.trim(line)
# Not a comment and contains config for any app with Oban
# Matches: "config :any_app, Oban" or "config :any_app, Oban,"
!String.starts_with?(trimmed, "#") and
String.contains?(line, ", Oban")
end)
has_queues =
Enum.any?(lines, fn line ->
trimmed = String.trim(line)
# Not a comment and contains queues:
!String.starts_with?(trimmed, "#") and String.contains?(line, "queues:")
end)
has_oban_config and has_queues
end
# Perform the igniter-based update logic
defp perform_igniter_update(igniter, opts) do
prefix = opts[:prefix] || "public"
force = opts[:force] || false
# Validate and fix Ueberauth configuration before update
igniter = validate_and_fix_ueberauth_config(igniter)
# Ensure Hammer rate limiter configuration exists
igniter = validate_and_add_hammer_config(igniter)
# Ensure Oban configuration exists
igniter = validate_and_add_oban_config(igniter)
# CRITICAL FIX: Ensure correct supervisor ordering in application.ex
# This must run AFTER add_oban_supervisor to fix installations with wrong order
igniter = fix_supervisor_ordering(igniter)
# Check if this is the first pass (config missing) or second pass (config exists)
config_status = Process.get(:phoenix_kit_config_status, :ok)
case config_status do
:missing ->
# First pass: Only add configuration, skip migration creation
# Migration will be created in second pass after app is started
igniter
:ok ->
# Second pass: Configuration exists, app is started, proceed with migration
case Common.check_installation_status(prefix) do
{:not_installed} ->
add_not_installed_notice(igniter)
{:current_version, current_version} ->
target_version = Common.current_version()
cond do
current_version >= target_version && !force ->
add_already_up_to_date_notice(igniter, current_version)
current_version < target_version || force ->
create_update_migration_with_igniter(
igniter,
prefix,
current_version,
target_version,
force,
opts
)
true ->
igniter
end
end
end
end
# Create update migration using igniter
defp create_update_migration_with_igniter(
igniter,
prefix,
current_version,
target_version,
force,
opts
) do
create_schema = prefix != "public"
# Generate timestamp and migration file name using Ecto format
timestamp = Common.generate_timestamp()
action = if force, do: "force_update", else: "update"
# Create padded version variables for shorter strings
current_version_padded = Common.pad_version(current_version)
target_version_padded = Common.pad_version(target_version)
migration_name =
"#{timestamp}_phoenix_kit_#{action}_v#{current_version_padded}_to_v#{target_version_padded}.exs"
# Generate module name
module_name =
"PhoenixKit#{String.capitalize(action)}V#{current_version_padded}ToV#{target_version_padded}"
# Create migration content
# @disable_ddl_transaction is critical: without it, Ecto wraps the entire
# migration in a single transaction. ALTER TABLE requires AccessExclusiveLock,
# which blocks (and is blocked by) every other connection on the table.
# A long-running transaction holding such locks on dozens of tables will deadlock
# with the production app's normal queries — even on an empty database.
# With DDL transaction disabled each statement auto-commits, so locks are held
# only for milliseconds per ALTER TABLE. PhoenixKit migrations are already
# fully idempotent (IF NOT EXISTS / IF EXISTS guards), so partial runs are safe.
migration_content = """
defmodule Ecto.Migrations.#{module_name} do
@moduledoc false
use Ecto.Migration
@disable_ddl_transaction true
def up do
# PhoenixKit Update Migration: V#{current_version_padded} -> V#{target_version_padded}
PhoenixKit.Migrations.up([
prefix: "#{prefix}",
version: #{target_version},
create_schema: #{create_schema}
])
end
def down do
# Rollback PhoenixKit to V#{current_version_padded}
PhoenixKit.Migrations.down([
prefix: "#{prefix}",
version: #{current_version}
])
end
end
"""
# Check for existing migration with same version range to prevent duplicates
existing_pattern =
"phoenix_kit_#{action}_v#{current_version_padded}_to_v#{target_version_padded}.exs"
existing_migration =
Path.wildcard("priv/repo/migrations/*#{existing_pattern}")
|> List.first()
if existing_migration do
notice = """
⚠️ Migration already exists: #{existing_migration}
Skipping duplicate creation. To regenerate, delete the existing file first.
"""
Igniter.add_notice(igniter, notice)
else
migration_path = "priv/repo/migrations/#{migration_name}"
igniter
|> Igniter.create_new_file(migration_path, migration_content)
|> add_migration_created_notice(migration_name, current_version, target_version)
|> add_post_igniter_instructions(opts)
end
end
# Add notices for different scenarios
defp add_not_installed_notice(igniter) do
notice = """
❌ PhoenixKit is not installed.
Please run: mix phoenix_kit.install
"""
Igniter.add_notice(igniter, notice)
end
defp add_already_up_to_date_notice(igniter, current_version) do
current_version_padded = Common.pad_version(current_version)
notice = """
✅ PhoenixKit is already up to date (V#{current_version_padded}).
Use --force to regenerate the migration anyway.
"""
Igniter.add_notice(igniter, notice)
end
defp add_migration_created_notice(igniter, migration_name, current_version, target_version) do
current_version_padded = Common.pad_version(current_version)
target_version_padded = Common.pad_version(target_version)
notice = """
📦 PhoenixKit Update Migration Created: #{migration_name}
- Updating from V#{current_version_padded} to V#{target_version_padded}
"""
Igniter.add_notice(igniter, notice)
end
defp add_post_igniter_instructions(igniter, opts) do
skip_assets = opts[:skip_assets] || false
yes = opts[:yes] || false
instructions = """
📋 Next steps:
"""
instructions =
if skip_assets do
instructions <> " • CSS integration will be updated manually\n"
else
instructions <> " • CSS integration and assets will be updated\n"
end
instructions =
if yes do
instructions <> " • Migration will run automatically (--yes flag)\n"
else
instructions <> " • You'll be prompted to run the migration\n"
end
final_instructions =
instructions <>
"""
After update completes:
1. Run migrations if not done automatically: mix ecto.migrate
2. Restart your Phoenix server: mix phx.server
3. Visit your application: #{Routes.path("/users/register")}
"""
Igniter.add_notice(igniter, final_instructions)
end
# Handle tasks that need to run after igniter completes
defp post_igniter_tasks(opts) do
prefix = Keyword.get(opts, :prefix, "public")
# CRITICAL: Run UUID repair BEFORE migrations
# This fixes upgrade path from PhoenixKit < 1.7.0 where uuid columns
# were not present in some tables, but later migrations use Ecto schemas
# that expect the uuid column to exist.
run_uuid_repair(prefix)
# Update CSS integration (enables daisyUI themes if disabled)
update_css_integration()
# Always rebuild assets unless explicitly skipped
unless Keyword.get(opts, :skip_assets, false) do
AssetRebuild.check_and_rebuild(verbose: true)
end
# Handle interactive migration execution
run_interactive_migration_update(opts)
# Run migrations for registered PhoenixKit modules (e.g. Document Creator)
run_module_migrations(opts)
# Show migration status summary
show_migration_status(prefix)
end
# Run UUID column repair for upgrades from pre-1.7.0 installations
defp run_uuid_repair(prefix) do
case UUIDRepair.maybe_repair(prefix: prefix) do
{:ok, :not_needed} ->
# No repair needed, continue silently
:ok
{:ok, :repaired} ->
Mix.shell().info("""
✅ UUID columns repaired successfully!
This ensures compatibility with migrations that use Ecto schemas.
""")
{:error, reason} ->
Mix.shell().info("""
⚠️ UUID repair encountered an issue: #{inspect(reason)}
You may need to add uuid columns manually before running migrations.
Manual fix (run in psql or your database client):
ALTER TABLE phoenix_kit_settings
ADD COLUMN IF NOT EXISTS uuid UUID DEFAULT uuid_generate_v7();
ALTER TABLE phoenix_kit_email_templates
ADD COLUMN IF NOT EXISTS uuid UUID DEFAULT uuid_generate_v7();
""")
end
rescue
error ->
Mix.shell().info("""
⚠️ UUID repair check failed: #{inspect(error)}
If migrations fail, you may need to add uuid columns manually.
""")
end
# Run interactive migration for updates
defp run_interactive_migration_update(opts) do
yes = Keyword.get(opts, :yes, false)
# Check if we can run migrations safely
case check_migration_conditions() do
:ok ->
run_interactive_migration_prompt_update(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
# Prompt user for migration execution (update-specific)
defp run_interactive_migration_prompt_update(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
# Display comprehensive help information
defp show_help do
Mix.shell().info("""
mix phoenix_kit.update - Update PhoenixKit to the latest version
USAGE
mix phoenix_kit.update [OPTIONS]
DESCRIPTION
Updates an existing PhoenixKit installation to the latest version by:
• Creating upgrade migrations that preserve existing data
• Adding new features and improvements
• Updating CSS configuration (enables daisyUI themes if disabled)
• Rebuilding assets using the Phoenix asset pipeline
• Optionally running database migrations automatically
OPTIONS
--prefix SCHEMA Database schema prefix for PhoenixKit tables
Default: "public" (standard PostgreSQL schema)
Must match prefix used during installation
Example: --prefix "auth"
--status, -s Show current installation status and available updates
Does not perform any changes
--force, -f Force update even if already up to date
Useful for regenerating migrations
--skip-assets Skip automatic asset rebuild check
Default: false
--yes, -y Skip confirmation prompts
Automatically runs migrations without asking
Useful for CI/CD environments
-h, --help Show this help message
EXAMPLES
# Update PhoenixKit to latest version (uses default "public" schema)
mix phoenix_kit.update
# Check current version and available updates
mix phoenix_kit.update --status
# Update with custom schema prefix (must match installation prefix)
mix phoenix_kit.update --prefix "auth"
# Update without prompts (useful for CI/CD)
mix phoenix_kit.update -y
# Force update and run migrations automatically
mix phoenix_kit.update --force -y
# Update without rebuilding assets
mix phoenix_kit.update --skip-assets
VERSION MANAGEMENT
PhoenixKit uses a versioned migration system.
Each version contains specific database schema changes that can
be applied incrementally.
Current latest version: V17
• V01: Basic authentication with role system
• V02: Remove is_active column from role assignments
• V03-V06: Additional features and improvements
• V07: Email system tables (logs, events, blocklist)
• V08-V17: Settings, OAuth, magic links, and more
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)
TWO-PASS UPDATE STRATEGY
If required configuration is missing, the update process will:
1. First run: Add missing configuration (e.g., Ueberauth settings)
2. Prompt you to run the command again
3. Second run: Complete the update with all configuration present
This prevents configuration timing issues where the application
starts before new configuration is available.
AFTER UPDATE
1. If migrations weren't run automatically:
mix ecto.migrate
2. Restart your Phoenix server:
mix phx.server
3. Visit your application:
http://localhost:4000/phoenix_kit/users/register
CI/CD USAGE
For automated deployments, use the --yes flag to skip prompts:
mix phoenix_kit.update -y
TROUBLESHOOTING
If the update fails or you need to check status:
• Check version: mix phoenix_kit.update --status
• Force regeneration: mix phoenix_kit.update --force
• Manual migration: mix ecto.migrate
• Rollback: mix ecto.rollback
DOCUMENTATION
For more information, visit:
https://hexdocs.pm/phoenix_kit
""")
end
# Run versioned migrations for all registered PhoenixKit modules that
# implement `migration_module/0`. Generates an incremental migration file
# in the parent app for each module that needs updating, then runs migrations.
defp run_module_migrations(opts) do
prefix = Keyword.get(opts, :prefix, "public")
modules =
try do
discover_module_migrations()
rescue
_ -> []
end
Enum.each(modules, fn {name, migration_mod} ->
try do
current = migration_mod.migrated_version_runtime(prefix: prefix)
target = migration_mod.current_version()
if current < target do
Mix.shell().info("\n#{name}: V#{pad_version(current)} → V#{pad_version(target)}")
generate_module_migration(name, migration_mod, current, target, prefix)
# Run the newly generated migration
Mix.Task.reenable("ecto.migrate")
Mix.Task.run("ecto.migrate")
Mix.shell().info("✅ #{name} migrated to V#{pad_version(target)}")
else
Mix.shell().info("✅ #{name}: V#{pad_version(current)} (up to date)")
end
rescue
error ->
Mix.shell().info("⚠️ #{name} migration check failed: #{Exception.message(error)}")
end
end)
end
# Discover modules with migrations via beam file scanning.
# Works without the full app started — scans beam files directly.
defp discover_module_migrations do
PhoenixKit.ModuleDiscovery.discover_external_modules()
|> Enum.flat_map(fn mod ->
if Code.ensure_loaded?(mod) and function_exported?(mod, :migration_module, 0) do
case mod.migration_module() do
nil -> []
migration_mod -> [{safe_module_name(mod), migration_mod}]
end
else
[]
end
end)
end
defp safe_module_name(mod) do
if function_exported?(mod, :module_name, 0), do: mod.module_name(), else: inspect(mod)
rescue
_ -> inspect(mod)
end
defp generate_module_migration(name, migration_mod, current, target, prefix) do
migrations_dir = Path.join(["priv", "repo", "migrations"])
File.mkdir_p!(migrations_dir)
slug =
name
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/, "_")
|> String.trim("_")
mod_name = inspect(migration_mod)
timestamp = Calendar.strftime(DateTime.utc_now(), "%Y%m%d%H%M%S")
filename =
"#{timestamp}_#{slug}_update_v#{pad_version(current)}_to_v#{pad_version(target)}.exs"
app_module =
Mix.Project.config()[:app]
|> to_string()
|> Macro.camelize()
class_name =
"#{slug |> Macro.camelize()}UpdateV#{pad_version(current)}ToV#{pad_version(target)}"
content = """
defmodule #{app_module}.Repo.Migrations.#{class_name} do
@moduledoc false
use Ecto.Migration
def up do
#{mod_name}.up(prefix: "#{prefix}", version: #{target})
end
def down do
#{mod_name}.down(prefix: "#{prefix}", version: #{current})
end
end
"""
path = Path.join(migrations_dir, filename)
File.write!(path, content)
Mix.shell().info(" Created migration: #{path}")
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
# 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
# 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
# Execute migration with feedback
defp run_migration_with_feedback do
Mix.shell().info("\n⏳ Running database migration...")
try do
# Run ecto.migrate in-process so it shares the current Repo pool
# (capped to 2 connections with Oban disabled and update_mode set).
# Using System.cmd("mix", ["ecto.migrate"]) would spawn a separate
# BEAM that starts its own full app with pool_size=20, bypassing
# all update_mode optimisations and saturating PgBouncer.
#
# Re-enable the task first since Mix tracks which tasks have run
# and ecto.migrate may have been invoked earlier in the session.
Mix.Task.reenable("ecto.migrate")
Mix.Task.run("ecto.migrate")
Mix.shell().info("\n✅ Migration completed successfully!")
show_update_success_notice()
rescue
error ->
Mix.shell().info("\n⚠️ Migration failed: #{Exception.message(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
# Show migration status summary (current vs target version)
defp show_migration_status(prefix) do
opts = %{prefix: prefix, escaped_prefix: String.replace(prefix, "'", "\\'")}
target = MigrationsPostgres.current_version()
db_version =
try do
MigrationsPostgres.migrated_version_runtime(opts)
rescue
_ -> 0
end
phoenix_kit_version =
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
a = IO.ANSI
Mix.shell().info("""
#{a.bright()}PhoenixKit v#{phoenix_kit_version}#{a.reset()}
#{a.bright()}├── Migration#{a.reset()}: #{format_version(db_version, target)}
#{a.bright()}└── Target#{a.reset()}: V#{pad_version(target)}
""")
end
defp format_version(db, target) when db >= target,
do: "#{IO.ANSI.green()}V#{pad_version(db)}#{IO.ANSI.reset()}"
defp format_version(0, _target),
do: "#{IO.ANSI.red()}Not installed#{IO.ANSI.reset()}"
defp format_version(db, target),
do:
"#{IO.ANSI.yellow()}V#{pad_version(db)} → V#{pad_version(target)} (needs migration)#{IO.ANSI.reset()}"
defp pad_version(v) when v < 10, do: "0#{v}"
defp pad_version(v), do: to_string(v)
# Validate and fix Ueberauth configuration
defp validate_and_fix_ueberauth_config(igniter) do
# Read current config.exs to check Ueberauth configuration
config_file = "config/config.exs"
if File.exists?(config_file) do
content = File.read!(config_file)
# Check Ueberauth configuration status
cond do
# Case 1: Incorrect configuration with providers: []
String.contains?(content, "config :ueberauth, Ueberauth") &&
Regex.match?(~r/providers:\s*\[\s*\]/, content) ->
fix_ueberauth_providers_config(igniter, content)
# Case 2: Configuration exists and is correct (providers: %{} or with values)
String.contains?(content, "config :ueberauth, Ueberauth") ->
igniter
# Case 3: Configuration is missing - add it
true ->
add_missing_ueberauth_config(igniter)
end
else
# config.exs doesn't exist, skip validation
igniter
end
end
# Fix Ueberauth providers configuration from [] to %{}
defp fix_ueberauth_providers_config(igniter, _content) do
igniter
|> Igniter.update_file("config/config.exs", fn source ->
content = Rewrite.Source.get(source, :content)
# Replace providers: [] with providers: %{}
updated_content =
Regex.replace(
~r/(config\s+:ueberauth,\s+Ueberauth,\s+providers:\s*)\[\s*\]/,
content,
"\\1%{}"
)
Rewrite.Source.update(source, :content, updated_content)
end)
|> add_ueberauth_fix_notice()
end
# Add notice about Ueberauth configuration fix
defp add_ueberauth_fix_notice(igniter) do
notice = """
✅ Fixed Ueberauth configuration: providers: [] → providers: %{}
OAuth authentication will now work correctly.
"""
Igniter.add_notice(igniter, String.trim(notice))
end
# Add missing Ueberauth configuration
defp add_missing_ueberauth_config(igniter) do
igniter
|> Config.configure_new(
"config.exs",
:ueberauth,
[Ueberauth],
providers: %{}
)
|> add_ueberauth_added_notice()
end
# Add notice about Ueberauth configuration being added
defp add_ueberauth_added_notice(igniter) do
notice = """
✅ Added missing Ueberauth configuration: providers: %{}
OAuth authentication configured for runtime loading.
"""
Igniter.add_notice(igniter, String.trim(notice))
end
# Validate and add Hammer rate limiter configuration if missing
defp validate_and_add_hammer_config(igniter) do
if RateLimiterConfig.hammer_config_exists?(igniter) do
# Configuration exists, no action needed
igniter
else
# Configuration missing, add it
igniter
|> RateLimiterConfig.add_rate_limiter_configuration()
|> add_hammer_config_added_notice()
end
end
# Add notice about Hammer configuration being added
defp add_hammer_config_added_notice(igniter) do
notice = """
⚠️ Added missing Hammer rate limiter configuration to config.exs
IMPORTANT: Restart your server if it's currently running.
Without this configuration, the application will fail to start.
"""
Igniter.add_notice(igniter, String.trim(notice))
end
# Validate and add Oban configuration if missing
# Fix supervisor ordering in application.ex to prevent startup crashes
# Ensures correct order: Repo → PhoenixKit.Supervisor → Oban → Endpoint
defp fix_supervisor_ordering(igniter) do
app_name = IgniterHelpers.get_parent_app_name(igniter)
app_file = "lib/#{app_name}/application.ex"
if File.exists?(app_file) do
content = File.read!(app_file)
# Check current supervisor ordering
case check_supervisor_order(content, app_name) do
:correct ->
# Order is already correct, no changes needed
igniter
{:needs_fix, reason} ->
# Order is incorrect, attempt to fix using Igniter API
igniter
|> fix_application_supervisor_order(app_name, reason)
|> add_supervisor_ordering_fixed_notice(reason)
:cannot_determine ->
# Cannot determine order (unusual setup), skip silently
igniter
end
else
# No application.ex found (unusual), skip
igniter
end
rescue
e ->
# If any error occurs, log warning but continue
Mix.shell().info("⚠️ Could not check supervisor ordering: #{inspect(e)}")
igniter
end
# Check the ordering of supervisors in application.ex
# Returns :correct, {:needs_fix, reason}, or :cannot_determine
defp check_supervisor_order(content, app_name) do
lines = String.split(content, "\n")
# Convert snake_case app_name to PascalCase module name
app_module = Macro.camelize(to_string(app_name))
# Find line numbers for each supervisor
repo_line = find_supervisor_line(lines, ~r/#{app_module}\.Repo[,\s]/)
phoenix_kit_line = find_supervisor_line(lines, ~r/PhoenixKit\.Supervisor[,\s]/)
endpoint_line = find_supervisor_line(lines, ~r/#{app_module}Web\.Endpoint[,\s]/)
oban_line =
find_supervisor_line(lines, ~r/\{Oban,|Application\.get_env\(:#{app_name}, Oban\)/)
validate_supervisor_positions(repo_line, phoenix_kit_line, oban_line, endpoint_line)
end
# Validate supervisor positions and return check result
# Correct order: Repo → PhoenixKit.Supervisor → Endpoint → Oban
# PhoenixKit MUST be before Endpoint so Presence is ready for LiveViews
defp validate_supervisor_positions(nil, nil, nil, _), do: :cannot_determine
defp validate_supervisor_positions(nil, _, _, _), do: :cannot_determine
defp validate_supervisor_positions(repo, nil, nil, _) when is_integer(repo), do: :correct
defp validate_supervisor_positions(repo, pk, nil, nil)
when is_integer(repo) and is_integer(pk) do
if repo < pk, do: :correct, else: {:needs_fix, "PhoenixKit.Supervisor before Repo"}
end
defp validate_supervisor_positions(repo, pk, nil, endpoint)
when is_integer(repo) and is_integer(pk) and is_integer(endpoint) do
cond do
pk < repo -> {:needs_fix, "PhoenixKit.Supervisor before Repo"}
pk > endpoint -> {:needs_fix, "PhoenixKit.Supervisor after Endpoint"}
true -> :correct
end
end
defp validate_supervisor_positions(repo, pk, oban, nil)
when is_integer(repo) and is_integer(pk) and is_integer(oban) do
check_supervisor_order_without_endpoint(repo, pk, oban)
end
defp validate_supervisor_positions(repo, pk, oban, endpoint)
when is_integer(repo) and is_integer(pk) and is_integer(oban) and is_integer(endpoint) do
check_full_supervisor_order(repo, pk, oban, endpoint)
end
defp validate_supervisor_positions(_, _, _, _), do: :cannot_determine
# Check ordering without endpoint
defp check_supervisor_order_without_endpoint(repo, pk, oban) do
cond do
pk < repo and oban < repo -> {:needs_fix, "both PhoenixKit and Oban before Repo"}
pk < repo -> {:needs_fix, "PhoenixKit.Supervisor before Repo"}
oban < repo -> {:needs_fix, "Oban before Repo"}
oban < pk -> {:needs_fix, "Oban before PhoenixKit.Supervisor"}
true -> :correct
end
end
# Check full ordering with endpoint
# Correct order: Repo → PhoenixKit.Supervisor → Endpoint → Oban
defp check_full_supervisor_order(repo, pk, oban, endpoint) do
cond do
pk < repo -> {:needs_fix, "PhoenixKit.Supervisor before Repo"}
oban < repo -> {:needs_fix, "Oban before Repo"}
pk > endpoint -> {:needs_fix, "PhoenixKit.Supervisor after Endpoint"}
oban < pk -> {:needs_fix, "Oban before PhoenixKit.Supervisor"}
true -> :correct
end
end
# Find the line number where a supervisor is defined
defp find_supervisor_line(lines, pattern) do
lines
|> Enum.with_index(1)
|> Enum.find(fn {line, _index} ->
trimmed = String.trim(line)
# Not a comment and matches pattern
!String.starts_with?(trimmed, "#") and Regex.match?(pattern, line)
end)
|> case do
{_line, index} -> index
nil -> nil
end
end
# Fix the supervisor ordering using manual reordering
# Note: We can't use Igniter.Project.Application.add_new_child to reorder existing children,
# so we need to manually reorder the children list
defp fix_application_supervisor_order(igniter, app_name, _reason) do
app_file = "lib/#{app_name}/application.ex"
Igniter.update_file(igniter, app_file, fn source ->
content = Rewrite.Source.get(source, :content)
fixed_content = reorder_supervisors(content, app_name)
Rewrite.Source.update(source, :content, fixed_content)
end)
end
# Reorder supervisors in application.ex to correct order
defp reorder_supervisors(content, app_name) do
lines = String.split(content, "\n")
# Convert snake_case app_name to PascalCase module name
app_module = Macro.camelize(to_string(app_name))
# Extract supervisor lines
{repo_line, repo_index} = extract_supervisor(lines, ~r/#{app_module}\.Repo[,\s]/)
{pk_line, pk_index} = extract_supervisor(lines, ~r/PhoenixKit\.Supervisor[,\s]/)
{oban_line, oban_index} =
extract_supervisor(lines, ~r/\{Oban,|Application\.get_env\(:#{app_name}, Oban\)/)
# Determine children list boundaries
children_start = find_children_list_start(lines)
children_end = find_children_list_end(lines, children_start)
if is_integer(children_start) and is_integer(children_end) do
# Build new children list with correct order
supervisors = %{
repo: {repo_line, repo_index},
phoenix_kit: {pk_line, pk_index},
oban: {oban_line, oban_index}
}
new_lines =
rebuild_children_list(lines, children_start, children_end, supervisors)
Enum.join(new_lines, "\n")
else
# Cannot find children list boundaries, return unchanged
content
end
end
# Extract supervisor line and its index
defp extract_supervisor(lines, pattern) do
case Enum.with_index(lines, 1) do
indexed_lines ->
case Enum.find(indexed_lines, fn {line, _index} ->
trimmed = String.trim(line)
!String.starts_with?(trimmed, "#") and Regex.match?(pattern, line)
end) do
{line, index} -> {line, index}
nil -> {nil, nil}
end
end
end
# Find the start of children list
defp find_children_list_start(lines) do
Enum.find_index(lines, fn line ->
String.contains?(line, "children = [")
end)
end
# Find the end of children list (closing bracket)
defp find_children_list_end(lines, start_index) do
lines
|> Enum.drop(start_index + 1)
|> Enum.with_index(start_index + 1)
|> Enum.find(fn {line, _index} ->
trimmed = String.trim(line)
trimmed == "]"
end)
|> case do
{_line, index} -> index
nil -> nil
end
end
# Rebuild children list with correct supervisor ordering
defp rebuild_children_list(lines, children_start, children_end, supervisors) do
%{
repo: {repo_line, repo_index},
phoenix_kit: {pk_line, pk_index},
oban: {oban_line, oban_index}
} = supervisors
# Lines before children list
before_children = Enum.take(lines, children_start + 1)
# Lines after children list
after_children = Enum.drop(lines, children_end)
# Get all children between start and end
children_lines =
lines
|> Enum.drop(children_start + 1)
|> Enum.take(children_end - children_start - 1)
# Remove repo, phoenix_kit, and oban lines from children
filtered_children =
children_lines
|> Enum.with_index(children_start + 2)
|> Enum.reject(fn {_line, index} ->
index in [repo_index, pk_index, oban_index]
end)
|> Enum.map(fn {line, _index} -> line end)
# Build new ordered children list
ordered_children =
build_ordered_supervisor_list(repo_line, pk_line, oban_line, filtered_children)
# Reconstruct file
before_children ++ ordered_children ++ after_children
end
# Build ordered list of supervisors with correct positioning
# Correct order: Repo → PhoenixKit → Endpoint → Oban
defp build_ordered_supervisor_list(repo_line, pk_line, oban_line, filtered_children) do
# Add Repo first (if exists)
ordered = if repo_line, do: [repo_line], else: []
# Split remaining children at Endpoint
{before_endpoint, from_endpoint} = split_at_endpoint(filtered_children)
# Add children before Endpoint
ordered = ordered ++ before_endpoint
# Add PhoenixKit BEFORE Endpoint (so Presence is ready for LiveViews)
ordered = if pk_line, do: ordered ++ [pk_line], else: ordered
# Add Endpoint
ordered = ordered ++ from_endpoint
# Add Oban AFTER Endpoint (typically last in the list)
if oban_line, do: ordered ++ [oban_line], else: ordered
end
# Split children at Endpoint line
defp split_at_endpoint(children) do
endpoint_index =
Enum.find_index(children, fn line ->
String.contains?(line, "Endpoint") and !String.contains?(line, "#")
end)
case endpoint_index do
nil -> {children, []}
index -> Enum.split(children, index)
end
end
# Add notice about supervisor ordering being fixed
defp add_supervisor_ordering_fixed_notice(igniter, reason) do
notice = """
⚠️ CRITICAL FIX APPLIED: Corrected supervisor ordering in application.ex
Issue detected: #{reason}
Fixed to correct order:
1. YourApp.Repo (database connection - must be first)
2. PhoenixKit.Supervisor (Presence must be ready before Endpoint)
3. YourAppWeb.Endpoint (starts accepting connections)
4. {Oban, ...} (uses Repo for job persistence)
PhoenixKit.Supervisor must start BEFORE Endpoint so that Presence
ETS tables are ready when LiveViews mount for collaborative editing.
IMPORTANT: Restart your server for changes to take effect.
"""
Igniter.add_notice(igniter, String.trim(notice))
end
defp validate_and_add_oban_config(igniter) do
config_exists = ObanConfig.oban_config_exists?(igniter)
supervisor_exists = ObanConfig.oban_supervisor_exists?(igniter)
# Always call add_oban_configuration - it handles both:
# - Adding new configuration if missing
# - Updating existing configuration with new queues (posts, sitemap, sqs_polling)
igniter =
igniter
|> ObanConfig.add_oban_configuration()
|> maybe_add_oban_config_notice(config_exists)
# Check and add supervisor separately
if supervisor_exists do
igniter
else
igniter
|> ObanConfig.add_oban_supervisor()
|> add_oban_supervisor_added_notice()
end
end
# Add appropriate notice based on whether config existed
defp maybe_add_oban_config_notice(igniter, config_existed) do
if config_existed do
# Config existed, might have been updated with new queues
add_oban_config_updated_notice(igniter)
else
# Config was newly added
add_oban_config_added_notice(igniter)
end
end
# Add notice about Oban configuration being added
defp add_oban_config_added_notice(igniter) do
notice = """
⚠️ Added missing Oban configuration to config.exs
IMPORTANT: Restart your server if it's currently running.
Without Oban, the storage system cannot process uploaded files.
"""
Igniter.add_notice(igniter, String.trim(notice))
end
# Add notice about Oban configuration being updated with new queues
defp add_oban_config_updated_notice(igniter) do
notice = """
⚙️ Oban configuration verified/updated in config.exs
New queues may have been added: posts, sitemap, sqs_polling
IMPORTANT: If your server is running, restart it to apply changes.
"""
Igniter.add_notice(igniter, String.trim(notice))
end
# Add notice about Oban supervisor being added
defp add_oban_supervisor_added_notice(igniter) do
notice = """
⚠️ Added Oban to application supervisor tree in application.ex
IMPORTANT: Restart your server if it's currently running.
Oban will now start automatically with your application.
"""
Igniter.add_notice(igniter, String.trim(notice))
end
# Reduce Ecto repo pool sizes and disable Oban queue workers before app.start
# so the update task uses minimal DB connections and doesn't starve the
# production app via PgBouncer. Must be called AFTER app.config so that
# runtime.exs has already applied its settings — then we override here.
defp cap_repo_pool_size_for_update(pool_size) do
app_name = Mix.Project.config()[:app]
repos =
Application.get_env(app_name, :ecto_repos, []) ++
Application.get_env(:phoenix_kit, :ecto_repos, [])
Enum.each(repos, fn repo ->
current = Application.get_env(app_name, repo, [])
updated = Keyword.put(current, :pool_size, pool_size)
Application.put_env(app_name, repo, updated)
Mix.shell().info("PhoenixKit: capped #{inspect(repo)} pool_size to #{pool_size}")
end)
# Disable Oban queue workers so they don't consume all pool connections
# before the settings cache warms and Dashboard.Registry initialises.
# With pool_size=2, 7+ Oban producers would otherwise starve everything else.
disable_oban_queues_for_update(app_name)
rescue
e ->
Mix.shell().info("PhoenixKit: could not cap repo pool_size: #{inspect(e)}")
end
defp disable_oban_queues_for_update(app_name) do
case Application.get_env(app_name, Oban) do
nil ->
:ok
oban_config ->
updated = oban_config |> Keyword.put(:queues, []) |> Keyword.put(:plugins, [])
Application.put_env(app_name, Oban, updated)
Mix.shell().info("PhoenixKit: disabled Oban queues/plugins for update task")
end
rescue
_ -> :ok
end
end
# Fallback module for when Igniter is not available
else
defmodule Mix.Tasks.PhoenixKit.Update do
@moduledoc """
PhoenixKit update task.
This task requires the Igniter library to be available. Please add it to your mix.exs:
{:igniter, "~> 0.7"}
Then run: mix deps.get
"""
@shortdoc "Update PhoenixKit (requires Igniter)"
use Mix.Task
def run(_args) do
Mix.shell().error("""
❌ PhoenixKit update requires the Igniter library.
Please add Igniter to your mix.exs dependencies:
def deps do
[
{:igniter, "~> 0.7"}
# ... your other dependencies
]
end
Then run:
mix deps.get
mix phoenix_kit.update
For more information, visit: https://hex.pm/packages/igniter
""")
end
end
end