Skip to content

Commit d5a7e33

Browse files
Document mise, add tooling-agnostic mix desktop.check_toolchain (#67) (#71)
* Add mix desktop.check_toolchain and .tool-versions parsing Introduce Desktop.ToolVersions and Desktop.Toolchain to compare the running OTP major and Elixir semver against project .tool-versions without shelling out to mise/asdf. Mix task fails fast with actionable messages when mismatched. Adds ExUnit coverage for parsing edge cases and verification behavior. Refs #67 Co-authored-by: Dominic Letz <dominicletz@users.noreply.github.com> * Document mise alongside asdf and link toolchain check task Linux getting started treats mise and asdf equally for .tool-versions, explains manager-agnostic shell wrappers, and documents mix desktop.check_toolchain. README notes contributor toolchain pinning. Refs #67 Co-authored-by: Dominic Letz <dominicletz@users.noreply.github.com> * Changelog: desktop.check_toolchain and mise/asdf docs Co-authored-by: Dominic Letz <dominicletz@users.noreply.github.com> * Fix Dialyzer: pass string suffix to Igniter.Project.Module.module_name/2 MainWindow was passed as an atom but the API expects String.t(), which made Dialyzer infer igniter/1 had no successful return and flagged the rest of the module as dead code. Use "MainWindow" consistently. Fixes CI Compile & Lint (dialyzer). Co-authored-by: Dominic Letz <dominicletz@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Dominic Letz <dominicletz@users.noreply.github.com>
1 parent 2fa4b21 commit d5a7e33

9 files changed

Lines changed: 364 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Changes in 1.5
44

55
- Support for iOS hibernation and wakeup
6+
- `mix desktop.check_toolchain` verifies running Erlang/OTP (major) and Elixir against `.tool-versions`; docs describe mise and asdf equally for Linux contributors
67

78
## Changes in 1.4
89

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ Checkout [the example app](https://github.com/elixir-desktop/desktop-example-app
1616

1717
## Getting Started
1818

19-
Check out the [Getting your Environment Ready Guide](./guides/getting_started.md) and [Your first Desktop App](./guides/your_first_desktop_app.md)
19+
Check out the [Getting your Environment Ready Guide](./guides/getting_started.md) and [Your first Desktop App](./guides/your_first_desktop_app.md).
20+
21+
This repo’s [`.tool-versions`](./.tool-versions) pins Erlang and Elixir for contributors; [mise](https://mise.jdx.dev/) and [asdf](https://asdf-vm.com/) both understand that file. After activating your toolchain, run `mix desktop.check_toolchain` to confirm the running OTP major and Elixir version match `.tool-versions`.
2022

2123
## Status / Roadmap
2224

guides/getting_started.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,31 @@ echo ". ~/maint-24/activate" >> ~/.bashrc
121121

122122
Best to use Erlang solutions packages: https://www.erlang-solutions.com/downloads/
123123

124-
**Or use ASDF**
124+
### Version managers (optional)
125+
126+
Many projects pin Erlang and Elixir with a **`.tool-versions`** file (same format works with [mise](https://mise.jdx.dev/) and [asdf](https://asdf-vm.com/)). Pick either tool—your shell only needs the correct `erl` / `elixir` on `PATH` when you run `mix`.
127+
128+
**mise** (example):
129+
130+
```bash
131+
curl https://mise.run | sh
132+
mise install
125133
```
134+
135+
**asdf** (example):
136+
137+
```bash
126138
asdf plugin update --all
127139
asdf install erlang 24.0.1
140+
asdf install elixir 1.14.0-otp-24
141+
```
142+
143+
Shell wrappers (for example Android `run_mix` scripts in the [example app](https://github.com/elixir-desktop/desktop-example-app)) should not hard-code asdf-specific paths. Prefer invoking Mix through your activated environment, or explicitly via `mise exec -- mix …` / `asdf exec mix …` when you rely on a version manager.
144+
145+
After your toolchain is active, you can verify it against the project’s `.tool-versions` from this library:
146+
147+
```bash
148+
mix desktop.check_toolchain
128149
```
129150

130151
**Install NIF Dependencies:**

lib/desktop/tool_versions.ex

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
defmodule Desktop.ToolVersions do
2+
@moduledoc false
3+
4+
@doc """
5+
Parses a `.tool-versions` file body (asdf/mise format).
6+
7+
Returns a map with optional string values for `:erlang` and `:elixir` keys
8+
(raw version fields from the file, e.g. `"26.2.5.5 system"`, `"1.19.1-otp-26"`).
9+
"""
10+
@spec parse(String.t()) :: %{optional(:erlang) => String.t(), optional(:elixir) => String.t()}
11+
def parse(content) when is_binary(content) do
12+
content
13+
|> String.split("\n")
14+
|> Enum.reduce(%{}, &parse_line/2)
15+
end
16+
17+
defp parse_line(line, acc) do
18+
line = String.trim(line)
19+
20+
cond do
21+
line == "" ->
22+
acc
23+
24+
String.starts_with?(line, "#") ->
25+
acc
26+
27+
true ->
28+
case Regex.run(~r/^(elixir|erlang)\s+(.+)$/, line) do
29+
[_, "elixir", rest] ->
30+
Map.put(acc, :elixir, String.trim(rest))
31+
32+
[_, "erlang", rest] ->
33+
Map.put(acc, :erlang, String.trim(rest))
34+
35+
_ ->
36+
acc
37+
end
38+
end
39+
end
40+
end

lib/desktop/toolchain.ex

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
defmodule Desktop.Toolchain do
2+
@moduledoc false
3+
4+
alias Desktop.ToolVersions
5+
6+
@doc """
7+
Verifies that running OTP major and Elixir version match entries parsed from `.tool-versions`.
8+
9+
`requirements` is the map returned by `Desktop.ToolVersions.parse/1`.
10+
11+
Optional `otp_release` and `elixir_version` override `System` values (for testing).
12+
13+
Returns `:ok` or `{:error, message}` where `message` is human-readable.
14+
"""
15+
@spec verify(map(), keyword()) :: :ok | {:error, String.t()}
16+
def verify(requirements, opts \\ []) when is_map(requirements) do
17+
otp_actual = Keyword.get_lazy(opts, :otp_release, fn -> System.otp_release() end)
18+
elixir_actual = Keyword.get_lazy(opts, :elixir_version, fn -> System.version() end)
19+
20+
errors =
21+
[]
22+
|> maybe_check_erlang(Map.get(requirements, :erlang), otp_actual)
23+
|> maybe_check_elixir(Map.get(requirements, :elixir), elixir_actual)
24+
25+
case errors do
26+
[] -> :ok
27+
msgs -> {:error, Enum.join(msgs, "\n")}
28+
end
29+
end
30+
31+
defp maybe_check_erlang(acc, nil, _otp_actual), do: acc
32+
33+
defp maybe_check_erlang(acc, raw, otp_actual) when is_binary(raw) do
34+
expected_major = erlang_major(raw)
35+
36+
case Integer.parse(to_string(otp_actual)) do
37+
{actual_major, _} when actual_major == expected_major ->
38+
acc
39+
40+
{actual_major, _} ->
41+
[
42+
"Erlang/OTP major mismatch: running OTP #{actual_major}, `.tool-versions` expects OTP #{expected_major} (from erlang #{inspect(raw)})."
43+
| acc
44+
]
45+
46+
:error ->
47+
["Could not parse running OTP release #{inspect(otp_actual)}." | acc]
48+
end
49+
end
50+
51+
defp maybe_check_elixir(acc, nil, _elixir_actual), do: acc
52+
53+
defp maybe_check_elixir(acc, raw, elixir_actual) when is_binary(raw) do
54+
expected_base = elixir_base_version(raw)
55+
56+
case Version.parse(expected_base) do
57+
{:ok, expected_ver} ->
58+
case Version.parse(elixir_actual) do
59+
{:ok, actual_ver} ->
60+
if Version.compare(actual_ver, expected_ver) == :eq do
61+
acc
62+
else
63+
[
64+
"Elixir version mismatch: running #{elixir_actual}, `.tool-versions` expects #{expected_base} (from elixir #{inspect(raw)})."
65+
| acc
66+
]
67+
end
68+
69+
:error ->
70+
["Could not parse running Elixir version #{inspect(elixir_actual)}." | acc]
71+
end
72+
73+
:error ->
74+
["Could not parse Elixir version in `.tool-versions`: #{inspect(raw)}." | acc]
75+
end
76+
end
77+
78+
@doc false
79+
def erlang_major(raw) when is_binary(raw) do
80+
raw
81+
|> String.split()
82+
|> hd()
83+
|> String.split(".")
84+
|> hd()
85+
|> String.to_integer()
86+
end
87+
88+
@doc false
89+
def elixir_base_version(raw) when is_binary(raw) do
90+
case Regex.run(~r/^(\d+\.\d+\.\d+)/, raw) do
91+
[_, base] ->
92+
base
93+
94+
nil ->
95+
raw
96+
|> String.split("-")
97+
|> hd()
98+
end
99+
end
100+
101+
@doc """
102+
Loads `.tool-versions` from `path`, parses it, and verifies the toolchain.
103+
"""
104+
@spec verify_file(String.t(), keyword()) :: :ok | {:error, String.t()}
105+
def verify_file(path, opts \\ []) do
106+
case File.read(path) do
107+
{:ok, content} ->
108+
content |> ToolVersions.parse() |> verify(opts)
109+
110+
{:error, reason} ->
111+
{:error, "Could not read #{inspect(path)}: #{inspect(reason)}"}
112+
end
113+
end
114+
end
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
defmodule Mix.Tasks.Desktop.CheckToolchain do
2+
@shortdoc "Checks running Erlang/OTP and Elixir against `.tool-versions`"
3+
4+
@moduledoc """
5+
#{@shortdoc}
6+
7+
Compares the **currently running** BEAM (`System.otp_release/0`, `System.version/0`)
8+
to `erlang` and `elixir` lines in `.tool-versions`. It does not invoke mise, asdf, or
9+
other installers—activate your toolchain however you prefer, then run this task to fail fast.
10+
11+
## Examples
12+
13+
mix desktop.check_toolchain
14+
15+
"""
16+
17+
use Mix.Task
18+
19+
@impl Mix.Task
20+
def run(_argv) do
21+
root = Mix.Project.config()[:root] || File.cwd!()
22+
path = Path.join(root, ".tool-versions")
23+
24+
unless File.exists?(path) do
25+
Mix.shell().error("No `.tool-versions` found at #{path}.")
26+
exit({:shutdown, 1})
27+
end
28+
29+
case Desktop.Toolchain.verify_file(path) do
30+
:ok ->
31+
Mix.shell().info("Toolchain matches `.tool-versions`.")
32+
33+
{:error, msg} ->
34+
Mix.shell().error(msg)
35+
exit({:shutdown, 1})
36+
end
37+
end
38+
end

lib/mix/tasks/desktop.install.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do
5050
menu = Igniter.Project.Module.module_name(igniter, "Menu")
5151
menubar = Igniter.Project.Module.module_name(igniter, "MenuBar")
5252
gettext = Igniter.Libs.Phoenix.web_module_name(igniter, "Gettext")
53-
main_window = Igniter.Project.Module.module_name(igniter, MainWindow)
53+
main_window = Igniter.Project.Module.module_name(igniter, "MainWindow")
5454

5555
igniter
5656
|> Igniter.compose_task("igniter.add", ["desktop"])
@@ -63,7 +63,7 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do
6363
quote do
6464
[
6565
app: unquote(app),
66-
id: unquote(Igniter.Project.Module.module_name(igniter, MainWindow)),
66+
id: unquote(Igniter.Project.Module.module_name(igniter, "MainWindow")),
6767
title: unquote(to_string(app)),
6868
size: {600, 500},
6969
menubar: unquote(menubar),
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
defmodule Desktop.ToolVersionsTest do
2+
use ExUnit.Case, async: true
3+
4+
alias Desktop.ToolVersions
5+
6+
test "parse ignores comments and blank lines" do
7+
content = """
8+
# comment
9+
erlang 26.2.5.5 system
10+
11+
elixir 1.19.1-otp-26
12+
"""
13+
14+
assert ToolVersions.parse(content) == %{
15+
erlang: "26.2.5.5 system",
16+
elixir: "1.19.1-otp-26"
17+
}
18+
end
19+
20+
test "parse handles extra whitespace on values" do
21+
content = "elixir 1.12.3 \n"
22+
23+
assert ToolVersions.parse(content) == %{elixir: "1.12.3"}
24+
end
25+
26+
test "parse empty file" do
27+
assert ToolVersions.parse("") == %{}
28+
end
29+
end

0 commit comments

Comments
 (0)