1defmodule Mix.Tasks.Hex do
2  use Mix.Task
3
4  @apikey_tag "HEXAPIKEY"
5
6  @shortdoc "Prints Hex help information"
7
8  @moduledoc """
9  Prints Hex tasks and their information.
10
11      $ mix hex
12
13  See `mix help hex.config` to see all available configuration options.
14  """
15
16  @impl true
17  def run(_args) do
18    Hex.start()
19
20    Hex.Shell.info("Hex v" <> Hex.version())
21    Hex.Shell.info("Hex is a package manager for the Erlang ecosystem.")
22
23    print_available_tasks()
24
25    Hex.Shell.info("Further information can be found here: https://hex.pm/docs")
26  end
27
28  defp print_available_tasks() do
29    line_break()
30    Hex.Shell.info("Available tasks:")
31    line_break()
32
33    pattern = "hex."
34    modules = Enum.filter(load_tasks(), &String.contains?(Mix.Task.task_name(&1), pattern))
35    {docs, max} = build_task_doc_list(modules)
36    display_doc_list(docs, max)
37    line_break()
38  end
39
40  defp load_tasks() do
41    Enum.filter(Mix.Task.load_all(), &(Mix.Task.moduledoc(&1) != false))
42  end
43
44  defp build_task_doc_list(modules) do
45    Enum.reduce(modules, {[], 0}, fn module, {docs, max} ->
46      task = "mix " <> Mix.Task.task_name(module)
47
48      task_list =
49        if Keyword.has_key?(module.__info__(:functions), :tasks) do
50          Enum.map(module.tasks(), fn {subtask, docs} -> {"#{task} #{subtask}", docs} end)
51        else
52          []
53        end
54
55      max =
56        Enum.reduce(task_list, max, fn {task, _}, max_now ->
57          max(byte_size(task), max_now)
58        end)
59
60      if Enum.empty?(task_list) do
61        {docs, max}
62      else
63        {docs ++ [task_list], max}
64      end
65    end)
66  end
67
68  defp display_doc_list(docs, max) do
69    Enum.each(Enum.sort_by(docs, &List.first/1), fn tasks ->
70      Enum.each(tasks, fn {task, doc} ->
71        Mix.shell().info(format_task(task, max, doc))
72      end)
73
74      line_break()
75    end)
76  end
77
78  defp format_task(task, max, doc) do
79    Hex.Stdlib.string_pad_trailing(task, max) <> " # " <> doc
80  end
81
82  defp line_break(), do: Hex.Shell.info("")
83
84  @doc false
85  def print_table(header, values) do
86    header = Enum.map(header, &[:underline, &1])
87    widths = widths([header | values])
88
89    print_row(header, widths)
90    Enum.each(values, &print_row(&1, widths))
91  end
92
93  defp ansi_length(binary) when is_binary(binary) do
94    byte_size(binary)
95  end
96
97  defp ansi_length(list) when is_list(list) do
98    Enum.reduce(list, 0, &(ansi_length(&1) + &2))
99  end
100
101  defp ansi_length(atom) when is_atom(atom) do
102    0
103  end
104
105  defp print_row(strings, widths) do
106    Enum.zip(strings, widths)
107    |> Enum.map(fn {string, width} ->
108      pad_size = width - ansi_length(string) + 2
109      pad = :lists.duplicate(pad_size, ?\s)
110      [string || "", :reset, pad]
111    end)
112    |> IO.ANSI.format()
113    |> Hex.Shell.info()
114  end
115
116  defp widths([head | tail]) do
117    widths = Enum.map(head, &ansi_length/1)
118
119    Enum.reduce(tail, widths, fn list, acc ->
120      Enum.zip(list, acc)
121      |> Enum.map(fn {string, width} -> max(width, ansi_length(string)) end)
122    end)
123  end
124
125  @doc false
126  def auth(opts \\ []) do
127    username = Hex.Shell.prompt("Username:") |> Hex.Stdlib.string_trim()
128    account_password = Mix.Tasks.Hex.password_get("Account password:") |> Hex.Stdlib.string_trim()
129    Mix.Tasks.Hex.generate_all_user_keys(username, account_password, opts)
130  end
131
132  @local_password_prompt "You have authenticated on Hex using your account password. However, " <>
133                           "Hex requires you to have a local password that applies only to this machine for security " <>
134                           "purposes. Please enter it."
135
136  @doc false
137  def generate_user_key(key_name, permissions, opts) do
138    case Hex.API.Key.new(key_name, permissions, opts) do
139      {:ok, {201, body, _}} ->
140        {:ok, body["secret"]}
141
142      other ->
143        Mix.shell().error("Generation of key failed")
144        Hex.Utils.print_error_result(other)
145        :error
146    end
147  end
148
149  @doc false
150  def generate_all_user_keys(username, password, opts \\ []) do
151    Hex.Shell.info("Generating keys...")
152    auth = [user: username, pass: password]
153    key_name = api_key_name(opts[:key_name])
154    permissions = [%{"domain" => "api"}]
155
156    case generate_user_key(key_name, permissions, auth) do
157      {:ok, write_key} ->
158        key_name = api_key_name(opts[:key_name], "read")
159        permissions = [%{"domain" => "api", "resource" => "read"}]
160
161        case generate_user_key(key_name, permissions, key: write_key) do
162          {:ok, read_key} ->
163            key_name = repositories_key_name(opts[:key_name])
164            permissions = [%{"domain" => "repositories"}]
165
166            case generate_user_key(key_name, permissions, key: write_key) do
167              {:ok, organization_key} ->
168                auth_organization("hexpm", organization_key)
169
170                Hex.Shell.info(@local_password_prompt)
171                prompt_encrypt_key(write_key, read_key)
172                {:ok, write_key, read_key, organization_key}
173
174              :error ->
175                :ok
176            end
177
178          :error ->
179            :error
180        end
181
182      :error ->
183        :error
184    end
185  end
186
187  @doc false
188  def generate_organization_key(organization_name, key_name, permissions, auth \\ nil) do
189    auth = auth || auth_info(:write)
190
191    case Hex.API.Key.Organization.new(organization_name, key_name, permissions, auth) do
192      {:ok, {201, body, _}} ->
193        {:ok, body["secret"]}
194
195      other ->
196        Mix.shell().error("Generation of key failed")
197        Hex.Utils.print_error_result(other)
198        :error
199    end
200  end
201
202  @doc false
203  def general_key_name(nil) do
204    {:ok, hostname} = :inet.gethostname()
205    List.to_string(hostname)
206  end
207
208  def general_key_name(key) do
209    key
210  end
211
212  @doc false
213  def api_key_name(key, extra \\ nil) do
214    {:ok, hostname} = :inet.gethostname()
215    name = "#{key || hostname}-api"
216    if extra, do: "#{name}-#{extra}", else: name
217  end
218
219  @doc false
220  def repository_key_name(organization, key) do
221    {:ok, hostname} = :inet.gethostname()
222    "#{key || hostname}-repository-#{organization}"
223  end
224
225  @doc false
226  def repositories_key_name(key) do
227    {:ok, hostname} = :inet.gethostname()
228    "#{key || hostname}-repositories"
229  end
230
231  @doc false
232  def update_keys(write_key, read_key \\ nil) do
233    Hex.Config.update(
234      "$write_key": write_key,
235      "$read_key": read_key,
236      "$encrypted_key": nil,
237      encrypted_key: nil
238    )
239
240    Hex.State.put(:api_key_write, write_key)
241    Hex.State.put(:api_key_read, read_key)
242  end
243
244  @doc false
245  def auth_organization(name, key) do
246    repo = Hex.Repo.get_repo(name) || Hex.Repo.default_hexpm_repo()
247    repo = Map.put(repo, :auth_key, key)
248
249    Hex.State.fetch!(:repos)
250    |> Map.put(name, repo)
251    |> Hex.Config.update_repos()
252  end
253
254  @doc false
255  def auth_info(permission, opts \\ [])
256
257  def auth_info(:write, opts) do
258    api_key_write_unencrypted = Hex.State.fetch!(:api_key_write_unencrypted)
259    api_key_write = Hex.State.fetch!(:api_key_write)
260
261    cond do
262      api_key_write_unencrypted -> [key: api_key_write_unencrypted]
263      api_key_write -> [key: prompt_decrypt_key(api_key_write)]
264      Keyword.get(opts, :auth_inline, true) -> authenticate_inline()
265      true -> []
266    end
267  end
268
269  def auth_info(:read, opts) do
270    api_key_write_unencrypted = Hex.State.fetch!(:api_key_write_unencrypted)
271    api_key_read = Hex.State.fetch!(:api_key_read)
272
273    cond do
274      api_key_write_unencrypted -> [key: api_key_write_unencrypted]
275      api_key_read -> [key: api_key_read]
276      Keyword.get(opts, :auth_inline, true) -> authenticate_inline()
277      true -> []
278    end
279  end
280
281  defp authenticate_inline() do
282    authenticate? =
283      Hex.Shell.yes?("No authenticated user found. Do you want to authenticate now?")
284
285    if authenticate? do
286      case auth() do
287        {:ok, write_key, _read_key, _org_key} -> [key: write_key]
288        :error -> no_auth_error()
289      end
290    else
291      no_auth_error()
292    end
293  end
294
295  defp no_auth_error() do
296    Mix.raise("No authenticated user found. Run `mix hex.user auth`")
297  end
298
299  @doc false
300  def prompt_encrypt_key(write_key, read_key, challenge \\ "Local password") do
301    password = password_get("#{challenge}:") |> Hex.Stdlib.string_trim()
302    confirm = password_get("#{challenge} (confirm):") |> Hex.Stdlib.string_trim()
303
304    if password != confirm do
305      Hex.Shell.error("Entered passwords do not match. Try again")
306      prompt_encrypt_key(write_key, read_key, challenge)
307    else
308      encrypted_write_key = Hex.Crypto.encrypt(write_key, password, @apikey_tag)
309      update_keys(encrypted_write_key, read_key)
310    end
311  end
312
313  @doc false
314  def prompt_decrypt_key(encrypted_key, challenge \\ "Local password") do
315    password = password_get("#{challenge}:") |> Hex.Stdlib.string_trim()
316
317    case Hex.Crypto.decrypt(encrypted_key, password, @apikey_tag) do
318      {:ok, key} ->
319        key
320
321      :error ->
322        Hex.Shell.error("Wrong password. Try again")
323        prompt_decrypt_key(encrypted_key, challenge)
324    end
325  end
326
327  @doc false
328  def encrypt_key(password, key) do
329    Hex.Crypto.encrypt(key, password, @apikey_tag)
330  end
331
332  @doc false
333  def decrypt_key(password, key) do
334    Hex.Crypto.decrypt(key, password, @apikey_tag)
335  end
336
337  @doc false
338  def required_opts(opts, required) do
339    Enum.map(required, fn req ->
340      unless Keyword.has_key?(opts, req) do
341        Mix.raise("Missing command line option: #{req}")
342      end
343    end)
344  end
345
346  @doc false
347  def convert_permissions([]) do
348    nil
349  end
350
351  @doc false
352  def convert_permissions(permissions) do
353    Enum.map(permissions, fn permission ->
354      permission = String.downcase(permission)
355      destructure [domain, resource], String.split(permission, ":", parts: 2)
356      %{"domain" => domain, "resource" => resource}
357    end)
358  end
359
360  # Password prompt that hides input by every 1ms
361  # clearing the line with stderr
362  @doc false
363  def password_get(prompt) do
364    if Hex.State.fetch!(:clean_pass) do
365      password_clean(prompt)
366    else
367      Hex.Shell.prompt(prompt <> " ")
368    end
369  end
370
371  defp password_clean(prompt) do
372    pid = spawn_link(fn -> loop(prompt) end)
373    ref = make_ref()
374    value = IO.gets(prompt <> " ")
375
376    send(pid, {:done, self(), ref})
377    receive do: ({:done, ^pid, ^ref} -> :ok)
378
379    value
380  end
381
382  defp loop(prompt) do
383    receive do
384      {:done, parent, ref} ->
385        send(parent, {:done, self(), ref})
386        IO.write(:standard_error, "\e[2K\r")
387    after
388      1 ->
389        IO.write(:standard_error, "\e[2K\r#{prompt} ")
390        loop(prompt)
391    end
392  end
393
394  @progress_steps 25
395
396  @doc false
397  def progress(nil) do
398    fn _ -> nil end
399  end
400
401  def progress(max) do
402    put_progress(0, 0)
403
404    fn size ->
405      fraction = size / max
406      completed = trunc(fraction * @progress_steps)
407      put_progress(completed, trunc(fraction * 100))
408      size
409    end
410  end
411
412  defp put_progress(completed, percent) do
413    unfilled = @progress_steps - completed
414    str = "\r[#{String.duplicate("#", completed)}#{String.duplicate(" ", unfilled)}]"
415    IO.write(:stderr, str <> " #{percent}%")
416  end
417
418  if Mix.env() == :test do
419    @doc false
420    def set_exit_code(code), do: throw({:exit_code, code})
421  else
422    @doc false
423    def set_exit_code(code), do: System.at_exit(fn _ -> System.halt(code) end)
424  end
425end
426