From 2886ab8e0558f498849a8910a8c9f321b0dd82b3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Apr 2026 13:15:19 +0000 Subject: [PATCH 1/4] Add mix desktop.check_toolchain and .tool-versions parsing Introduce Desktop.ToolVersions and Desktop.Toolchain to compare the active OTP major and Elixir release against .tool-versions without invoking asdf or mise, so CI and shell wrappers can fail fast with a clear message. Co-authored-by: Dominic Letz --- lib/desktop/tool_versions.ex | 49 +++++++++++++ lib/desktop/toolchain.ex | 89 ++++++++++++++++++++++++ lib/mix/tasks/desktop.check_toolchain.ex | 57 +++++++++++++++ test/desktop/tool_versions_test.exs | 16 +++++ test/desktop/toolchain_test.exs | 36 ++++++++++ 5 files changed, 247 insertions(+) create mode 100644 lib/desktop/tool_versions.ex create mode 100644 lib/desktop/toolchain.ex create mode 100644 lib/mix/tasks/desktop.check_toolchain.ex create mode 100644 test/desktop/tool_versions_test.exs create mode 100644 test/desktop/toolchain_test.exs diff --git a/lib/desktop/tool_versions.ex b/lib/desktop/tool_versions.ex new file mode 100644 index 0000000..553f8d4 --- /dev/null +++ b/lib/desktop/tool_versions.ex @@ -0,0 +1,49 @@ +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, :enoent}` when the file is missing. + """ + @spec read_from_dir(String.t()) :: {:ok, map()} | {:error, :enoent} + def read_from_dir(directory) do + path = Path.join(directory, ".tool-versions") + + case File.read(path) do + {:ok, body} -> {:ok, parse(body)} + {:error, :enoent} -> {:error, :enoent} + {:error, _} -> {:error, :enoent} + end + end +end diff --git a/lib/desktop/toolchain.ex b/lib/desktop/toolchain.ex new file mode 100644 index 0000000..3a690e1 --- /dev/null +++ b/lib/desktop/toolchain.ex @@ -0,0 +1,89 @@ +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, or `{:ok, :verified}` on success. + + `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} + + {: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 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..891eaa6 --- /dev/null +++ b/lib/mix/tasks/desktop.check_toolchain.ex @@ -0,0 +1,57 @@ +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) + + 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 + """) + + 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 +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..0d54667 --- /dev/null +++ b/test/desktop/toolchain_test.exs @@ -0,0 +1,36 @@ +defmodule Desktop.ToolchainTest do + use ExUnit.Case, async: true + + 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 +end From 52bda17bfd96fd54033b06894923f3a7c55af20a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Apr 2026 13:15:22 +0000 Subject: [PATCH 2/4] Document mise with asdf and toolchain check in guides Update Linux getting started to cover mise and asdf equally, point README readers at .tool-versions and mix desktop.check_toolchain, and note wrapper scripts should use mise exec or asdf exec rather than asdf-only paths. Co-authored-by: Dominic Letz --- CHANGELOG.md | 1 + README.md | 4 +++- guides/getting_started.md | 31 ++++++++++++++++++++++++++++--- 3 files changed, 32 insertions(+), 4 deletions(-) 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: From 4926e7fa3b93f3946278c0d246a3536cc6aaef48 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Apr 2026 14:18:41 +0000 Subject: [PATCH 3/4] Fix toolchain tests tmp_dir tag; propagate .tool-versions read errors ExUnit only injects context.tmp_dir when :tmp_dir is tagged; add @moduletag so Desktop.ToolchainTest setup runs. Stop mapping all File.read failures to :enoent; surface real I/O errors via Desktop.Toolchain.verify with a clear message for the Mix task. Co-authored-by: Dominic Letz --- lib/desktop/tool_versions.ex | 7 +++---- lib/desktop/toolchain.ex | 11 +++++++++++ test/desktop/toolchain_test.exs | 10 ++++++++-- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/lib/desktop/tool_versions.ex b/lib/desktop/tool_versions.ex index 553f8d4..8fd8222 100644 --- a/lib/desktop/tool_versions.ex +++ b/lib/desktop/tool_versions.ex @@ -34,16 +34,15 @@ defmodule Desktop.ToolVersions do @doc """ Reads `.tool-versions` from `directory` and parses it. Returns `{:ok, map}` or - `{:error, :enoent}` when the file is missing. + `{:error, reason}` from `File.read/1` (typically `:enoent` when the file is missing). """ - @spec read_from_dir(String.t()) :: {:ok, map()} | {:error, :enoent} + @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, :enoent} -> {:error, :enoent} - {:error, _} -> {:error, :enoent} + {:error, reason} -> {:error, reason} end end end diff --git a/lib/desktop/toolchain.ex b/lib/desktop/toolchain.ex index 3a690e1..62e2961 100644 --- a/lib/desktop/toolchain.ex +++ b/lib/desktop/toolchain.ex @@ -22,6 +22,9 @@ defmodule Desktop.Toolchain do {:error, :enoent} -> {:ok, :no_tool_versions} + {:error, reason} -> + {:error, [read_tool_versions_failed_message(reason)]} + {:ok, tools} -> errors = [] @@ -75,6 +78,14 @@ defmodule Desktop.Toolchain do 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) diff --git a/test/desktop/toolchain_test.exs b/test/desktop/toolchain_test.exs index 0d54667..3fdaa66 100644 --- a/test/desktop/toolchain_test.exs +++ b/test/desktop/toolchain_test.exs @@ -1,6 +1,8 @@ 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) @@ -23,14 +25,18 @@ defmodule Desktop.ToolchainTest do 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 {: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 {:error, [msg]} = + Desktop.Toolchain.verify(root, otp_release: "26", elixir_version: "1.18.0") + assert msg =~ "Elixir mismatch" end end From 90ab8c94e32f3e8a90fe835652c9bd88983e28ea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Apr 2026 14:20:57 +0000 Subject: [PATCH 4/4] Skip version-manager hint when .tool-versions is unreadable The Mix task should not suggest mise/asdf when failure is an I/O error from File.read. Document read failures in Toolchain.verify/2 and add a regression test using a directory named .tool-versions. Co-authored-by: Dominic Letz --- lib/desktop/toolchain.ex | 3 ++- lib/mix/tasks/desktop.check_toolchain.ex | 18 ++++++++++++------ test/desktop/toolchain_test.exs | 7 +++++++ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/lib/desktop/toolchain.ex b/lib/desktop/toolchain.ex index 62e2961..de434b8 100644 --- a/lib/desktop/toolchain.ex +++ b/lib/desktop/toolchain.ex @@ -5,7 +5,8 @@ defmodule Desktop.Toolchain do 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, or `{:ok, :verified}` on success. + 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: diff --git a/lib/mix/tasks/desktop.check_toolchain.ex b/lib/mix/tasks/desktop.check_toolchain.ex index 891eaa6..38ec028 100644 --- a/lib/mix/tasks/desktop.check_toolchain.ex +++ b/lib/mix/tasks/desktop.check_toolchain.ex @@ -32,12 +32,14 @@ defmodule Mix.Tasks.Desktop.CheckToolchain do {:error, messages} -> for msg <- messages, do: Mix.shell().error(msg) - 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 - """) + 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 @@ -54,4 +56,8 @@ defmodule Mix.Tasks.Desktop.CheckToolchain do 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/toolchain_test.exs b/test/desktop/toolchain_test.exs index 3fdaa66..fbf5844 100644 --- a/test/desktop/toolchain_test.exs +++ b/test/desktop/toolchain_test.exs @@ -39,4 +39,11 @@ defmodule Desktop.ToolchainTest do 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