defmodule Mix.Tasks.Hexorcist.Update do
@shortdoc "Applies a project's Hexorcist upgrade plan locally (mix deps.update)"
@moduledoc """
Fetches this project's upgrade plan from Hexorcist and applies the safe,
lockfile-only part (`mix deps.update`), optionally test-gated, leaving the
`mix.lock` change as a reviewable diff. It never commits or pushes by default.
mix hexorcist.update --project <public-id>
Configuration — flags override env, env overrides the repo config file
`.hexorcist.exs` (a keyword list, e.g. `[project: "…", url: "…"]`):
* `--project` / `HEXORCIST_PROJECT` / `:project` — the project's public id (required)
* `--token` / `HEXORCIST_TOKEN` — account token (required; never put in the file)
* `--url` / `HEXORCIST_URL` / `:url` — base URL (default https://hexorcist.net)
* `--test` — run `mix test` after updating; roll back changes if it fails
* `--edit-mix` — raise `mix.exs` constraints to unlock still-blocked majors
* `--commit` — `git commit` the change (still never pushes)
* `--dry-run` — just print the plan; change nothing
Commit `.hexorcist.exs` so the project id travels with the repo; keep the
**token** in your environment (it's a secret).
Without `--edit-mix`, only **lockfile** bumps are applied (updates that fit
your existing `mix.exs`). With it, a still-blocked update has its literal
`{:dep, "~> x"}` constraint raised — in the root `mix.exs` or, for an umbrella,
whichever `apps/*/mix.exs` declares it (all of them, if several do). Anything
not safely rewritable is reported as a suggestion, and an edit that doesn't
actually unlock the dep is reverted.
"""
use Mix.Task
@switches [
project: :string,
token: :string,
url: :string,
test: :boolean,
commit: :boolean,
edit_mix: :boolean,
dry_run: :boolean
]
@default_url "https://hexorcist.net"
@config_file ".hexorcist.exs"
@impl true
def run(argv) do
{opts, _, _} = OptionParser.parse(argv, switches: @switches)
config = load_config()
project =
opt(opts, :project, "HEXORCIST_PROJECT") || config[:project] ||
abort("--project (or HEXORCIST_PROJECT, or :project in #{@config_file}) is required")
token =
opt(opts, :token, "HEXORCIST_TOKEN") || abort("--token (or HEXORCIST_TOKEN) is required")
url = opt(opts, :url, "HEXORCIST_URL") || config[:url] || @default_url
plan = fetch_plan(url, project, token)
print_plan(plan)
cond do
plan["deps_update_command"] in [nil, ""] ->
Mix.shell().info("\nNothing to update. 🎉")
opts[:dry_run] ->
Mix.shell().info("\n[dry-run] would run: #{plan["deps_update_command"]}")
true ->
apply_plan(plan, opts)
end
end
## Fetch
defp fetch_plan(url, project, token) do
{:ok, _} = Application.ensure_all_started(:inets)
{:ok, _} = Application.ensure_all_started(:ssl)
endpoint = "#{String.trim_trailing(url, "/")}/api/projects/#{project}/upgrade-plan"
headers = [{~c"authorization", ~c"Bearer #{token}"}, {~c"accept", ~c"application/json"}]
case :httpc.request(:get, {String.to_charlist(endpoint), headers}, [], body_format: :binary) do
{:ok, {{_, 200, _}, _h, body}} ->
:json.decode(body)
{:ok, {{_, status, _}, _h, body}} ->
abort("Hexorcist returned HTTP #{status}: #{String.slice(body, 0, 300)}")
{:error, reason} ->
abort("request failed: #{inspect(reason)}")
end
end
## Apply
defp apply_plan(plan, opts) do
packages = Enum.map(plan["updates"], & &1["package"])
Mix.shell().info("\nUpdating #{length(packages)} package(s) in the lockfile…")
cmd!("mix", ["deps.update" | packages])
cmd!("mix", ["deps.get"])
if opts[:edit_mix], do: edit_constraints(plan)
if opts[:test], do: gate_on_tests(["mix.lock", "mix.exs"])
files = ["mix.lock"] ++ if(opts[:edit_mix], do: mix_files(), else: [])
diff = git(["diff", "--stat" | files])
if String.trim(diff) == "" do
Mix.shell().info(
"\nNo changes (everything was constraint-capped — try --edit-mix to raise mix.exs)."
)
else
Mix.shell().info("\nChanges:\n#{diff}")
if opts[:commit], do: commit(files)
Mix.shell().info(
"\nDone. Review the diff" <>
if(opts[:commit], do: " (committed, not pushed).", else: " and commit when ready.")
)
end
end
## Phase 2 — raise mix.exs constraints to unlock still-blocked majors
defp edit_constraints(plan) do
locked = locked_versions()
paths = mix_files()
blocked =
Enum.filter(plan["updates"], fn u ->
cur = Map.get(locked, u["package"])
is_binary(cur) and is_binary(u["latest"]) and newer?(u["latest"], cur)
end)
if blocked == [] do
Mix.shell().info("\nNo constraint-blocked updates.")
else
Mix.shell().info(
"\nTrying to raise mix.exs constraints for #{length(blocked)} blocked dep(s)…"
)
Enum.each(blocked, &try_unblock(&1, paths))
end
end
# The mix.exs files that can declare a dep's constraint: the root `mix.exs`,
# plus — for an umbrella — every child app under `apps_path` (`apps/*/mix.exs`
# by default). A dep declared in several child apps gets raised in each.
defp mix_files do
apps =
case File.read("mix.exs") do
{:ok, contents} ->
case HexorcistUpdate.MixEdit.umbrella_apps_path(contents) do
{:ok, apps_path} -> Path.wildcard(Path.join(apps_path, "*/mix.exs"))
:none -> []
end
_ ->
[]
end
["mix.exs" | apps] |> Enum.filter(&File.exists?/1) |> Enum.uniq()
end
defp try_unblock(update, paths) do
dep = update["package"]
latest = update["latest"]
case HexorcistUpdate.MixEdit.new_constraint(latest) do
{:ok, new_constraint} ->
# Snapshot every candidate file so a failed unblock reverts to exactly
# the state on entry — preserving edits a previous dep already kept.
snapshots = Map.new(paths, &{&1, read!(&1)})
if Enum.any?(snapshots, fn {_path, contents} ->
HexorcistUpdate.MixEdit.overridden?(contents, dep)
end) do
# A deliberate override pin (often load-bearing in umbrellas) — never
# bump it automatically; just flag it.
suggest(dep, latest, "pinned with `override: true` — bump it by hand if you mean to")
else
unblock_files(dep, latest, new_constraint, snapshots)
end
:error ->
suggest(dep, latest, "couldn't parse the target version")
end
end
defp unblock_files(dep, latest, new_constraint, snapshots) do
results =
Map.new(snapshots, fn {path, contents} ->
{path, HexorcistUpdate.MixEdit.bump(contents, dep, new_constraint)}
end)
edited =
for {path, {:ok, new_contents, _old}} <- results do
File.write!(path, new_contents)
path
end
if edited == [] do
explain_no_edit(dep, latest, Map.values(results))
else
finish_unblock(dep, latest, new_constraint, edited, snapshots)
end
end
defp finish_unblock(dep, latest, new_constraint, edited, snapshots) do
cmd("mix", ["deps.update", dep])
if newer?(latest, Map.get(locked_versions(), dep, "0.0.0")) do
# The edit didn't actually unlock it (a transitive parent still caps it).
Enum.each(edited, fn path -> File.write!(path, Map.fetch!(snapshots, path)) end)
cmd("mix", ["deps.get"])
Mix.shell().info(
" #{dep}: raised to #{new_constraint} in #{Enum.join(edited, ", ")}, " <>
"but still blocked — reverted."
)
else
Mix.shell().info(
" #{dep}: → #{new_constraint} in #{Enum.join(edited, ", ")} ✓ " <>
"(now #{Map.get(locked_versions(), dep)})"
)
end
end
# No file held an editable literal — report the most informative reason.
defp explain_no_edit(dep, latest, results) do
cond do
Enum.any?(results, &(&1 == :ambiguous)) ->
suggest(dep, latest, "appears multiple times in a single mix.exs")
old =
Enum.find_value(results, fn
{:skip, o} -> o
_ -> nil
end) ->
suggest(dep, latest, "constraint #{old} isn't a `~>` requirement")
true ->
suggest(dep, latest, "no literal {:#{dep}, \"…\"} in any mix.exs (transitive or dynamic)")
end
end
defp suggest(dep, latest, why),
do: Mix.shell().info(" #{dep}: not auto-edited (#{why}) — bump it to ~> #{latest} by hand.")
# dep => version, read from mix.lock's `{:hex, :dep, "version", …}` entries.
defp locked_versions do
case File.read("mix.lock") do
{:ok, contents} ->
~r/\{:hex,\s*:([a-z0-9_]+),\s*"([^"]+)"/
|> Regex.scan(contents)
|> Map.new(fn [_, dep, version] -> {dep, version} end)
_ ->
%{}
end
end
defp newer?(a, b) do
case {Version.parse(a), Version.parse(b)} do
{{:ok, va}, {:ok, vb}} -> Version.compare(va, vb) == :gt
_ -> false
end
end
defp gate_on_tests(files) do
Mix.shell().info("\nRunning `mix test`…")
case System.cmd("mix", ["test"], into: IO.stream(:stdio, :line)) do
{_, 0} ->
:ok
{_, _} ->
git(["checkout", "--" | files])
cmd("mix", ["deps.get"])
abort("tests failed — rolled back #{Enum.join(files, ", ")}. Nothing changed.")
end
end
defp commit(files) do
git(["add" | files])
git(["commit", "-m", "Update dependencies (via Hexorcist)"])
end
## Output
defp print_plan(plan) do
Mix.shell().info("Upgrade plan for #{plan["project"]}:")
case plan["updates"] do
[] ->
Mix.shell().info(" (no available updates)")
updates ->
Enum.each(updates, fn u ->
tag = if u["reason"] == "vulnerability", do: " [VULN → #{u["fix"]}]", else: ""
Mix.shell().info(
" #{u["package"]}: #{u["current"]} → #{u["latest"]} (#{u["type"]})#{tag}"
)
end)
end
end
## Helpers
defp opt(opts, key, env), do: opts[key] || System.get_env(env)
# Reads `.hexorcist.exs` (a keyword list) from the current repo. Parsed as a
# literal, never `eval`'d — a cloned repo's config can't run code — and only
# the literal string values for :project/:url are taken.
defp load_config do
path = Path.join(File.cwd!(), @config_file)
with true <- File.exists?(path),
{:ok, contents} <- File.read(path),
{:ok, pairs} when is_list(pairs) <-
Code.string_to_quoted(contents, static_atoms_encoder: &keep_key/2) do
Enum.flat_map(pairs, fn
{"project", v} when is_binary(v) -> [project: v]
{"url", v} when is_binary(v) -> [url: v]
_ -> []
end)
else
_ -> []
end
end
# Keep source atoms as binaries so a hostile config can't intern junk atoms.
defp keep_key(atom_string, _meta), do: {:ok, atom_string}
defp cmd!(bin, args) do
case System.cmd(bin, args, into: IO.stream(:stdio, :line), stderr_to_stdout: true) do
{_, 0} -> :ok
{_, status} -> abort("`#{bin} #{Enum.join(args, " ")}` failed (exit #{status})")
end
end
# Non-raising variant (a per-dep `mix deps.update` that can't resolve isn't fatal).
defp cmd(bin, args) do
{_out, status} = System.cmd(bin, args, into: IO.stream(:stdio, :line), stderr_to_stdout: true)
status
end
defp read!(path), do: File.read!(path)
defp git(args) do
{out, 0} = System.cmd("git", args, stderr_to_stdout: true)
out
end
defp abort(message) do
Mix.raise("hexorcist.update: #{message}")
end
end