Packages
Fireside is a small Elixir library that allows importing code components (templates) into an existing Elixir project together with their dependencies. It also allows upgrading these components if they have a newer version available.
Current section
Files
Jump to
Current section
Files
lib/fireside.ex
defmodule Fireside do
@moduledoc """
This is the documentation for the Fireside project.
"""
alias Igniter.Code.Common
alias Igniter.Code.Function
alias Igniter.Project.Config
alias Sourceror.Zipper
require Logger
@doc """
Checks if the provided component is installed in the current project.
## Examples
iex> Fireside.component_installed?(:example_component)
false
"""
def component_installed?(component_name)
def component_installed?(component_name) when is_binary(component_name) do
component_installed?(String.to_atom(component_name))
end
def component_installed?(component_name) when is_atom(component_name) do
get_local_component_config(component_name) != nil
end
@doc """
Installs a Fireside component into the project.
This is done by importing files listed in `config/0` (in `fireside.exs`) of the target component.
If the component defines a `setup/1` method, it will also be called.
## Parameters
- `component_name`: The name of the component to install. It can be an atom or a string.
- `source`: The source from which to install the component. The supported formats are:
- `[path: component_path]`: Install from a local path.
- `[{:git, git_url} | git_opts]`: Install from a Git repository. `git_opts` can include `:ref`, `:branch`, or `:tag`.
- `[{:github, github_repo} | git_opts]`: Install from a GitHub repository. `git_opts` can include `:ref`, `:branch`, or `:tag`.
- `opts`: A keyword list of options. Supported options include:
- `:unlocked?` - When true, the component is installed without being tracked by Fireside.
- `:yes?` - When true, auto-accepts all prompts during installation.
- `:no_hash?` - When true, skips adding hash to files and .fireside.exs.
## Examples
iex> Fireside.install(:my_component, [path: "/path/to/component"], unlocked?: true)
:ok
iex> Fireside.install(:my_component, [git: "https://github.com/user/repo.git"] ++ [tag: "v1.0.0"])
:ok
iex> Fireside.install(:my_component, [github: "user/repo"] ++ [branch: "main"])
:ok
"""
def install(component_name, source, opts \\ [])
def install(component_name, source, opts) when is_binary(component_name) do
install(String.to_atom(component_name), source, opts)
end
def install(component_name, source, opts) when is_atom(component_name) do
Fireside.Helpers.ensure_clean_git!()
do_install_or_update(component_name, source, opts)
end
@doc """
Updates a previously installed Fireside component.
This is done by reimporting files listed in `config/0` (in `fireside.exs`) of the target component.
If the component's version is being changed and a corresponding `upgrade/3` migration is defined,
it will also be executed. (Note: multiple `upgrade/3` migrations may be executed if the app's
version is changed by more than 1.)
Before updating, the integrity of the installed component will be checked by comparing the hash
of the AST with the hash listed in `config/fireside.exs`. If the installed component has been
modified and has diverged from its original source, the operation will be aborted, as it might
lead to a regression since any added/removed functionality may be overwritten by remote updates.
In this case, it is recommended to run `unlock/2` to instruct Fireside to stop tracking the
component's source.
## Parameters
- `component_name`: The name of the component to update. It can be an atom or a string.
- `source`: The source from which to update the component. If `nil` (default), the source from the
local component configuration is used. Otherwise, the supported formats are:
- `[path: component_path]`: Install from a local path.
- `[{:git, git_url} | git_opts]`: Install from a Git repository. `git_opts` can include `:ref`, `:branch`, or `:tag`.
- `[{:github, github_repo} | git_opts]`: Install from a GitHub repository. `git_opts` can include `:ref`, `:branch`, or `:tag`.
- `opts`: A keyword list of options. Supported options include:
- `:yes?` - When true, auto-accepts all prompts during the update.
- `:force?` - When true, skips integrity check and overwrites files.
- `:no_hash?` - When true, skips adding hash to files and .fireside.exs.
## Examples
iex> Fireside.update(:my_component)
:ok
iex> Fireside.update(:my_component, [git: "https://github.com/user/repo.git"] ++ [branch: "dev"])
:ok
"""
def update(component_name, source \\ nil, opts \\ [])
def update(component_name, source, opts) when is_binary(component_name) do
update(String.to_atom(component_name), source, opts)
end
def update(component_name, source, opts) do
local_component_config = get_local_component_config(component_name)
if !Keyword.get(opts, :force?, false) do
ensure_integrity!(local_component_config)
end
Fireside.Helpers.ensure_clean_git!()
opts = opts ++ [current_version: local_component_config[:version]]
case source do
nil ->
do_install_or_update(
component_name,
local_component_config[:origin],
opts
)
source ->
do_install_or_update(component_name, source, opts)
end
:ok
end
@doc """
Unlocks a Fireside component, removing it from Fireside's tracking without deleting it.
This is useful if you want to continue using the component but no longer want it to be managed
by Fireside.
## Parameters
- `component_name`: The name of the component to unlock. It can be an atom or a string.
- `opts`: A keyword list of options. Supported options include:
- `:yes?` - When true, auto-accepts all prompts during the unlock process.
## Examples
iex> Fireside.unlock(:my_component)
:ok
"""
def unlock(component_name, opts) when is_binary(component_name) do
unlock(String.to_atom(component_name), opts)
end
def unlock(component_name, opts) when is_atom(component_name) do
local_component_config = get_local_component_config(component_name)
igniter =
for {file_path, _hash} <- local_component_config[:files], reduce: Igniter.new() do
igniter ->
Igniter.update_elixir_file(igniter, file_path, fn zipper ->
zipper_without_fireside_comments =
zipper |> Zipper.node() |> Fireside.Helpers.remove_fireside_comments()
{:ok, Zipper.replace(zipper, zipper_without_fireside_comments)}
end)
end
igniter
|> remove_local_component_config(component_name)
|> run_igniter(opts)
:ok
end
@doc """
Uninstalls a Fireside component from the project.
This function will attempt to remove all files and configurations associated with the component that were
managed by Fireside. However, there are certain manual steps you may need to perform after the
uninstallation; read more in the "Warning" below.
## Parameters
- `component_name`: The name of the component to uninstall. It can be an atom or a string.
- `opts`: A keyword list of options. Supported options include:
- `:yes?` - When true, auto-accepts all prompts during the uninstallation.
> ### Warning {: .warning}
>
> After uninstalling a component, you may need to manually:
>
> - **Remove Optional or Manually Added Files**: If the component installation added files that were not tracked by Fireside
> (e.g., files that were marked as `:optional` or added outside the standard component structure via Igniter),
> these will not be automatically deleted. You will need to identify and remove these files yourself, if needed.
> - **Cleanup Configuration Changes**: If the component installation modified configuration files
> (e.g., `config/config.exs`, `config/dev.exs`), these changes will not be reverted automatically.
> Review these files and manually remove any settings or configurations related to the uninstalled component.
> - **Remove Unused Dependencies**: Dependencies installed alongside the component will not be automatically
> removed, as Fireside cannot determine if they are used elsewhere in your project. You may need to manually
> remove these dependencies from your `mix.exs` file and run `mix deps.clean` to fully remove them from your project.
## Examples
iex> Fireside.uninstall(:my_component)
:ok
"""
def uninstall(component_name, opts \\ [])
def uninstall(component_name, opts) when is_binary(component_name) do
uninstall(String.to_atom(component_name), opts)
end
def uninstall(component_name, opts) when is_atom(component_name) do
local_component_config = get_local_component_config(component_name) || %{}
igniter =
Igniter.new()
|> track_managed_files(local_component_config)
|> Igniter.assign(:imported_files, [])
|> Igniter.add_warning("""
NOTE: the dependencies installed alongside the component will NOT be deleted as Fireside has no way of knowing if they are used elsewhere in the codebase.
If you want to manually delete them, please check the list of non-optional dependencies in the source code of the component you're uninstalling.
Keep in mind that those dependencies may have had associated Igniter installers that added some code or configuration to your codebase—you may want to delete that too.
""")
|> Igniter.add_warning(
"Imported files that were marked as :optional in Fireside's component configuration will also not be deleted, as they could have existed before component installation (for example, `test/supports/data_case.ex` or `lib/my_app_web/endpoint.ex`)"
)
|> Igniter.add_warning(
"Finally, any code modification that were performed in `setup/1` or `upgrade/3` by the component will not be removed. Please consult the component's original Fireside config (fireside.exs) to see if anything was added modified manually, such as config.exs."
)
|> add_deletions()
|> remove_local_component_config(component_name)
run_igniter(igniter, opts)
:ok
end
defp run_igniter(igniter, opts) do
Igniter.do_or_dry_run(igniter,
yes: Keyword.get(opts, :yes?, false),
title: Keyword.get(opts, :title, "Fireside")
)
end
defp do_install_or_update(component_name, source, opts)
defp do_install_or_update(component_name, [{:github, github_repo} | params], opts) do
git_url = "https://github.com/#{github_repo}.git"
do_install_or_update(component_name, [git: git_url] ++ params, opts)
end
defp do_install_or_update(component_name, [{:git, git_url} | git_opts] = origin, opts) do
temp_dir =
Path.join(System.tmp_dir!(), "fireside_#{component_name}_#{:os.system_time(:millisecond)}")
File.mkdir_p!(temp_dir)
{_, 0} = System.cmd("git", ["clone", git_url, temp_dir])
cond do
ref = git_opts[:ref] ->
{_, 0} = System.cmd("git", ["checkout", ref], cd: temp_dir)
branch = git_opts[:branch] ->
{_, 0} = System.cmd("git", ["checkout", branch], cd: temp_dir)
tag = git_opts[:tag] ->
{_, 0} = System.cmd("git", ["checkout", "tags/#{tag}"], cd: temp_dir)
true ->
:ok
end
import_component(component_name, temp_dir, origin, opts)
File.rm_rf!(temp_dir)
end
defp do_install_or_update(component_name, [path: component_path] = origin, opts) do
import_component(component_name, component_path, origin, opts)
end
defp import_component(component_name, component_path, origin, opts) do
current_version = Keyword.get(opts, :current_version, nil)
unlocked? = Keyword.get(opts, :unlocked?, false)
ensure_path_is_a_fireside_component!(component_path)
install_required_dependencies(component_path, yes?: Keyword.get(opts, :yes?, false))
fireside_module = get_fireside_component_module(component_name, component_path)
local_component_config = get_local_component_config(component_name) || %{}
igniter = track_managed_files(Igniter.new(), local_component_config)
{igniter, current_version} =
case current_version do
nil ->
igniter =
try do
fireside_module.setup(igniter)
rescue
_ -> igniter
end
{igniter, 1}
_ ->
{igniter, current_version}
end
igniter =
igniter
|> Igniter.update_assign(:fireside_managed_files, [], & &1)
|> Igniter.assign(imported_files: [], deletions: [], hashes: [])
|> install_files(fireside_module, component_path)
|> run_upgrades(fireside_module, current_version)
|> replace_component_name(fireside_module)
|> add_deletions()
|> update_formatter_ignores()
igniter =
if unlocked? do
igniter
else
add_or_replace_fireside_lock(
igniter,
fireside_module,
origin,
opts
)
end
Igniter.add_notice(
igniter,
"\"#{component_name}\" (version: #{fireside_module.config()[:version]}) has been successfully installed."
)
run_igniter(igniter, opts)
end
defp format(igniter) do
for source <- Rewrite.sources(igniter.rewrite),
Rewrite.Source.get(source, :path) in igniter.assigns.fireside_managed_files,
reduce: igniter do
igniter ->
Igniter.format(igniter, Rewrite.Source.get(source, :path))
end
end
defp install_files(igniter, fireside_module, component_path) do
fireside_component_files =
Fireside.Helpers.expand_fireside_component_globs(
fireside_module.config()[:files],
component_path
)
for kind <- [:required, :optional], reduce: igniter do
igniter ->
relative_paths = fireside_component_files[kind]
Enum.reduce(relative_paths, igniter, fn relative_path, igniter ->
install_file(
igniter,
fireside_module,
component_path,
relative_path,
skip_if_exists?: kind == :optional,
untracked?:
relative_path in fireside_component_files[:overwritable] or kind == :optional
)
end)
end
end
defp install_file(igniter, fireside_module, component_path, relative_file_path, opts) do
fireside_module_prefix = Fireside.Helpers.get_module_prefix(fireside_module)
project_prefix = Fireside.Helpers.get_module_prefix(Mix.Project.get!())
ast =
[component_path, relative_file_path]
|> Path.join()
|> File.read!()
|> Sourceror.parse_string!()
module_name =
ast
|> Fireside.Helpers.replace_module_prefix_from_to(fireside_module_prefix, project_prefix)
|> Fireside.Helpers.get_module_name()
proper_location =
case relative_file_path do
"/lib/" <> _ ->
Igniter.Project.Module.proper_location(igniter, module_name, :source_folder)
"/test/support/" <> _ ->
Igniter.Project.Module.proper_location(igniter, module_name, :test_support)
"/test/" <> _ ->
Igniter.Project.Module.proper_location(igniter, module_name, :test)
end
igniter =
if Keyword.fetch!(opts, :skip_if_exists?) do
Igniter.include_or_create_file(
igniter,
proper_location,
Sourceror.to_string(ast)
)
else
fireside_managed_files = igniter.assigns.fireside_managed_files
if Igniter.exists?(igniter, proper_location) and
proper_location not in fireside_managed_files do
raise "Conflicting file #{proper_location} already exists, aborting."
end
igniter
|> Igniter.create_or_update_elixir_file(
proper_location,
Sourceror.to_string(ast),
fn _ -> Zipper.zip(ast) end
)
|> Igniter.update_assign(:imported_files, [proper_location], &(&1 ++ [proper_location]))
end
if Keyword.fetch!(opts, :untracked?) do
Igniter.update_assign(
igniter,
:untracked_files,
[proper_location],
fn overwritable_paths -> overwritable_paths ++ [proper_location] end
)
else
igniter
end
end
defp add_deletions(igniter) do
deletion_list =
MapSet.difference(
MapSet.new(igniter.assigns.fireside_managed_files),
MapSet.new(igniter.assigns.imported_files)
)
for file_path <- deletion_list, reduce: igniter do
igniter ->
Igniter.rm(igniter, file_path)
end
end
defp remove_local_component_config(igniter, component_name) do
Igniter.update_elixir_file(
igniter,
".fireside.exs",
fn zipper ->
rightmost = Common.rightmost(zipper)
if Igniter.Code.List.list?(rightmost) do
Igniter.Code.Keyword.remove_keyword_key(zipper, component_name)
else
raise "Expected the last element of the .fireside.exs file to be a list, got #{rightmost}"
end
end
)
end
defp add_or_replace_fireside_lock(igniter, fireside_module, origin, opts \\ []) do
igniter =
for source <- Rewrite.sources(igniter.rewrite),
Rewrite.Source.get(source, :path) in igniter.assigns.imported_files,
Rewrite.Source.get(source, :path) not in igniter.assigns.untracked_files,
reduce: igniter do
igniter ->
{new_quoted, hash} =
source
|> Rewrite.Source.get(:quoted)
|> compute_and_include_hash(
fireside_module.config()[:name],
Keyword.get(opts, :no_hash?, false)
)
new_source =
Rewrite.Source.update(
source,
:quoted,
new_quoted
)
path = Rewrite.Source.get(source, :path)
Igniter.update_assign(
%{igniter | rewrite: Rewrite.update!(igniter.rewrite, new_source)},
:hashes,
[{path, hash}],
fn hashes -> hashes ++ [{path, hash}] end
)
end
fireside_component_name = fireside_module.config()[:name]
local_component_config = get_local_component_config(fireside_component_name) || %{}
new_component_config =
local_component_config
|> Map.put(:origin, origin)
|> Map.put(:version, fireside_module.config()[:version])
|> Map.put(:files, igniter.assigns.hashes)
update_component_config(igniter, fireside_component_name, new_component_config)
end
defp ensure_path_is_a_fireside_component!(path) do
if !File.dir?(path) do
raise "directory `#{path}` doesn't exist"
end
fireside_module_path = Path.join(path, "/fireside.exs")
if !File.exists?(fireside_module_path) do
raise "#{path} is not a Fireside component, aborting."
end
end
defp get_fireside_component_module(component_name, component_path) do
fireside_module_path = Path.join(component_path, "/fireside.exs")
fireside_module = Fireside.Helpers.load_module(fireside_module_path)
if fireside_module.config()[:name] != component_name do
raise "The provided Fireside module is not \"#{component_name}\""
end
fireside_module
end
def update_component_config(igniter, component_name, new_config) do
Igniter.create_or_update_elixir_file(
igniter,
".fireside.exs",
"""
[
components: [
#{component_name}: #{inspect(new_config)}
]
]
""",
fn zipper ->
rightmost = Common.rightmost(zipper)
if Igniter.Code.List.list?(rightmost) do
Igniter.Code.Keyword.put_in_keyword(zipper, [:components, component_name], new_config)
else
raise "Expected the last element of the .fireside.exs file to be a list, got #{rightmost}"
end
end
)
end
defp get_local_component_config(component_name) do
fireside_config = parse_project_fireside_config()
if fireside_config[:components] do
fireside_config[:components][component_name]
end
end
defp parse_project_fireside_config do
igniter = Igniter.include_existing_file(Igniter.new(), ".fireside.exs")
case Rewrite.source(igniter.rewrite, ".fireside.exs") do
{:error, _} ->
[]
{:ok, source} ->
{fireside_exs, _} = source |> Rewrite.Source.get(:quoted) |> Code.eval_quoted()
fireside_exs
end
end
defp track_managed_files(igniter, local_component_config) do
for {file_path, _hash} <- Map.get(local_component_config, :files, []), reduce: igniter do
igniter ->
igniter
|> Igniter.include_existing_file(file_path)
|> Igniter.update_assign(
:fireside_managed_files,
[file_path],
fn fireside_managed_files -> fireside_managed_files ++ [file_path] end
)
end
end
defp replace_component_name(igniter, fireside_module) do
fireside_module_prefix = Fireside.Helpers.get_module_prefix(fireside_module)
project_prefix = Fireside.Helpers.get_module_prefix(Mix.Project.get!())
igniter = Igniter.include_all_elixir_files(igniter)
for source <- Rewrite.sources(igniter.rewrite),
Rewrite.Source.get(source, :path) != ".fireside.exs",
reduce: igniter do
igniter ->
new_quoted =
source
|> Rewrite.Source.get(:quoted)
|> Fireside.Helpers.replace_module_prefix_from_to(
fireside_module_prefix,
project_prefix
)
|> Fireside.Helpers.replace_app_prefix(
Igniter.Project.Application.app_name(igniter),
fireside_module.config()[:name]
)
new_source =
Rewrite.Source.update(
source,
:quoted,
new_quoted
)
%{igniter | rewrite: Rewrite.update!(igniter.rewrite, new_source)}
end
end
defp install_required_dependencies(component_path, opts) do
mix_file = Path.join(component_path, "mix.exs")
if !File.exists?(mix_file) do
raise "mix.exs not found in the component directory"
end
{deps, _} =
mix_file
|> File.read!()
|> Sourceror.parse_string!()
|> Sourceror.Zipper.zip()
|> Function.move_to_defp(:deps, 0)
|> then(fn {:ok, zipper} ->
case Common.move_right(zipper, &Igniter.Code.List.list?/1) do
{:ok, zipper} ->
zipper
:error ->
{:error, "deps/0 doesn't return a list"}
end
end)
|> Sourceror.Zipper.node()
|> Code.eval_quoted()
yes? = Keyword.get(opts, :yes?, false)
deps
|> Enum.reject(&(elem(&1, 0) == :igniter))
|> Enum.reject(fn
{_app, opts} when is_list(opts) ->
Keyword.get(opts, :optional, false)
{_app, _requirement, opts} ->
Keyword.get(opts, :optional, false)
{_app, _requirement} ->
false
end)
|> Igniter.Util.Install.install(if(yes?, do: ["--yes"], else: []), Igniter.new(),
error_on_abort?: true,
title: "Fireside",
notify_on_present?: false
)
end
defp ensure_integrity!(imported_component_config) do
igniter = track_managed_files(Igniter.new(), imported_component_config)
for {file_path, hash} <- imported_component_config[:files] do
if Igniter.exists?(igniter, file_path) do
# Skip integrity check if hash is nil (component was installed with --no-hash)
if !is_nil(hash) do
source =
igniter.rewrite
|> Rewrite.source!(file_path)
|> Rewrite.Source.get(:quoted)
|> Fireside.Helpers.remove_fireside_comments()
if Fireside.Helpers.calculate_hash(source) != hash do
raise "#{file_path} has diverged from its original source, aborting."
end
end
else
raise "#{file_path} does not exist, aborting."
end
end
:ok
end
defp run_upgrades(igniter, fireside_module, current_version) do
target_version = fireside_module.config()[:version]
if target_version > current_version do
{igniter, _version} =
Enum.reduce(
(current_version + 1)..target_version,
{igniter, current_version},
fn next_version, {igniter, current_version} ->
igniter =
try do
fireside_module.upgrade(igniter, current_version, next_version)
rescue
_ -> igniter
end
{igniter, current_version + 1}
end
)
igniter
else
igniter
end
end
defp compute_and_include_hash(ast, component_name, no_hash? \\ false) do
hash = if no_hash?, do: nil, else: Fireside.Helpers.calculate_hash(ast)
new_ast =
if no_hash? do
ast
else
Sourceror.prepend_comments(
ast,
[
%{
line: 1,
previous_eol_count: 1,
next_eol_count: 1,
text:
"#! fireside: DO NOT EDIT this file. Run `mix fireside.unlock #{component_name}` if you want to stop syncing."
}
],
:leading
)
end
{new_ast, hash}
end
defp update_formatter_ignores(igniter) do
igniter
end
end