diff --git a/CHANGELOG.md b/CHANGELOG.md index 6432989..f3ec364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Changes in 1.5 - Support for iOS hibernation and wakeup +- Document mise alongside asdf for `.tool-versions`; add `mix desktop.check_toolchain` to verify the active Erlang/OTP and Elixir match the project file without depending on a specific version manager ## Changes in 1.4 diff --git a/README.md b/README.md index 83c259d..1aefe7d 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,9 @@ Checkout [the example app](https://github.com/elixir-desktop/desktop-example-app ## Getting Started -Check out the [Getting your Environment Ready Guide](./guides/getting_started.md) and [Your first Desktop App](./guides/your_first_desktop_app.md) +Check out the [Getting your Environment Ready Guide](./guides/getting_started.md) and [Your first Desktop App](./guides/your_first_desktop_app.md). + +Erlang/Elixir versions are pinned in `.tool-versions` for [mise](https://mise.jdx.dev/) or [asdf](https://asdf-vm.com/). After activating those runtimes, run `mix desktop.check_toolchain` to verify they match the file. ## Status / Roadmap diff --git a/guides/getting_started.md b/guides/getting_started.md index d415396..e2804eb 100644 --- a/guides/getting_started.md +++ b/guides/getting_started.md @@ -121,12 +121,37 @@ echo ". ~/maint-24/activate" >> ~/.bashrc Best to use Erlang solutions packages: https://www.erlang-solutions.com/downloads/ -**Or use ASDF** +**Or use a version manager (mise or asdf)** + +This repository ships a `.tool-versions` file so you can pin Erlang and Elixir with +[mise](https://mise.jdx.dev/) or [asdf](https://asdf-vm.com/). Either tool reads the same file format. + +Install the pinned versions, then run Mix from that environment: + +```bash +# mise (https://mise.jdx.dev/) +mise install +mise exec -- mix deps.get + +# asdf (https://asdf-vm.com/) +asdf plugin add erlang || true +asdf plugin add elixir || true +asdf install +asdf exec mix deps.get ``` -asdf plugin update --all -asdf install erlang 24.0.1 + +To confirm your shell is using the same OTP major and Elixir release as `.tool-versions` +(before CI or Android scripts run `mix`), use: + +```bash +mix desktop.check_toolchain ``` +This task only compares the **active** `elixir` / `erlang` in your PATH to `.tool-versions`; +it does not call mise or asdf, so it stays compatible with any way you activate those runtimes. + +For shell wrappers (for example an Android `run_mix` script), avoid hard-coding asdf-specific paths: run `mix` from an environment where Erlang/Elixir are already correct, or prefix the command with `mise exec --` or `asdf exec` so the same `.tool-versions` file drives every tool. + **Install NIF Dependencies:** For compiling NIFs you will need a c compiler and dependencies, such as from: diff --git a/lib/desktop/tool_versions.ex b/lib/desktop/tool_versions.ex new file mode 100644 index 0000000..8fd8222 --- /dev/null +++ b/lib/desktop/tool_versions.ex @@ -0,0 +1,48 @@ +defmodule Desktop.ToolVersions do + @moduledoc false + + @doc """ + Parses a `.tool-versions` file body into a map of tool name => declared version string. + + Names are normalized to lowercase. Lines starting with `#` and blank lines are ignored. + For lines with more than two whitespace-separated fields (e.g. `erlang 26.2.5.5 system`), + the version is the second field only. + """ + @spec parse(String.t()) :: %{optional(String.t()) => String.t()} + def parse(content) when is_binary(content) do + content + |> String.split("\n") + |> Enum.flat_map(&parse_line/1) + |> Map.new() + end + + defp parse_line(line) do + line = String.trim(line) + + cond do + line == "" or String.starts_with?(line, "#") -> + [] + + true -> + case String.split(line, ~r/\s+/, parts: 3) do + [tool, version] -> [{String.downcase(tool), version}] + [tool, version, _rest] -> [{String.downcase(tool), version}] + _ -> [] + end + end + end + + @doc """ + Reads `.tool-versions` from `directory` and parses it. Returns `{:ok, map}` or + `{:error, reason}` from `File.read/1` (typically `:enoent` when the file is missing). + """ + @spec read_from_dir(String.t()) :: {:ok, map()} | {:error, :enoent | File.posix() | :badarg} + def read_from_dir(directory) do + path = Path.join(directory, ".tool-versions") + + case File.read(path) do + {:ok, body} -> {:ok, parse(body)} + {:error, reason} -> {:error, reason} + end + end +end diff --git a/lib/desktop/toolchain.ex b/lib/desktop/toolchain.ex new file mode 100644 index 0000000..de434b8 --- /dev/null +++ b/lib/desktop/toolchain.ex @@ -0,0 +1,101 @@ +defmodule Desktop.Toolchain do + @moduledoc false + + @doc """ + Verifies that the running Erlang/OTP and Elixir versions match `.tool-versions` in + `project_root` when that file declares `erlang` and/or `elixir` entries. + + Returns `{:ok, :no_tool_versions}` if the file is missing, `{:ok, :verified}` on success, + or `{:error, [message]}` when `.tool-versions` cannot be read or versions do not match. + + `opts` may override runtime versions for testing: + + * `:otp_release` — string from `System.otp_release/0` (major OTP, e.g. `"26"`) + * `:elixir_version` — string from `System.version/0` (e.g. `"1.19.1"`) + """ + @spec verify(String.t(), keyword()) :: + {:ok, :no_tool_versions | :verified} | {:error, [String.t()]} + def verify(project_root, opts \\ []) do + otp = Keyword.get_lazy(opts, :otp_release, &System.otp_release/0) + elixir = Keyword.get_lazy(opts, :elixir_version, &System.version/0) + + case Desktop.ToolVersions.read_from_dir(project_root) do + {:error, :enoent} -> + {:ok, :no_tool_versions} + + {:error, reason} -> + {:error, [read_tool_versions_failed_message(reason)]} + + {:ok, tools} -> + errors = + [] + |> maybe_check_otp(tools, otp) + |> maybe_check_elixir(tools, elixir) + + if errors == [] do + {:ok, :verified} + else + {:error, Enum.reverse(errors)} + end + end + end + + defp maybe_check_otp(errors, tools, otp) do + case Map.get(tools, "erlang") do + nil -> + errors + + declared -> + expected = otp_major_from_declared(declared) + + if expected && expected != otp do + [ + "Erlang/OTP mismatch: .tool-versions requests OTP #{expected} (from #{declared}), but this shell is OTP #{otp}." + | errors + ] + else + errors + end + end + end + + defp maybe_check_elixir(errors, tools, elixir) do + case Map.get(tools, "elixir") do + nil -> + errors + + declared -> + expected = elixir_semver_from_declared(declared) + running = elixir_semver_from_declared(elixir) + + if expected && running && expected != running do + [ + "Elixir mismatch: .tool-versions requests #{expected} (from #{declared}), but this shell is #{running}." + | errors + ] + else + errors + end + end + end + + defp read_tool_versions_failed_message(reason) when is_atom(reason) do + "Could not read .tool-versions: #{:file.format_error(reason)}" + end + + defp read_tool_versions_failed_message(reason) do + "Could not read .tool-versions: #{inspect(reason)}" + end + + defp otp_major_from_declared(declared) do + declared + |> String.split(".", parts: 2) + |> List.first() + end + + defp elixir_semver_from_declared(declared) do + declared + |> String.split("-", parts: 2) + |> List.first() + end +end diff --git a/lib/mix/tasks/desktop.check_toolchain.ex b/lib/mix/tasks/desktop.check_toolchain.ex new file mode 100644 index 0000000..38ec028 --- /dev/null +++ b/lib/mix/tasks/desktop.check_toolchain.ex @@ -0,0 +1,63 @@ +defmodule Mix.Tasks.Desktop.CheckToolchain do + @shortdoc "Checks Erlang/OTP and Elixir against .tool-versions in the project" + + @moduledoc """ + #{@shortdoc} + + This task is tooling-agnostic: it does not invoke asdf, mise, or kerl. It only + compares the **currently active** `elixir` and `erlang` in your shell against the + versions declared in `.tool-versions` at the project root (next to `mix.exs`). + + Use it from CI scripts or Android build wrappers to fail fast with a clear message + when the wrong runtime is active. + + ## Examples + + mix desktop.check_toolchain + """ + + use Mix.Task + + @impl Mix.Task + def run(_argv) do + root = project_root() + + case Desktop.Toolchain.verify(root) do + {:ok, :no_tool_versions} -> + Mix.shell().info("No .tool-versions found; skipping version check.") + + {:ok, :verified} -> + Mix.shell().info(".tool-versions matches the active Erlang/OTP and Elixir.") + + {:error, messages} -> + for msg <- messages, do: Mix.shell().error(msg) + + unless toolchain_hint_irrelevant?(messages) do + Mix.shell().error(""" + Activate the versions in .tool-versions for this project, for example: + mise install && mise exec -- mix desktop.check_toolchain + or: + asdf install && asdf exec mix desktop.check_toolchain + """) + end + + System.halt(1) + end + end + + defp project_root do + if mix_project?() do + Mix.Project.project_file() |> Path.dirname() + else + File.cwd!() + end + end + + defp mix_project? do + Mix.Project.get() != nil + end + + defp toolchain_hint_irrelevant?(messages) do + Enum.any?(messages, &match?("Could not read .tool-versions:" <> _, &1)) + end +end diff --git a/test/desktop/tool_versions_test.exs b/test/desktop/tool_versions_test.exs new file mode 100644 index 0000000..bf5f801 --- /dev/null +++ b/test/desktop/tool_versions_test.exs @@ -0,0 +1,16 @@ +defmodule Desktop.ToolVersionsTest do + use ExUnit.Case, async: true + + test "parse ignores comments and blank lines" do + assert Desktop.ToolVersions.parse(""" + # comment + elixir 1.19.1 + + erlang 26.2.5.5 system + """) == %{"elixir" => "1.19.1", "erlang" => "26.2.5.5"} + end + + test "parse normalizes tool names to lowercase" do + assert Desktop.ToolVersions.parse("Elixir 1.2.3") == %{"elixir" => "1.2.3"} + end +end diff --git a/test/desktop/toolchain_test.exs b/test/desktop/toolchain_test.exs new file mode 100644 index 0000000..fbf5844 --- /dev/null +++ b/test/desktop/toolchain_test.exs @@ -0,0 +1,49 @@ +defmodule Desktop.ToolchainTest do + use ExUnit.Case, async: true + + @moduletag :tmp_dir + + setup context do + root = Path.join(context.tmp_dir, "proj") + File.mkdir_p!(root) + Map.put(context, :root, root) + end + + test "returns no_tool_versions when .tool-versions is missing", %{root: root} do + assert Desktop.Toolchain.verify(root) == {:ok, :no_tool_versions} + end + + test "verified when versions match", %{root: root} do + File.write!(Path.join(root, ".tool-versions"), "erlang 26.2.5.5\nelixir 1.19.1-otp-26\n") + + assert Desktop.Toolchain.verify(root, + otp_release: "26", + elixir_version: "1.19.1" + ) == {:ok, :verified} + end + + test "error when OTP major mismatches", %{root: root} do + File.write!(Path.join(root, ".tool-versions"), "erlang 26.0.1\n") + + assert {:error, [msg]} = + Desktop.Toolchain.verify(root, otp_release: "25", elixir_version: "1.19.1") + + assert msg =~ "OTP" + end + + test "error when Elixir semver mismatches", %{root: root} do + File.write!(Path.join(root, ".tool-versions"), "elixir 1.19.1-otp-26\n") + + assert {:error, [msg]} = + Desktop.Toolchain.verify(root, otp_release: "26", elixir_version: "1.18.0") + + assert msg =~ "Elixir mismatch" + end + + test "error when .tool-versions cannot be read", %{root: root} do + File.mkdir!(Path.join(root, ".tool-versions")) + + assert {:error, [msg]} = Desktop.Toolchain.verify(root) + assert msg =~ "Could not read .tool-versions" + end +end