Skip to content

Commit d64a366

Browse files
committed
Release 0.5.0
1 parent 413af1e commit d64a366

7 files changed

Lines changed: 234 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## 0.5.0
4+
5+
- Add `NPM.install/2` for script context — works like `Mix.install/2`, installs to a content-addressed cache directory without requiring a Mix project
6+
- Add `NPM.installed?/0`, `NPM.install_dir!/0`, `NPM.node_modules_dir!/0` helpers
7+
- `mix npm.install` now accepts multiple packages: `mix npm.install lodash react vue`
8+
- Fix infinite loop when a package lists itself as a dependency (e.g. `sqlite-napi`)
9+
310
## 0.4.6
411

512
- Add packument disk cache (`~/.npm_ex/packuments/`) with 1h TTL — avoids refetching registry metadata on repeat installs

lib/mix/tasks/npm.install.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ defmodule Mix.Tasks.Npm.Install do
1111
mix npm.install --frozen # Fail if lockfile is stale (CI)
1212
mix npm.install --production # Skip devDependencies
1313
mix npm.install eslint --save-dev # Add to devDependencies
14+
mix npm.install lodash react vue # Add multiple packages
1415
1516
Resolves all dependencies using the PubGrub solver, writes `npm.lock`,
1617
and links packages into `node_modules/`.
@@ -25,8 +26,7 @@ defmodule Mix.Tasks.Npm.Install do
2526

2627
case positional do
2728
[] -> NPM.install(opts)
28-
[spec] -> install_spec(spec, opts)
29-
_ -> Mix.shell().error("Usage: mix npm.install [package[@range]] [--frozen] [--save-dev]")
29+
specs -> Enum.each(specs, &install_spec(&1, opts))
3030
end
3131
end
3232

lib/npm.ex

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,49 @@ defmodule NPM do
2222
@node_modules "node_modules"
2323

2424
@doc """
25-
Install all dependencies from `package.json`.
25+
Install npm packages in a script context, without a Mix project.
26+
27+
Works like `Mix.install/2` — installs to a content-addressed cache directory,
28+
is idempotent, and can only be called once per VM (raises on different deps).
29+
30+
NPM.install(%{"tailwindcss" => "^4.2.2"})
31+
32+
After installation, use `NPM.install_dir!/0` and `NPM.node_modules_dir!/0`
33+
to locate the installed packages.
34+
35+
## Options
36+
37+
* `:force` — reinstall even if cached (default: `false`)
38+
"""
39+
@spec install(map(), keyword()) :: :ok
40+
def install(deps, opts) when is_map(deps) do
41+
NPM.ScriptInstall.install(deps, opts)
42+
end
43+
44+
@doc """
45+
Returns whether `NPM.install/2` has been called in this VM.
46+
"""
47+
@spec installed? :: boolean()
48+
defdelegate installed?, to: NPM.ScriptInstall
49+
50+
@doc """
51+
Returns the root directory of the current `NPM.install/2` installation.
52+
53+
Raises if `NPM.install/2` has not been called.
54+
"""
55+
@spec install_dir! :: String.t()
56+
defdelegate install_dir!, to: NPM.ScriptInstall
57+
58+
@doc """
59+
Returns the `node_modules` path of the current `NPM.install/2` installation.
60+
61+
Raises if `NPM.install/2` has not been called.
62+
"""
63+
@spec node_modules_dir! :: String.t()
64+
defdelegate node_modules_dir!, to: NPM.ScriptInstall
65+
66+
@doc """
67+
Install all dependencies from `package.json` (project context).
2668
2769
## Options
2870
@@ -31,7 +73,7 @@ defmodule NPM do
3173
* `:production` - when `true`, skips `devDependencies`.
3274
"""
3375
@spec install(keyword()) :: :ok | {:error, term()}
34-
def install(opts \\ []) do
76+
def install(opts \\ []) when is_list(opts) do
3577
case NPM.PackageJSON.read_all() do
3678
{:ok, %{dependencies: deps, dev_dependencies: dev_deps, optional_dependencies: opt_deps}} ->
3779
all_deps =

