diff --git a/opsm_ex/lib/opsm/runtime/manager.ex b/opsm_ex/lib/opsm/runtime/manager.ex index 0b5a627..fe544e3 100644 --- a/opsm_ex/lib/opsm/runtime/manager.ex +++ b/opsm_ex/lib/opsm/runtime/manager.ex @@ -292,31 +292,55 @@ defmodule Opsm.Runtime.Manager do nil -> {:error, {:no_plugin, tool}} path -> - case System.cmd("nickel", ["export", "--format", "json", path], stderr_to_stdout: true) do - {json_str, 0} -> - case Jason.decode(json_str) do - {:ok, plugin} -> - # Cache for next time - File.mkdir_p!(@plugins_dir) - File.write!(cached, json_str) - {:ok, plugin} - err -> {:error, {:json_decode, err}} + # System.cmd/3 raises ErlangError (:enoent) when the nickel binary + # is not installed — surface that as a structured error instead. + try do + case System.cmd("nickel", ["export", "--format", "json", path], stderr_to_stdout: true) do + {json_str, 0} -> + case Jason.decode(json_str) do + {:ok, plugin} -> + # Cache for next time + File.mkdir_p!(@plugins_dir) + File.write!(cached, json_str) + {:ok, plugin} + err -> {:error, {:json_decode, err}} + end + {err_str, _} -> + {:error, {:nickel_eval, err_str}} + end + rescue + e in ErlangError -> + case e do + %ErlangError{original: :enoent} -> {:error, {:nickel_not_installed, tool}} + _ -> reraise e, __STACKTRACE__ end - {err_str, _} -> - {:error, {:nickel_eval, err_str}} end end end end - @plugin_search_dirs [ - # Relative to the OPSM install — resolved at runtime - Path.expand("../runtime/core", :code.priv_dir(:opsm)), - Path.expand("~/.opsm/plugins/core"), - ] + # Computed at call time, NOT a module attribute: an attribute freezes + # :code.priv_dir/1 at compile time (pointing inside whatever _build the + # module was compiled in), and the repo-checkout fallbacks depend on cwd. + defp plugin_search_dirs do + priv_relative = + case :code.priv_dir(:opsm) do + {:error, _} -> [] + priv -> [Path.expand("../runtime/core", priv)] + end + + priv_relative ++ + [ + Path.expand("~/.opsm/plugins/core"), + # Repo checkout: cwd is opsm_ex/ under mix, the repo root as escript + Path.expand("../runtime/core", File.cwd!()), + Path.expand("runtime/core", File.cwd!()) + ] + end - defp find_plugin_ncl(tool) do - Enum.find_value(@plugin_search_dirs, fn dir -> + @doc false + def find_plugin_ncl(tool) do + Enum.find_value(plugin_search_dirs(), fn dir -> path = Path.join(dir, "#{tool}.ncl") if File.exists?(path), do: path end) diff --git a/opsm_ex/test/opsm/runtime/integration_test.exs b/opsm_ex/test/opsm/runtime/integration_test.exs index 414ec40..a798468 100644 --- a/opsm_ex/test/opsm/runtime/integration_test.exs +++ b/opsm_ex/test/opsm/runtime/integration_test.exs @@ -140,7 +140,6 @@ defmodule Opsm.Runtime.IntegrationTest do # --------------------------------------------------------------------------- describe "Manager.install/2 — live download (zig 0.13.0)" do - @tag :external_api @tag :live_download test "installs zig 0.13.0 and creates expected directory layout" do install_result = Manager.install("zig", @zig_stable) @@ -158,7 +157,6 @@ defmodule Opsm.Runtime.IntegrationTest do assert File.dir?(install_dir), "expected install_dir #{install_dir} to exist" end - @tag :external_api @tag :live_download test "which/1 returns a path after successful install" do Manager.install("zig", @zig_stable) @@ -175,7 +173,6 @@ defmodule Opsm.Runtime.IntegrationTest do end end - @tag :external_api @tag :live_download test "current_version/1 returns the installed version after install" do Manager.install("zig", @zig_stable) @@ -185,7 +182,6 @@ defmodule Opsm.Runtime.IntegrationTest do assert version == @zig_stable or version == "none" end - @tag :external_api @tag :live_download test "remove/1 cleans up installed tool" do Manager.install("zig", @zig_stable) @@ -202,7 +198,6 @@ defmodule Opsm.Runtime.IntegrationTest do # --------------------------------------------------------------------------- describe "Manager.install/2 — idempotency" do - @tag :external_api @tag :live_download test "installing the same version twice returns :ok both times" do on_exit(fn -> Manager.remove("zig") end) @@ -229,7 +224,6 @@ defmodule Opsm.Runtime.IntegrationTest do {:ok, dir: dir} end - @tag :external_api @tag :live_download test "installs tools declared in [runtime] section", %{dir: dir} do manifest = Path.join(dir, "opsm.toml") diff --git a/opsm_ex/test/opsm/runtime/manager_test.exs b/opsm_ex/test/opsm/runtime/manager_test.exs index 82a7564..f36c833 100644 --- a/opsm_ex/test/opsm/runtime/manager_test.exs +++ b/opsm_ex/test/opsm/runtime/manager_test.exs @@ -105,6 +105,24 @@ defmodule Opsm.Runtime.ManagerTest do end end + # --------------------------------------------------------------------------- + # find_plugin_ncl/1 — plugin search path resolution + # --------------------------------------------------------------------------- + + describe "find_plugin_ncl/1" do + test "resolves a core plugin from the repo checkout (runtime/core)" do + # Under mix, cwd is opsm_ex/ — the sibling runtime/core/ dir must be + # searched at call time (a compile-time priv_dir attribute cannot see it) + path = Manager.find_plugin_ncl("zig") + assert is_binary(path), "expected zig.ncl to resolve from the repo checkout" + assert String.ends_with?(path, "/zig.ncl") + end + + test "returns nil for a tool with no plugin definition" do + assert Manager.find_plugin_ncl("definitely-not-a-real-tool-xyz") == nil + end + end + # --------------------------------------------------------------------------- # list_installed/0 — filesystem scan # ---------------------------------------------------------------------------