defmodule NPM.Package.JSON do @moduledoc """ Read and write `package.json` files. """ @default_path "package.json" @doc "Read dependencies from `package.json`." @spec read(String.t()) :: {:ok, %{String.t() => String.t()}} | {:error, term()} def read(path \\ @default_path) do read_field(path, "dependencies") end @doc "Read all dependency groups from `package.json`." @spec read_all(String.t()) :: {:ok, %{dependencies: map(), dev_dependencies: map(), optional_dependencies: map()}} | {:error, term()} def read_all(path \\ @default_path) do case read_raw(path) do {:ok, data} -> {:ok, %{ dependencies: Map.get(data, "dependencies", %{}), dev_dependencies: Map.get(data, "devDependencies", %{}), optional_dependencies: Map.get(data, "optionalDependencies", %{}) }} {:error, :enoent} -> {:ok, %{dependencies: %{}, dev_dependencies: %{}, optional_dependencies: %{}}} {:error, reason} -> {:error, reason} end end @doc "Read scripts from `package.json`." @spec read_scripts(String.t()) :: {:ok, %{String.t() => String.t()}} | {:error, term()} def read_scripts(path \\ @default_path) do read_field(path, "scripts") end @doc "Read workspace patterns from `package.json`." @spec read_workspaces(String.t()) :: {:ok, [String.t()]} | {:error, term()} def read_workspaces(path \\ @default_path) do case read_raw(path) do {:ok, data} -> case Map.get(data, "workspaces") do nil -> {:ok, []} patterns when is_list(patterns) -> {:ok, patterns} %{"packages" => patterns} when is_list(patterns) -> {:ok, patterns} _ -> {:ok, []} end {:error, :enoent} -> {:ok, []} {:error, reason} -> {:error, reason} end end @doc """ Expand workspace patterns to actual directories with package.json files. Supports glob patterns like `packages/*` and `apps/**`. """ @spec expand_workspaces([String.t()], String.t()) :: [String.t()] def expand_workspaces(patterns, base_dir \\ ".") do Enum.flat_map(patterns, fn pattern -> Path.join(base_dir, pattern) |> Path.wildcard() |> Enum.filter(&File.exists?(Path.join(&1, "package.json"))) end) end @doc """ Check if a dependency range refers to a local file path. Supports `file:../path` and `file:./path` references. """ @spec file_dep?(String.t()) :: boolean() def file_dep?("file:" <> _), do: true def file_dep?(_), do: false @doc """ Resolve a file dependency path. Returns the absolute path for a `file:` reference. """ @spec resolve_file_dep(String.t(), String.t()) :: String.t() def resolve_file_dep("file:" <> path, base_dir) do Path.expand(path, base_dir) end @doc """ Check if a dependency range refers to a git repository. Supports `git+https://`, `git+ssh://`, `github:user/repo`, and `git://` URLs. """ @spec git_dep?(String.t()) :: boolean() def git_dep?("git+" <> _), do: true def git_dep?("git://" <> _), do: true def git_dep?("github:" <> _), do: true def git_dep?(range), do: String.contains?(range, ".git") @doc """ Check if a dependency range refers to a URL tarball. Supports `http://` and `https://` URLs ending in `.tgz` or `.tar.gz`. """ @spec url_dep?(String.t()) :: boolean() def url_dep?("http://" <> _ = url), do: tarball_url?(url) def url_dep?("https://" <> _ = url), do: tarball_url?(url) def url_dep?(_), do: false defp tarball_url?(url) do String.ends_with?(url, ".tgz") or String.ends_with?(url, ".tar.gz") end @doc "Read overrides from `package.json`." @spec read_overrides(String.t()) :: {:ok, %{String.t() => String.t()}} | {:error, term()} def read_overrides(path \\ @default_path) do read_field(path, "overrides") end @doc "Read resolutions (Yarn-style) from `package.json`." @spec read_resolutions(String.t()) :: {:ok, %{String.t() => String.t()}} | {:error, term()} def read_resolutions(path \\ @default_path) do read_field(path, "resolutions") end @doc """ Read bundleDependencies (or bundledDependencies) from `package.json`. Returns a list of package names that should be bundled in the tarball. """ @spec read_bundle_deps(String.t()) :: {:ok, [String.t()]} | {:error, term()} def read_bundle_deps(path \\ @default_path) do case read_raw(path) do {:ok, data} -> bundle = Map.get(data, "bundleDependencies") || Map.get(data, "bundledDependencies", []) case bundle do true -> {:ok, Map.keys(Map.get(data, "dependencies", %{}))} deps when is_list(deps) -> {:ok, deps} _ -> {:ok, []} end {:error, :enoent} -> {:ok, []} {:error, reason} -> {:error, reason} end end defp read_field(path, field) do case read_raw(path) do {:ok, data} -> {:ok, Map.get(data, field, %{})} {:error, :enoent} -> {:ok, %{}} {:error, reason} -> {:error, reason} end end @doc """ Add a dependency to `package.json`, creating the file if needed. ## Options * `:dev` - when `true`, adds to `devDependencies` instead of `dependencies` """ @spec add_dep(String.t(), String.t(), String.t(), keyword()) :: :ok | {:error, term()} def add_dep(name, range, path \\ @default_path, opts \\ []) do data = read_raw!(path) field = cond do opts[:dev] -> "devDependencies" opts[:optional] -> "optionalDependencies" true -> "dependencies" end deps = Map.get(data, field, %{}) updated = Map.put(data, field, Map.put(deps, name, range)) File.write(path, NPM.JSON.encode_pretty(updated)) end @doc "Remove a dependency from `package.json`." @spec remove_dep(String.t(), String.t()) :: :ok | {:error, term()} def remove_dep(name, path \\ @default_path) do data = read_raw!(path) field = ["dependencies", "devDependencies", "optionalDependencies"] |> Enum.find(fn field -> data |> Map.get(field, %{}) |> Map.has_key?(name) end) if field do deps = Map.get(data, field, %{}) updated = Map.put(data, field, Map.delete(deps, name)) File.write(path, NPM.JSON.encode_pretty(updated)) else {:error, {:not_found, name}} end end defp read_raw(path), do: NPM.JSON.read_file(path) defp read_raw!(path) do case read_raw(path) do {:ok, data} -> data {:error, :enoent} -> %{} {:error, reason} -> raise reason end end end