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.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
Config.configures_key?(
Igniter.new(),
"fireside.exs",
Igniter.Project.Application.app_name(),
[Fireside, component_name]
)
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.
## 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(Igniter.new(), 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.
## 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)
ensure_integrity!(local_component_config)
opts = opts ++ [current_version: local_component_config[:version]]
Fireside.Helpers.ensure_clean_git!()
igniter = track_managed_files(Igniter.new(), local_component_config)
case source do
nil ->
do_install_or_update(
igniter,
component_name,
local_component_config[:origin],
opts
)
source ->
do_install_or_update(igniter, 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(
"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."
)
|> 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("""
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.
""")
|> add_deletions()
|> remove_local_component_config(component_name)
if run_igniter(igniter, opts) == :changes_made do
cleanup_no_longer_used_files(igniter)
end
: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(igniter, component_name, source, opts)
defp do_install_or_update(igniter, component_name, [{:github, github_repo} | params], opts) do
git_url = "https://github.com/#{github_repo}.git"
do_install_or_update(igniter, component_name, [git: git_url] ++ params, opts)
end
defp do_install_or_update(igniter, 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(igniter, component_name, temp_dir, origin, opts)
File.rm_rf!(temp_dir)
end
defp do_install_or_update(igniter, component_name, [path: component_path] = origin, opts) do
import_component(igniter, component_name, component_path, origin, opts)
end
defp import_component(igniter, 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)
{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()
igniter =
if unlocked? do
igniter
else
add_or_replace_fireside_lock(
igniter,
fireside_module,
origin
)
end
igniter =
Igniter.add_notice(
igniter,
"\"#{component_name}\" (version: #{fireside_module.config()[:version]}) has been successfully installed."
)
if run_igniter(igniter, opts) in [:changes_made, :no_changes] do
cleanup_no_longer_used_files(igniter)
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.Code.Module.proper_location(module_name)
"/test/support/" <> _ ->
Igniter.Code.Module.proper_test_support_location(module_name)
"/test/" <> _ ->
Igniter.Code.Module.proper_test_location(module_name)
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
|> Igniter.add_warning("#{file_path} will be deleted.")
|> Igniter.update_assign(:deletions, [file_path], fn deletions -> deletions ++ [file_path] end)
end
end
defp cleanup_no_longer_used_files(igniter) do
for file_path <- igniter.assigns.deletions do
File.rm!(file_path)
end
end
defp remove_local_component_config(igniter, component_name) do
Config.configure(
igniter,
"fireside.exs",
Igniter.Project.Application.app_name(),
[Fireside],
[],
updater: fn zipper ->
Igniter.Code.Keyword.remove_keyword_key(zipper, component_name)
end
)
end
defp add_or_replace_fireside_lock(igniter, fireside_module, origin) 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])
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
app_name = Igniter.Project.Application.app_name()
igniter
|> Config.configure(
"fireside.exs",
app_name,
[Fireside, fireside_module.config()[:name], :origin],
origin
)
|> Config.configure(
"fireside.exs",
app_name,
[Fireside, fireside_module.config()[:name], :version],
fireside_module.config()[:version]
)
|> Config.configure(
"fireside.exs",
app_name,
[Fireside, fireside_module.config()[:name], :files],
Macro.escape(igniter.assigns.hashes)
)
end
defp ensure_path_is_a_fireside_component!(path) do
unless File.dir?(path) do
raise "directory `#{path}` doesn't exist"
end
fireside_module_path = Path.join(path, "/fireside.exs")
unless 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)
unless fireside_module.config()[:name] == component_name do
raise "The provided Fireside module is not \"#{component_name}\""
end
fireside_module
end
defp get_local_component_config(component_name) do
zipper =
"config/fireside.exs"
|> File.read!()
|> Sourceror.parse_string!()
|> Sourceror.Zipper.zip()
otp_app = Igniter.Project.Application.app_name()
{:ok, zipper} =
Function.move_to_function_call_in_current_scope(
zipper,
:config,
3,
fn function_call ->
Function.argument_equals?(function_call, 0, otp_app) and
Function.argument_equals?(function_call, 1, Fireside) and
Function.argument_matches_predicate?(
function_call,
2,
fn argument_zipper ->
Igniter.Code.Keyword.keyword_has_path?(argument_zipper, [component_name])
end
)
end
)
{{:__block__, _, [^component_name]}, map} =
zipper
|> Function.move_to_nth_argument(2)
|> then(fn {:ok, zipper} -> zipper end)
|> Sourceror.Zipper.down()
|> Sourceror.Zipper.node()
{config, []} =
map
|> Sourceror.to_string()
|> Code.eval_string()
config
end
defp track_managed_files(igniter, local_component_config) do
for {file_path, _hash} <- 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) != "config/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(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")
unless 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 Igniter.Code.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(),
append?: true,
error_on_abort?: true,
title: "Fireside",
notify_on_present?: false
)
Mix.Task.run("deps.compile")
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
source =
igniter.rewrite
|> Rewrite.source!(file_path)
|> Rewrite.Source.get(:quoted)
|> Fireside.Helpers.remove_fireside_comments()
unless Fireside.Helpers.calculate_hash(source) == hash do
raise "#{file_path} has diverged from its original source, aborting."
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) do
hash = Fireside.Helpers.calculate_hash(ast)
{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
), hash}
end
end