Skip to content

Commit 1748a9e

Browse files
committed
feat: auto update
Auto update is opt-in via an environment variable. This env var will only be set by the editor extensions if it's being started from the default location, so that if you are installing NextLS via a package manager, it won't try to update itself. Closes #170
1 parent 7b84077 commit 1748a9e

File tree

7 files changed

+276
-6
lines changed

7 files changed

+276
-6
lines changed

lib/next_ls.ex

+11-1
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ defmodule NextLS do
4646
{args, opts} =
4747
Keyword.split(args, [
4848
:cache,
49+
:auto_update,
4950
:task_supervisor,
5051
:runtime_task_supervisor,
5152
:dynamic_supervisor,
@@ -70,6 +71,7 @@ defmodule NextLS do
7071

7172
{:ok,
7273
assign(lsp,
74+
auto_update: Keyword.get(args, :auto_update, false),
7375
exit_code: 1,
7476
documents: %{},
7577
refresh_refs: %{},
@@ -360,6 +362,14 @@ defmodule NextLS do
360362
def handle_notification(%Initialized{}, lsp) do
361363
GenLSP.log(lsp, "[NextLS] NextLS v#{version()} has initialized!")
362364

365+
with opts when is_list(opts) <- lsp.assigns.auto_update do
366+
{:ok, _} =
367+
DynamicSupervisor.start_child(
368+
lsp.assigns.dynamic_supervisor,
369+
{NextLS.Updater, Keyword.merge(opts, logger: lsp.assigns.logger)}
370+
)
371+
end
372+
363373
for extension <- lsp.assigns.extensions do
364374
{:ok, _} =
365375
DynamicSupervisor.start_child(
@@ -664,7 +674,7 @@ defmodule NextLS do
664674
{:noreply, lsp}
665675
end
666676

667-
defp version do
677+
def version do
668678
case :application.get_key(:next_ls, :vsn) do
669679
{:ok, version} -> to_string(version)
670680
_ -> "dev"

lib/next_ls/logger.ex

+15
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ defmodule NextLS.Logger do
1111
def info(server, msg), do: GenServer.cast(server, {:log, :info, msg})
1212
def warning(server, msg), do: GenServer.cast(server, {:log, :warning, msg})
1313

14+
def show_message(server, type, msg) when type in [:error, :warning, :info, :log] do
15+
GenServer.cast(server, {:show_message, type, msg})
16+
end
17+
1418
def init(args) do
1519
lsp = Keyword.fetch!(args, :lsp)
1620
{:ok, %{lsp: lsp}}
@@ -20,4 +24,15 @@ defmodule NextLS.Logger do
2024
apply(GenLSP, type, [state.lsp, String.trim("[NextLS] #{msg}")])
2125
{:noreply, state}
2226
end
27+
28+
def handle_cast({:show_message, type, msg}, state) do
29+
GenLSP.notify(state.lsp, %GenLSP.Notifications.WindowShowMessage{
30+
params: %GenLSP.Structures.ShowMessageParams{
31+
type: apply(GenLSP.Enumerations.MessageType, type, []),
32+
message: msg
33+
}
34+
})
35+
36+
{:noreply, state}
37+
end
2338
end

lib/next_ls/lsp_supervisor.ex

+13
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@ defmodule NextLS.LSPSupervisor do
5353
raise OptionsError, invalid
5454
end
5555

56+
auto_update =
57+
if "NEXTLS_AUTO_UPDATE" |> System.get_env("false") |> String.to_existing_atom() do
58+
[
59+
binpath: System.get_env("NEXTLS_BINPATH", Path.expand("~/.cache/elixir-tools/nextls/bin/nextls")),
60+
api_host: System.get_env("NEXTLS_GITHUB_API", "https://api.github.com"),
61+
github_host: System.get_env("NEXTLS_GITHUB", "https://github.com"),
62+
current_version: Version.parse!(NextLS.version())
63+
]
64+
else
65+
false
66+
end
67+
5668
children = [
5769
{DynamicSupervisor, name: NextLS.DynamicSupervisor},
5870
{Task.Supervisor, name: NextLS.TaskSupervisor},
@@ -61,6 +73,7 @@ defmodule NextLS.LSPSupervisor do
6173
{NextLS.DiagnosticCache, name: :diagnostic_cache},
6274
{Registry, name: NextLS.Registry, keys: :duplicate},
6375
{NextLS,
76+
auto_update: auto_update,
6477
buffer: NextLS.Buffer,
6578
cache: :diagnostic_cache,
6679
task_supervisor: NextLS.TaskSupervisor,

lib/next_ls/updater.ex

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
defmodule NextLS.Updater do
2+
@moduledoc false
3+
use Task
4+
5+
def start_link(arg \\ []) do
6+
Task.start_link(__MODULE__, :run, [arg])
7+
end
8+
9+
def run(opts) do
10+
Logger.put_module_level(Req.Steps, :none)
11+
12+
binpath = Keyword.get(opts, :binpath, Path.expand("~/.cache/elixir-tools/nextls/bin/nextls"))
13+
api_host = Keyword.get(opts, :api_host, "https://api.github.com")
14+
github_host = Keyword.get(opts, :github_host, "https://github.com")
15+
logger = Keyword.fetch!(opts, :logger)
16+
current_version = Keyword.fetch!(opts, :current_version)
17+
retry = Keyword.get(opts, :retry, :safe)
18+
19+
case Req.get("/repos/elixir-tools/next-ls/releases/latest", base_url: api_host, retry: retry) do
20+
{:ok, %{body: %{"tag_name" => "v" <> version = tag}}} ->
21+
with {:ok, latest_version} <- Version.parse(version),
22+
:gt <- Version.compare(latest_version, current_version) do
23+
with :ok <- File.rename(binpath, binpath <> "-#{Version.to_string(current_version)}"),
24+
{:ok, _} <-
25+
File.open(binpath, [:write], fn file ->
26+
fun = fn request, finch_request, finch_name, finch_options ->
27+
fun = fn
28+
{:status, status}, response ->
29+
%{response | status: status}
30+
31+
{:headers, headers}, response ->
32+
%{response | headers: headers}
33+
34+
{:data, data}, response ->
35+
IO.binwrite(file, data)
36+
response
37+
end
38+
39+
case Finch.stream(finch_request, finch_name, Req.Response.new(), fun, finch_options) do
40+
{:ok, response} -> {request, response}
41+
{:error, exception} -> {request, exception}
42+
end
43+
end
44+
45+
with {:error, error} <-
46+
Req.get("/elixir-tools/next-ls/releases/download/#{tag}/next_ls_#{os()}_#{arch()}",
47+
finch_request: fun,
48+
base_url: github_host,
49+
retry: retry
50+
) do
51+
NextLS.Logger.show_message(logger, :error, "Failed to download version #{version} of Next LS!")
52+
NextLS.Logger.error(logger, "Failed to download Next LS: #{inspect(error)}")
53+
:error
54+
end
55+
end) do
56+
System.cmd("chmod", ["+x", binpath])
57+
58+
NextLS.Logger.show_message(logger, :info, "Downloaded #{version} of Next LS!")
59+
NextLS.Logger.info(logger, "Downloaded #{version} of Next LS!")
60+
end
61+
end
62+
63+
{:error, error} ->
64+
NextLS.Logger.error(
65+
logger,
66+
"Failed to retrieve the latest version number of Next LS from the GitHub API: #{inspect(error)}"
67+
)
68+
end
69+
end
70+
71+
defp arch do
72+
arch_str = :erlang.system_info(:system_architecture)
73+
[arch | _] = arch_str |> List.to_string() |> String.split("-")
74+
75+
case {:os.type(), arch, :erlang.system_info(:wordsize) * 8} do
76+
{{:win32, _}, _arch, 64} -> :amd64
77+
{_os, arch, 64} when arch in ~w(arm aarch64) -> :arm64
78+
{_os, arch, 64} when arch in ~w(amd64 x86_64) -> :amd64
79+
{os, arch, _wordsize} -> raise "Unsupported system: os=#{inspect(os)}, arch=#{inspect(arch)}"
80+
end
81+
end
82+
83+
defp os do
84+
case :os.type() do
85+
{:win32, _} -> :windows
86+
{:unix, :darwin} -> :darwin
87+
{:unix, :linux} -> :linux
88+
unknown_os -> raise "Unsupported system: os=#{inspect(unknown_os)}}"
89+
end
90+
end
91+
end

mix.exs

+8-5
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,15 @@ defmodule NextLS.MixProject do
6060
# Run "mix help deps" to learn about dependencies.
6161
defp deps do
6262
[
63-
{:gen_lsp, "~> 0.6"},
6463
{:exqlite, "~> 0.13.14"},
65-
{:styler, "~> 0.8", only: :dev},
66-
{:ex_doc, ">= 0.0.0", only: :dev},
64+
{:gen_lsp, "~> 0.6"},
65+
{:req, "~> 0.3.11"},
66+
6767
{:burrito, github: "burrito-elixir/burrito", only: [:dev, :prod]},
68-
{:dialyxir, ">= 0.0.0", only: [:dev, :test], runtime: false}
68+
{:bypass, "~> 2.1", only: :test},
69+
{:dialyxir, ">= 0.0.0", only: [:dev, :test], runtime: false},
70+
{:ex_doc, ">= 0.0.0", only: :dev},
71+
{:styler, "~> 0.8", only: :dev}
6972
]
7073
end
7174

@@ -76,7 +79,7 @@ defmodule NextLS.MixProject do
7679
links: %{
7780
GitHub: "https://github.com/elixir-tools/next-ls",
7881
Sponsor: "https://github.com/sponsors/mhanberg",
79-
Downloads: "https://github.com/elixir-tools/next-ls/releases",
82+
Downloads: "https://github.com/elixir-tools/next-ls/releases"
8083
},
8184
files: ~w(lib LICENSE mix.exs priv README.md .formatter.exs)
8285
]

mix.lock

+8
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
%{
22
"burrito": {:git, "https://github.com/burrito-elixir/burrito.git", "68ec772f22f623d75bd1f667b1cb4c95f2935b3b", []},
3+
"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"},
34
"castore": {:hex, :castore, "1.0.3", "7130ba6d24c8424014194676d608cb989f62ef8039efd50ff4b3f33286d06db8", [:mix], [], "hexpm", "680ab01ef5d15b161ed6a95449fac5c6b8f60055677a8e79acf01b27baa4390b"},
45
"cc_precompiler": {:hex, :cc_precompiler, "0.1.8", "933a5f4da3b19ee56539a076076ce4d7716d64efc8db46fd066996a7e46e2bfd", [:mix], [{:elixir_make, "~> 0.7.3", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "176bdf4366956e456bf761b54ad70bc4103d0269ca9558fd7cee93d1b3f116db"},
6+
"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"},
7+
"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"},
8+
"cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"},
59
"db_connection": {:hex, :db_connection, "2.5.0", "bb6d4f30d35ded97b29fe80d8bd6f928a1912ca1ff110831edcd238a1973652c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c92d5ba26cd69ead1ff7582dbb860adeedfff39774105a4f1c92cbb654b55aa2"},
610
"dialyxir": {:hex, :dialyxir, "1.3.0", "fd1672f0922b7648ff9ce7b1b26fcf0ef56dda964a459892ad15f6b4410b5284", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "00b2a4bcd6aa8db9dcb0b38c1225b7277dca9bc370b6438715667071a304696f"},
711
"earmark_parser": {:hex, :earmark_parser, "1.4.32", "fa739a0ecfa34493de19426681b23f6814573faee95dfd4b4aafe15a7b5b32c6", [:mix], [], "hexpm", "b8b0dd77d60373e77a3d7e8afa598f325e49e8663a51bcc2b88ef41838cca755"},
@@ -21,6 +25,10 @@
2125
"nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"},
2226
"nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"},
2327
"nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"},
28+
"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"},
29+
"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"},
30+
"plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"},
31+
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
2432
"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"},
2533
"schematic": {:hex, :schematic, "0.2.1", "0b091df94146fd15a0a343d1bd179a6c5a58562527746dadd09477311698dbb1", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0b255d65921e38006138201cd4263fd8bb807d9dfc511074615cd264a571b3b1"},
2634
"styler": {:hex, :styler, "0.8.1", "f3c0f65023e4bfbf7e7aa752d128b8475fdabfd30f96ee7314b84480cc56e788", [:mix], [], "hexpm", "1aa48d3aa689a639289af3d8254d40e068e98c083d6e5e3d1a695e71a147b344"},

test/next_ls/updater_test.exs

+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
defmodule NextLS.UpdaterTest do
2+
use ExUnit.Case, async: true
3+
4+
alias NextLS.Updater
5+
6+
@moduletag :tmp_dir
7+
8+
setup do
9+
me = self()
10+
11+
{:ok, logger} =
12+
Task.start_link(fn ->
13+
recv = fn recv ->
14+
receive do
15+
{:"$gen_cast", msg} ->
16+
# dbg(msg)
17+
send(me, msg)
18+
end
19+
20+
recv.(recv)
21+
end
22+
23+
recv.(recv)
24+
end)
25+
26+
[logger: logger]
27+
end
28+
29+
test "downloads the exe", %{tmp_dir: tmp_dir, logger: logger} do
30+
api = Bypass.open(port: 8000)
31+
github = Bypass.open(port: 8001)
32+
33+
Bypass.expect(api, "GET", "/repos/elixir-tools/next-ls/releases/latest", fn conn ->
34+
conn
35+
|> Plug.Conn.put_resp_header("content-type", "application/json")
36+
|> Plug.Conn.resp(200, Jason.encode!(%{tag_name: "v1.0.0"}))
37+
end)
38+
39+
exe = String.duplicate("time to hack\n", 1000)
40+
41+
Bypass.expect(github, fn conn ->
42+
assert "GET" == conn.method
43+
assert "/elixir-tools/next-ls/releases/download/v1.0.0/next_ls_" <> rest = conn.request_path
44+
45+
assert rest in [
46+
"darwin_arm64",
47+
"darwin_amd64",
48+
"linux_arm64",
49+
"linux_amd64",
50+
"windows_amd64"
51+
]
52+
53+
Plug.Conn.resp(conn, 200, exe)
54+
end)
55+
56+
binpath = Path.join(tmp_dir, "nextls")
57+
58+
Updater.run(
59+
current_version: Version.parse!("0.9.0"),
60+
binpath: binpath,
61+
api_host: "http://localhost:8000",
62+
github_host: "http://localhost:8001",
63+
logger: logger
64+
)
65+
66+
assert File.read!(binpath) == exe
67+
assert File.stat!(binpath).mode == 33_188
68+
end
69+
70+
test "doesn't download when the version is at the latest", %{tmp_dir: tmp_dir, logger: logger} do
71+
api = Bypass.open(port: 8000)
72+
73+
Bypass.expect(api, "GET", "/repos/elixir-tools/next-ls/releases/latest", fn conn ->
74+
conn
75+
|> Plug.Conn.put_resp_header("content-type", "application/json")
76+
|> Plug.Conn.resp(200, Jason.encode!(%{tag_name: "v1.0.0"}))
77+
end)
78+
79+
binpath = Path.join(tmp_dir, "nextls")
80+
81+
Updater.run(
82+
current_version: Version.parse!("1.0.0"),
83+
binpath: binpath,
84+
api_host: "http://localhost:8000",
85+
github_host: "http://localhost:8001",
86+
logger: logger
87+
)
88+
89+
refute File.exists?(binpath)
90+
end
91+
92+
test "logs that it failed when api call fails", %{tmp_dir: tmp_dir, logger: logger} do
93+
binpath = Path.join(tmp_dir, "nextls")
94+
95+
Updater.run(
96+
current_version: Version.parse!("1.0.0"),
97+
binpath: binpath,
98+
api_host: "http://localhost:8000",
99+
github_host: "http://localhost:8001",
100+
logger: logger,
101+
retry: false
102+
)
103+
104+
assert_receive {:log, :error, "Failed to retrieve the latest version number of Next LS from the GitHub API: " <> _}
105+
end
106+
107+
test "logs that it failed when download fails", %{tmp_dir: tmp_dir, logger: logger} do
108+
api = Bypass.open(port: 8000)
109+
110+
Bypass.expect(api, "GET", "/repos/elixir-tools/next-ls/releases/latest", fn conn ->
111+
conn
112+
|> Plug.Conn.put_resp_header("content-type", "application/json")
113+
|> Plug.Conn.resp(200, Jason.encode!(%{tag_name: "v1.0.0"}))
114+
end)
115+
116+
binpath = Path.join(tmp_dir, "nextls")
117+
118+
Updater.run(
119+
current_version: Version.parse!("0.9.0"),
120+
binpath: binpath,
121+
api_host: "http://localhost:8000",
122+
github_host: "http://localhost:8001",
123+
logger: logger,
124+
retry: false
125+
)
126+
127+
assert_receive {:show_message, :error, "Failed to download version 1.0.0 of Next LS!"}
128+
assert_receive {:log, :error, "Failed to download Next LS: " <> _}
129+
end
130+
end

0 commit comments

Comments
 (0)