diff --git a/lib/next_ls.ex b/lib/next_ls.ex index ee7044e3..4ce70445 100644 --- a/lib/next_ls.ex +++ b/lib/next_ls.ex @@ -46,6 +46,7 @@ defmodule NextLS do {args, opts} = Keyword.split(args, [ :cache, + :auto_update, :task_supervisor, :runtime_task_supervisor, :dynamic_supervisor, @@ -70,6 +71,7 @@ defmodule NextLS do {:ok, assign(lsp, + auto_update: Keyword.get(args, :auto_update, false), exit_code: 1, documents: %{}, refresh_refs: %{}, @@ -360,6 +362,14 @@ defmodule NextLS do def handle_notification(%Initialized{}, lsp) do GenLSP.log(lsp, "[NextLS] NextLS v#{version()} has initialized!") + with opts when is_list(opts) <- lsp.assigns.auto_update do + {:ok, _} = + DynamicSupervisor.start_child( + lsp.assigns.dynamic_supervisor, + {NextLS.Updater, Keyword.merge(opts, logger: lsp.assigns.logger)} + ) + end + for extension <- lsp.assigns.extensions do {:ok, _} = DynamicSupervisor.start_child( @@ -664,7 +674,7 @@ defmodule NextLS do {:noreply, lsp} end - defp version do + def version do case :application.get_key(:next_ls, :vsn) do {:ok, version} -> to_string(version) _ -> "dev" diff --git a/lib/next_ls/logger.ex b/lib/next_ls/logger.ex index 54510f89..ba708be9 100644 --- a/lib/next_ls/logger.ex +++ b/lib/next_ls/logger.ex @@ -11,6 +11,10 @@ defmodule NextLS.Logger do def info(server, msg), do: GenServer.cast(server, {:log, :info, msg}) def warning(server, msg), do: GenServer.cast(server, {:log, :warning, msg}) + def show_message(server, type, msg) when type in [:error, :warning, :info, :log] do + GenServer.cast(server, {:show_message, type, msg}) + end + def init(args) do lsp = Keyword.fetch!(args, :lsp) {:ok, %{lsp: lsp}} @@ -20,4 +24,15 @@ defmodule NextLS.Logger do apply(GenLSP, type, [state.lsp, String.trim("[NextLS] #{msg}")]) {:noreply, state} end + + def handle_cast({:show_message, type, msg}, state) do + GenLSP.notify(state.lsp, %GenLSP.Notifications.WindowShowMessage{ + params: %GenLSP.Structures.ShowMessageParams{ + type: apply(GenLSP.Enumerations.MessageType, type, []), + message: msg + } + }) + + {:noreply, state} + end end diff --git a/lib/next_ls/lsp_supervisor.ex b/lib/next_ls/lsp_supervisor.ex index d15b64a2..467f2697 100644 --- a/lib/next_ls/lsp_supervisor.ex +++ b/lib/next_ls/lsp_supervisor.ex @@ -53,6 +53,18 @@ defmodule NextLS.LSPSupervisor do raise OptionsError, invalid end + auto_update = + if "NEXTLS_AUTO_UPDATE" |> System.get_env("false") |> String.to_existing_atom() do + [ + binpath: System.get_env("NEXTLS_BINPATH", Path.expand("~/.cache/elixir-tools/nextls/bin/nextls")), + api_host: System.get_env("NEXTLS_GITHUB_API", "https://api.github.com"), + github_host: System.get_env("NEXTLS_GITHUB", "https://github.com"), + current_version: Version.parse!(NextLS.version()) + ] + else + false + end + children = [ {DynamicSupervisor, name: NextLS.DynamicSupervisor}, {Task.Supervisor, name: NextLS.TaskSupervisor}, @@ -61,6 +73,7 @@ defmodule NextLS.LSPSupervisor do {NextLS.DiagnosticCache, name: :diagnostic_cache}, {Registry, name: NextLS.Registry, keys: :duplicate}, {NextLS, + auto_update: auto_update, buffer: NextLS.Buffer, cache: :diagnostic_cache, task_supervisor: NextLS.TaskSupervisor, diff --git a/lib/next_ls/updater.ex b/lib/next_ls/updater.ex new file mode 100644 index 00000000..7eed4491 --- /dev/null +++ b/lib/next_ls/updater.ex @@ -0,0 +1,96 @@ +defmodule NextLS.Updater do + @moduledoc false + use Task + + def start_link(arg \\ []) do + Task.start_link(__MODULE__, :run, [arg]) + end + + def run(opts) do + Logger.put_module_level(Req.Steps, :none) + + binpath = Keyword.get(opts, :binpath, Path.expand("~/.cache/elixir-tools/nextls/bin/nextls")) + api_host = Keyword.get(opts, :api_host, "https://api.github.com") + github_host = Keyword.get(opts, :github_host, "https://github.com") + logger = Keyword.fetch!(opts, :logger) + current_version = Keyword.fetch!(opts, :current_version) + retry = Keyword.get(opts, :retry, :safe) + + case Req.get("/repos/elixir-tools/next-ls/releases/latest", base_url: api_host, retry: retry) do + {:ok, %{body: %{"tag_name" => "v" <> version = tag}}} -> + with {:ok, latest_version} <- Version.parse(version), + :gt <- Version.compare(latest_version, current_version) do + with :ok <- File.rename(binpath, binpath <> "-#{Version.to_string(current_version)}"), + {:ok, _} <- + File.open(binpath, [:write], fn file -> + fun = fn request, finch_request, finch_name, finch_options -> + fun = fn + {:status, status}, response -> + %{response | status: status} + + {:headers, headers}, response -> + %{response | headers: headers} + + {:data, data}, response -> + IO.binwrite(file, data) + response + end + + case Finch.stream(finch_request, finch_name, Req.Response.new(), fun, finch_options) do + {:ok, response} -> {request, response} + {:error, exception} -> {request, exception} + end + end + + with {:error, error} <- + Req.get("/elixir-tools/next-ls/releases/download/#{tag}/next_ls_#{os()}_#{arch()}", + finch_request: fun, + base_url: github_host, + retry: retry + ) do + NextLS.Logger.show_message(logger, :error, "Failed to download version #{version} of Next LS!") + NextLS.Logger.error(logger, "Failed to download Next LS: #{inspect(error)}") + :error + end + end) do + File.chmod(binpath, 0o755) + + NextLS.Logger.show_message( + logger, + :info, + "[Next LS] Downloaded v#{version}, please restart your editor for it to take effect." + ) + + NextLS.Logger.info(logger, "Downloaded #{version} of Next LS") + end + end + + {:error, error} -> + NextLS.Logger.error( + logger, + "Failed to retrieve the latest version number of Next LS from the GitHub API: #{inspect(error)}" + ) + end + end + + defp arch do + arch_str = :erlang.system_info(:system_architecture) + [arch | _] = arch_str |> List.to_string() |> String.split("-") + + case {:os.type(), arch, :erlang.system_info(:wordsize) * 8} do + {{:win32, _}, _arch, 64} -> :amd64 + {_os, arch, 64} when arch in ~w(arm aarch64) -> :arm64 + {_os, arch, 64} when arch in ~w(amd64 x86_64) -> :amd64 + {os, arch, _wordsize} -> raise "Unsupported system: os=#{inspect(os)}, arch=#{inspect(arch)}" + end + end + + defp os do + case :os.type() do + {:win32, _} -> :windows + {:unix, :darwin} -> :darwin + {:unix, :linux} -> :linux + unknown_os -> raise "Unsupported system: os=#{inspect(unknown_os)}}" + end + end +end diff --git a/mix.exs b/mix.exs index 324e1bed..7a3ab046 100644 --- a/mix.exs +++ b/mix.exs @@ -60,12 +60,15 @@ defmodule NextLS.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:gen_lsp, "~> 0.6"}, {:exqlite, "~> 0.13.14"}, - {:styler, "~> 0.8", only: :dev}, - {:ex_doc, ">= 0.0.0", only: :dev}, + {:gen_lsp, "~> 0.6"}, + {:req, "~> 0.3.11"}, + {:burrito, github: "burrito-elixir/burrito", only: [:dev, :prod]}, - {:dialyxir, ">= 0.0.0", only: [:dev, :test], runtime: false} + {:bypass, "~> 2.1", only: :test}, + {:dialyxir, ">= 0.0.0", only: [:dev, :test], runtime: false}, + {:ex_doc, ">= 0.0.0", only: :dev}, + {:styler, "~> 0.8", only: :dev} ] end @@ -76,7 +79,7 @@ defmodule NextLS.MixProject do links: %{ GitHub: "https://github.com/elixir-tools/next-ls", Sponsor: "https://github.com/sponsors/mhanberg", - Downloads: "https://github.com/elixir-tools/next-ls/releases", + Downloads: "https://github.com/elixir-tools/next-ls/releases" }, files: ~w(lib LICENSE mix.exs priv README.md .formatter.exs) ] diff --git a/mix.lock b/mix.lock index 1335eb4c..044b04c7 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,11 @@ %{ "burrito": {:git, "https://github.com/burrito-elixir/burrito.git", "68ec772f22f623d75bd1f667b1cb4c95f2935b3b", []}, + "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, "castore": {:hex, :castore, "1.0.3", "7130ba6d24c8424014194676d608cb989f62ef8039efd50ff4b3f33286d06db8", [:mix], [], "hexpm", "680ab01ef5d15b161ed6a95449fac5c6b8f60055677a8e79acf01b27baa4390b"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.8", "933a5f4da3b19ee56539a076076ce4d7716d64efc8db46fd066996a7e46e2bfd", [:mix], [{:elixir_make, "~> 0.7.3", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "176bdf4366956e456bf761b54ad70bc4103d0269ca9558fd7cee93d1b3f116db"}, + "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, "db_connection": {:hex, :db_connection, "2.5.0", "bb6d4f30d35ded97b29fe80d8bd6f928a1912ca1ff110831edcd238a1973652c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c92d5ba26cd69ead1ff7582dbb860adeedfff39774105a4f1c92cbb654b55aa2"}, "dialyxir": {:hex, :dialyxir, "1.3.0", "fd1672f0922b7648ff9ce7b1b26fcf0ef56dda964a459892ad15f6b4410b5284", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "00b2a4bcd6aa8db9dcb0b38c1225b7277dca9bc370b6438715667071a304696f"}, "earmark_parser": {:hex, :earmark_parser, "1.4.32", "fa739a0ecfa34493de19426681b23f6814573faee95dfd4b4aafe15a7b5b32c6", [:mix], [], "hexpm", "b8b0dd77d60373e77a3d7e8afa598f325e49e8663a51bcc2b88ef41838cca755"}, @@ -21,6 +25,10 @@ "nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"}, "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, + "plug": {:hex, :plug, "1.14.2", "cff7d4ec45b4ae176a227acd94a7ab536d9b37b942c8e8fa6dfc0fff98ff4d80", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "842fc50187e13cf4ac3b253d47d9474ed6c296a8732752835ce4a86acdf68d13"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, + "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "req": {:hex, :req, "0.3.11", "462315e50db6c6e1f61c45e8c0b267b0d22b6bd1f28444c136908dfdca8d515a", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0e4b331627fedcf90b29aa8064cd5a95619ef6134d5ab13919b6e1c4d7cccd4b"}, "schematic": {:hex, :schematic, "0.2.1", "0b091df94146fd15a0a343d1bd179a6c5a58562527746dadd09477311698dbb1", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0b255d65921e38006138201cd4263fd8bb807d9dfc511074615cd264a571b3b1"}, "styler": {:hex, :styler, "0.8.1", "f3c0f65023e4bfbf7e7aa752d128b8475fdabfd30f96ee7314b84480cc56e788", [:mix], [], "hexpm", "1aa48d3aa689a639289af3d8254d40e068e98c083d6e5e3d1a695e71a147b344"}, diff --git a/test/next_ls/updater_test.exs b/test/next_ls/updater_test.exs new file mode 100644 index 00000000..54c82f76 --- /dev/null +++ b/test/next_ls/updater_test.exs @@ -0,0 +1,134 @@ +defmodule NextLS.UpdaterTest do + use ExUnit.Case, async: true + + alias NextLS.Updater + + @moduletag :tmp_dir + + setup do + me = self() + + {:ok, logger} = + Task.start_link(fn -> + recv = fn recv -> + receive do + {:"$gen_cast", msg} -> + # dbg(msg) + send(me, msg) + end + + recv.(recv) + end + + recv.(recv) + end) + + [logger: logger] + end + + test "downloads the exe", %{tmp_dir: tmp_dir, logger: logger} do + api = Bypass.open(port: 8000) + github = Bypass.open(port: 8001) + + Bypass.expect(api, "GET", "/repos/elixir-tools/next-ls/releases/latest", fn conn -> + conn + |> Plug.Conn.put_resp_header("content-type", "application/json") + |> Plug.Conn.resp(200, Jason.encode!(%{tag_name: "v1.0.0"})) + end) + + exe = String.duplicate("time to hack\n", 1000) + + Bypass.expect(github, fn conn -> + assert "GET" == conn.method + assert "/elixir-tools/next-ls/releases/download/v1.0.0/next_ls_" <> rest = conn.request_path + + assert rest in [ + "darwin_arm64", + "darwin_amd64", + "linux_arm64", + "linux_amd64", + "windows_amd64" + ] + + Plug.Conn.resp(conn, 200, exe) + end) + + binpath = Path.join(tmp_dir, "nextls") + File.write(binpath, "yoyoyo") + + Updater.run( + current_version: Version.parse!("0.9.0"), + binpath: binpath, + api_host: "http://localhost:8000", + github_host: "http://localhost:8001", + logger: logger + ) + + assert File.read!(binpath) == exe + assert File.stat!(binpath).mode == 33_261 + assert File.exists?(binpath <> "-0.9.0") + end + + test "doesn't download when the version is at the latest", %{tmp_dir: tmp_dir, logger: logger} do + api = Bypass.open(port: 8000) + + Bypass.expect(api, "GET", "/repos/elixir-tools/next-ls/releases/latest", fn conn -> + conn + |> Plug.Conn.put_resp_header("content-type", "application/json") + |> Plug.Conn.resp(200, Jason.encode!(%{tag_name: "v1.0.0"})) + end) + + binpath = Path.join(tmp_dir, "nextls") + + Updater.run( + current_version: Version.parse!("1.0.0"), + binpath: binpath, + api_host: "http://localhost:8000", + github_host: "http://localhost:8001", + logger: logger + ) + + refute File.exists?(binpath) + end + + test "logs that it failed when api call fails", %{tmp_dir: tmp_dir, logger: logger} do + binpath = Path.join(tmp_dir, "nextls") + File.write(binpath, "yoyoyo") + + Updater.run( + current_version: Version.parse!("1.0.0"), + binpath: binpath, + api_host: "http://localhost:8000", + github_host: "http://localhost:8001", + logger: logger, + retry: false + ) + + assert_receive {:log, :error, "Failed to retrieve the latest version number of Next LS from the GitHub API: " <> _} + end + + test "logs that it failed when download fails", %{tmp_dir: tmp_dir, logger: logger} do + api = Bypass.open(port: 8000) + + Bypass.expect(api, "GET", "/repos/elixir-tools/next-ls/releases/latest", fn conn -> + conn + |> Plug.Conn.put_resp_header("content-type", "application/json") + |> Plug.Conn.resp(200, Jason.encode!(%{tag_name: "v1.0.0"})) + end) + + binpath = Path.join(tmp_dir, "nextls") + File.write(binpath, "yoyoyo") + + Updater.run( + current_version: Version.parse!("0.9.0"), + binpath: binpath, + api_host: "http://localhost:8000", + github_host: "http://localhost:8001", + logger: logger, + retry: false + ) + + assert_receive {:show_message, :error, "Failed to download version 1.0.0 of Next LS!"} + assert_receive {:log, :error, "Failed to download Next LS: " <> _} + end +end