lib/npm/resolver.ex

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ defmodule NPM.Resolver do
229229
@impl true
230230
def dependencies(_repo, package, version) do
231231
case get_cached_packument(package) do
232-
{:ok, packument} -> deps_for_version(packument, to_string(version))
232+
{:ok, packument} -> deps_for_version(package, packument, to_string(version))
233233
{:error, _} -> :error
234234
end
235235
end
@@ -262,7 +262,7 @@ defmodule NPM.Resolver do
262262
|> Enum.sort(Version)
263263
end
264264

265-
defp deps_for_version(packument, version_str) do
265+
defp deps_for_version(package, packument, version_str) do
266266
excluded = get_excluded()
267267

268268
case Map.get(packument.versions, version_str) do
@@ -274,17 +274,18 @@ defmodule NPM.Resolver do
274274

275275
deps =
276276
info
277-
|> solver_dependencies(excluded, overrides)
277+
|> solver_dependencies(package, excluded, overrides)
278278

279279
{:ok, deps}
280280
end
281281
end
282282

283-
defp solver_dependencies(info, excluded, overrides) do
283+
defp solver_dependencies(info, self_name, excluded, overrides) do
284284
optional_dependency_names = Map.keys(info.optional_dependencies)
285285

286286
required =
287287
info.dependencies
288+
|> Enum.reject(fn {name, _} -> name == self_name end)
288289
|> Enum.reject(fn {name, _} -> name in optional_dependency_names end)
289290
|> Enum.reject(fn {name, _} -> MapSet.member?(excluded, name) end)
290291
|> Enum.map(fn {name, range} -> {name, Map.get(overrides, name, range), false} end)

lib/npm/script_install.ex

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
defmodule NPM.ScriptInstall do
2+
@moduledoc false
3+
4+
@state_key :npm_script_installed
5+
6+
@spec install(map(), keyword()) :: :ok
7+
def install(deps, opts) when is_map(deps) do
8+
Application.ensure_all_started(:req)
9+
10+
unless Keyword.get(opts, :__skip_project_check__, false) do
11+
if Mix.Project.get() do
12+
Mix.raise("NPM.install/2 cannot be used inside a Mix project. Use mix npm.install instead.")
13+
end
14+
end
15+
16+
id = cache_id(deps)
17+
force? = Keyword.get(opts, :force, false)
18+
19+
case :persistent_term.get(@state_key, nil) do
20+
nil ->
21+
do_install(deps, id, force?)
22+
23+
{^id, _dir} when not force? ->
24+
:ok
25+
26+
{^id, _dir} ->
27+
do_install(deps, id, true)
28+
29+
_ ->
30+
Mix.raise("NPM.install/2 can only be called with the same dependencies in the given VM")
31+
end
32+
end
33+
34+
defp do_install(deps, id, force?) do
35+
dir = install_dir(id)
36+
37+
if force?, do: File.rm_rf!(dir)
38+
39+
nm_dir = Path.join(dir, "node_modules")
40+
lockfile_path = Path.join(dir, "npm.lock")
41+
42+
if not force? and File.exists?(lockfile_path) and node_modules_intact?(lockfile_path, nm_dir) do
43+
:persistent_term.put(@state_key, {id, dir})
44+
:ok
45+
else
46+
File.mkdir_p!(dir)
47+
resolve_and_link(deps, dir, nm_dir, lockfile_path)
48+
:persistent_term.put(@state_key, {id, dir})
49+
:ok
50+
end
51+
end
52+
53+
defp resolve_and_link(deps, _dir, nm_dir, lockfile_path) do
54+
NPM.Resolver.clear_cache()
55+
56+
case NPM.Resolver.resolve(deps) do
57+
{:ok, resolved} ->
58+
{_nested, flat} = Map.pop(resolved, :nested, %{})
59+
lockfile = build_lockfile(flat)
60+
NPM.Lockfile.write(lockfile, lockfile_path)
61+
NPM.Linker.link(lockfile, nm_dir)
62+
63+
{:error, message} ->
64+
Mix.raise("NPM.install/2 resolution failed:\n#{message}")
65+
end
66+
end
67+
68+
defp build_lockfile(resolved) do
69+
for {name, version_str} <- resolved, into: %{} do
70+
{:ok, packument} = NPM.Registry.get_packument(name)
71+
info = Map.fetch!(packument.versions, version_str)
72+
73+
{name,
74+
%{
75+
version: version_str,
76+
integrity: info.dist.integrity,
77+
tarball: info.dist.tarball,
78+
dependencies: info.dependencies,
79+
optional_dependencies: info.optional_dependencies
80+
}}
81+
end
82+
end
83+
84+
defp node_modules_intact?(lockfile_path, nm_dir) do
85+
case NPM.Lockfile.read(lockfile_path) do
86+
{:ok, lockfile} when lockfile != %{} ->
87+
Enum.all?(lockfile, fn {name, _} ->
88+
File.exists?(Path.join([nm_dir, name, "package.json"]))
89+
end)
90+
91+
_ ->
92+
false
93+
end
94+
end
95+
96+
defp cache_id(deps) do
97+
deps
98+
|> :erlang.term_to_binary()
99+
|> :erlang.md5()
100+
|> Base.encode16(case: :lower)
101+
end
102+
103+
defp install_dir(id) do
104+
root =
105+
System.get_env("NPM_INSTALL_DIR") ||
106+
Path.join(NPM.Cache.dir(), "installs")
107+
108+
Path.join(root, id)
109+
end
110+
111+
@spec installed? :: boolean()
112+
def installed? do
113+
:persistent_term.get(@state_key, nil) != nil
114+
end
115+
116+
@spec install_dir! :: String.t()
117+
def install_dir! do
118+
case :persistent_term.get(@state_key, nil) do
119+
{_id, dir} -> dir
120+
nil -> Mix.raise("NPM.install/2 has not been called")
121+
end
122+
end
123+
124+
@spec node_modules_dir! :: String.t()
125+
def node_modules_dir! do
126+
Path.join(install_dir!(), "node_modules")
127+
end
128+
end

mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
defmodule NPM.MixProject do
22
use Mix.Project
33

4-
@version "0.4.6"
4+
@version "0.5.0"
55
@source_url "https://github.com/elixir-volt/npm_ex"
66

77
def project do

test/npm/script_install_test.exs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
defmodule NPM.ScriptInstallTest do
2+
use ExUnit.Case, async: false
3+
4+
@test_opts [__skip_project_check__: true]
5+
6+
setup do
7+
:persistent_term.erase(:npm_script_installed)
8+
:ok
9+
end
10+
11+
test "installs packages to content-addressed cache dir" do
12+
deps = %{"is-number" => "^7.0.0"}
13+
assert :ok = NPM.ScriptInstall.install(deps, @test_opts)
14+
assert NPM.ScriptInstall.installed?()
15+
16+
nm = NPM.ScriptInstall.node_modules_dir!()
17+
assert File.exists?(Path.join(nm, "is-number/package.json"))
18+
end
19+
20+
test "second call with same deps is a noop" do
21+
deps = %{"is-number" => "^7.0.0"}
22+
assert :ok = NPM.ScriptInstall.install(deps, @test_opts)
23+
assert :ok = NPM.ScriptInstall.install(deps, @test_opts)
24+
end
25+
26+
test "second call with different deps raises" do
27+
deps1 = %{"is-number" => "^7.0.0"}
28+
deps2 = %{"is-odd" => "^3.0.0"}
29+
assert :ok = NPM.ScriptInstall.install(deps1, @test_opts)
30+
31+
assert_raise Mix.Error, ~r/same dependencies/, fn ->
32+
NPM.ScriptInstall.install(deps2, @test_opts)
33+
end
34+
end
35+
36+
test "force reinstalls" do
37+
deps = %{"is-number" => "^7.0.0"}
38+
assert :ok = NPM.ScriptInstall.install(deps, @test_opts)
39+
assert :ok = NPM.ScriptInstall.install(deps, [force: true] ++ @test_opts)
40+
end
41+
42+
test "install_dir! raises when not installed" do
43+
assert_raise Mix.Error, ~r/not been called/, fn ->
44+
NPM.ScriptInstall.install_dir!()
45+
end
46+
end
47+
end

0 commit comments

Comments
 (0)