defmodule Aube do @moduledoc """ Elixir bindings for the Aube JavaScript package manager. """ @type install_option :: {:cwd, Path.t()} | {:frozen_lockfile, boolean()} | {:no_frozen_lockfile, boolean()} | {:prefer_frozen_lockfile, boolean()} | {:prod, boolean()} | {:dev, boolean()} | {:no_optional, boolean()} | {:offline, boolean()} | {:prefer_offline, boolean()} | {:ignore_scripts, boolean()} | {:lockfile_only, boolean()} | {:force, boolean()} | {:node_linker, String.t()} | {:registry, String.t()} @type install_result :: %{ project_dir: String.t(), duration_ms: non_neg_integer() } @type install_error :: %{ message: String.t(), code: String.t() | nil } @type run_result :: %{ exit_code: integer() } @type run_error :: %{ message: String.t(), code: String.t() | nil, exit_code: integer() } @allowed_options [ :cwd, :frozen_lockfile, :no_frozen_lockfile, :prefer_frozen_lockfile, :prod, :dev, :no_optional, :offline, :prefer_offline, :ignore_scripts, :lockfile_only, :force, :node_linker, :registry ] @doc """ Installs JavaScript packages for a project with Aube. """ @spec install([install_option()]) :: {:ok, install_result()} | {:error, install_error()} def install(opts \\ []) def install(opts) when is_list(opts) do with :ok <- validate_known_options(opts), :ok <- validate_mutual_exclusion(opts), {:ok, cwd} <- resolve_cwd(opts) do Aube.Native.install(native_options(opts, cwd)) end end def install(_opts) do {:error, %{message: "install options must be a keyword list", code: "invalid_options"}} end @doc """ Runs the Aube CLI with argv-style arguments. """ @spec run([String.t()]) :: {:ok, run_result()} | {:error, run_error()} def run(args) when is_list(args) do if Enum.all?(args, &is_binary/1) do Aube.Native.run_cli(args) else {:error, %{ message: "run args must be a list of strings", code: "invalid_args", exit_code: 1 }} end end def run(_args) do {:error, %{ message: "run args must be a list of strings", code: "invalid_args", exit_code: 1 }} end defp validate_known_options(opts) do case Keyword.keys(opts) -- @allowed_options do [] -> :ok unknown -> {:error, %{ message: "unknown install option(s): #{Enum.map_join(unknown, ", ", &inspect/1)}", code: "invalid_options" }} end end defp validate_mutual_exclusion(opts) do frozen_count = Enum.count( [:frozen_lockfile, :no_frozen_lockfile, :prefer_frozen_lockfile], &truthy?(opts[&1]) ) cond do frozen_count > 1 -> {:error, %{message: "frozen lockfile options are mutually exclusive", code: "invalid_options"}} truthy?(opts[:prod]) and truthy?(opts[:dev]) -> {:error, %{message: "prod and dev install modes are mutually exclusive", code: "invalid_options"}} truthy?(opts[:offline]) and truthy?(opts[:prefer_offline]) -> {:error, %{message: "offline and prefer_offline are mutually exclusive", code: "invalid_options"}} true -> :ok end end defp resolve_cwd(opts) do case Keyword.fetch(opts, :cwd) do {:ok, cwd} -> cwd |> Path.expand() |> validate_cwd() :error -> cond do File.exists?("assets/package.json") -> {:ok, Path.expand("assets")} File.exists?("package.json") -> {:ok, File.cwd!()} true -> {:error, %{ message: "no package.json found; pass :cwd or run from a project with package.json", code: "missing_package_json" }} end end end defp validate_cwd(cwd) do if File.dir?(cwd) do {:ok, cwd} else {:error, %{message: "cwd is not a directory: #{cwd}", code: "invalid_cwd"}} end end defp native_options(opts, cwd) do %{ cwd: cwd, frozen_lockfile: truthy?(opts[:frozen_lockfile]), no_frozen_lockfile: truthy?(opts[:no_frozen_lockfile]), prefer_frozen_lockfile: truthy?(opts[:prefer_frozen_lockfile]), prod: truthy?(opts[:prod]), dev: truthy?(opts[:dev]), no_optional: truthy?(opts[:no_optional]), offline: truthy?(opts[:offline]), prefer_offline: truthy?(opts[:prefer_offline]), ignore_scripts: truthy?(opts[:ignore_scripts]), lockfile_only: truthy?(opts[:lockfile_only]), force: truthy?(opts[:force]), node_linker: opts[:node_linker], registry: opts[:registry] } end defp truthy?(value), do: value in [true, "true", "1", 1] end