From 3f4f1e1da169ce5ede78e2453260c4ff0acd00ec Mon Sep 17 00:00:00 2001 From: "Adam C. Stephens" Date: Tue, 11 Aug 2026 19:13:34 -0400 Subject: [PATCH 1/2] Add escript CLI mode --- .github/workflows/build.yml | 5 +- deps.nix | 243 ++++++++++++++++++++++++++++++++++++ flake.nix | 9 +- lib/deps_nix.ex | 21 +++- lib/deps_nix/cli.ex | 27 ++++ lib/deps_nix/docs.ex | 84 +++++++++++++ lib/mix/tasks/deps.nix.ex | 87 +++---------- mix.exs | 3 +- package.nix | 39 ++++++ test/deps_nix_test.exs | 8 ++ 10 files changed, 447 insertions(+), 79 deletions(-) create mode 100644 deps.nix create mode 100644 lib/deps_nix/cli.ex create mode 100644 lib/deps_nix/docs.ex create mode 100644 package.nix diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fb0f604..9f18751 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,7 +6,6 @@ on: workflow_dispatch: jobs: - build: name: Build runs-on: ${{ matrix.os }} @@ -27,3 +26,7 @@ jobs: run: nix develop .#ci --command -- mix test - name: Check fixture derivations run: nix flake check + - name: Check deps.nix is up to date + run: | + nix run .# -- --no-app-config + git diff --exit-code -- deps.nix diff --git a/deps.nix b/deps.nix new file mode 100644 index 0000000..0854155 --- /dev/null +++ b/deps.nix @@ -0,0 +1,243 @@ +{ + lib, + beamPackages, + cmake, + extend, + lexbor, + fetchFromGitHub, + oniguruma, + overrides ? (x: y: { }), + overrideFenixOverlay ? null, + rustlerPrecompiledOverrides ? { }, + stdenv, + pkg-config, + vips, + writeText, +}: + +let + buildMix = lib.makeOverridable beamPackages.buildMix; + buildRebar3 = lib.makeOverridable beamPackages.buildRebar3; + + workarounds = { + portCompiler = _unusedArgs: old: { + buildPlugins = [ beamPackages.pc ]; + }; + + rustlerPrecompiled = + { + toolchain ? null, + buildInputs ? [ ], + nativeBuildInputs ? [ ], + env ? { }, + ... + }: + old: + let + extendedPkgs = extend fenixOverlay; + fenixOverlay = + if overrideFenixOverlay == null then + import "${ + fetchTarball { + url = "https://github.com/nix-community/fenix/archive/6399553b7a300c77e7f07342904eb696a5b6bf9d.tar.gz"; + sha256 = "sha256-C6tT7K1Lx6VsYw1BY5S3OavtapUvEnDQtmQB5DSgbCc="; + } + }/overlay.nix" + else + overrideFenixOverlay; + nativeDir = "${old.src}/native/${with builtins; head (attrNames (readDir "${old.src}/native"))}"; + fenix = + if toolchain == null then + extendedPkgs.fenix.stable + else + extendedPkgs.fenix.fromToolchainName toolchain; + native = + ( + (extendedPkgs.makeRustPlatform { + inherit (fenix) cargo rustc; + }).buildRustPackage + { + inherit env buildInputs; + pname = "${old.beamModuleName}-native"; + version = old.version; + src = nativeDir; + cargoLock = { + lockFile = "${nativeDir}/Cargo.lock"; + }; + nativeBuildInputs = [ extendedPkgs.cmake ] ++ nativeBuildInputs; + doCheck = false; + } + ).overrideAttrs + rustlerPrecompiledOverrides.${old.beamModuleName} or { }; + + in + { + nativeBuildInputs = [ extendedPkgs.cargo ]; + + env.RUSTLER_PRECOMPILED_FORCE_BUILD_ALL = "true"; + env.RUSTLER_PRECOMPILED_GLOBAL_CACHE_PATH = "unused-but-required"; + + preConfigure = '' + mkdir -p priv/native + for lib in ${native}/lib/* + do + dest="$(basename "$lib")" + if [[ "''${dest##*.}" = "dylib" ]] + then + dest="''${dest%.dylib}.so" + fi + dest="''${dest#lib}" + ln -s "$lib" "priv/native/$dest" + done + ''; + + preBuild = '' + suggestion() { + echo "***********************************************" + echo " deps_nix " + echo + echo " Rust dependency build failed. " + echo + echo " If you saw network errors, you might need " + echo " to disable compilation on the appropriate " + echo " RustlerPrecompiled module in your " + echo " application config. " + echo + echo " We think you need this: " + echo + echo -n " " + grep -Rl 'use RustlerPrecompiled' lib \ + | xargs grep 'defmodule' \ + | sed 's/defmodule \(.*\) do/config :${old.beamModuleName}, \1, skip_compilation?: true/' + echo "***********************************************" + exit 1 + } + trap suggestion ERR + ''; + }; + + elixirMake = _unusedArgs: old: { + preConfigure = '' + export ELIXIR_MAKE_CACHE_DIR="$TEMPDIR/elixir_make_cache" + ''; + }; + + lazyHtml = _unusedArgs: old: { + preConfigure = '' + export ELIXIR_MAKE_CACHE_DIR="$TEMPDIR/elixir_make_cache" + ''; + + postPatch = '' + substituteInPlace mix.exs \ + --replace-fail "Fine.include_dir()" '"${packages.fine}/src/c_include"' \ + --replace-fail '@lexbor_git_sha "244b84956a6dc7eec293781d051354f351274c46"' '@lexbor_git_sha ""' + ''; + + preBuild = '' + install -Dm644 -t _build/c/third_party/lexbor/$LEXBOR_GIT_SHA/build ${lexbor}/lib/liblexbor_static.a + ''; + }; + }; + + defaultOverrides = ( + final: prev: + + let + apps = { + crc32cer = [ + { + name = "portCompiler"; + } + ]; + explorer = [ + { + name = "rustlerPrecompiled"; + toolchain = { + name = "nightly-2025-06-23"; + sha256 = "sha256-UAoZcxg3iWtS+2n8TFNfANFt/GmkuOMDf7QAE0fRxeA="; + }; + } + ]; + snappyer = [ + { + name = "portCompiler"; + } + ]; + }; + + applyOverrides = + appName: drv: + let + allOverridesForApp = builtins.foldl' ( + acc: workaround: acc // (workarounds.${workaround.name} workaround) drv + ) { } apps.${appName}; + + in + if builtins.hasAttr appName apps then drv.override allOverridesForApp else drv; + + in + builtins.mapAttrs applyOverrides prev + ); + + self = packages // (defaultOverrides self packages) // (overrides self packages); + + packages = + with beamPackages; + with self; + { + + ex_nar = + let + version = "0.3.0"; + drv = buildMix { + inherit version; + name = "ex_nar"; + + src = fetchHex { + inherit version; + pkg = "ex_nar"; + sha256 = "cbb42d047764feac6c411efddcadc31866e9a998dd6e2bc1eb428cec1c49fdcd"; + }; + }; + in + drv; + + hpax = + let + version = "1.0.3"; + drv = buildMix { + inherit version; + name = "hpax"; + + src = fetchHex { + inherit version; + pkg = "hpax"; + sha256 = "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"; + }; + }; + in + drv; + + mint = + let + version = "1.7.1"; + drv = buildMix { + inherit version; + name = "mint"; + + src = fetchHex { + inherit version; + pkg = "mint"; + sha256 = "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"; + }; + + beamDeps = [ + hpax + ]; + }; + in + drv; + + }; +in +self diff --git a/flake.nix b/flake.nix index 774284e..5b17594 100644 --- a/flake.nix +++ b/flake.nix @@ -18,16 +18,21 @@ { packages = forAllSystems ( { pkgs, ... }: + let + beamPackages = pkgs.beamMinimal29Packages.overrideScope (_: prev: { elixir = prev.elixir_1_20; }); + in (pkgs.callPackages ./fixtures/example/deps.nix { }) - // { + // rec { example = pkgs.callPackage ./fixtures/example/package.nix { }; + default = deps_nix; + deps_nix = pkgs.callPackage ./package.nix { inherit beamPackages; }; } ); devShells = forAllSystems ( { pkgs, ... }: let - beamPackages = pkgs.beam29Packages.overrideScope (_: prev: { elixir = prev.elixir_1_20; }); + beamPackages = pkgs.beamMinimal29Packages.overrideScope (_: prev: { elixir = prev.elixir_1_20; }); in { default = pkgs.callPackage ./shells/local.nix { inherit beamPackages; }; diff --git a/lib/deps_nix.ex b/lib/deps_nix.ex index 4922c6e..e835c65 100644 --- a/lib/deps_nix.ex +++ b/lib/deps_nix.ex @@ -58,7 +58,12 @@ defmodule DepsNix do |> wrap(opts.output) end - @spec parse_args(list()) :: Options.t() + @version Mix.Project.config()[:version] + + @spec version() :: String.t() + def version, do: @version + + @spec parse_args(list()) :: Options.t() | :help | :version def parse_args(args) do args |> OptionParser.parse( @@ -67,7 +72,9 @@ defmodule DepsNix do output: :string, app_config: :boolean, app_config_path: :string, - include_paths: :boolean + include_paths: :boolean, + help: :boolean, + version: :boolean ] ) |> to_opts() @@ -149,7 +156,7 @@ defmodule DepsNix do end end - @spec to_opts({list(), any(), any()}) :: Options.t() + @spec to_opts({list(), any(), any()}) :: Options.t() | :help | :version defp to_opts({[], _, _}) do %Options{ cwd: File.cwd!(), @@ -159,6 +166,14 @@ defmodule DepsNix do end defp to_opts({opts, _, _}) do + cond do + Keyword.get(opts, :help) -> :help + Keyword.get(opts, :version) -> :version + true -> build_opts(opts) + end + end + + defp build_opts(opts) do default = to_opts({[], [], []}) %{ diff --git a/lib/deps_nix/cli.ex b/lib/deps_nix/cli.ex new file mode 100644 index 0000000..6c82b56 --- /dev/null +++ b/lib/deps_nix/cli.ex @@ -0,0 +1,27 @@ +defmodule DepsNix.CLI do + def main(args) do + case DepsNix.parse_args(args) do + :help -> + IO.ANSI.Docs.print_headings(["deps_nix"]) + IO.ANSI.Docs.print(DepsNix.Docs.markdown("deps_nix"), "text/markdown", []) + + :version -> + IO.puts("deps_nix #{DepsNix.version()}") + + opts -> + generate(opts) + end + end + + defp generate(opts) do + {:ok, _started_apps} = Application.ensure_all_started([:mint, :mix]) + + Code.compile_file(Path.absname(System.get_env("MIX_EXS", "mix.exs"))) + + Mix.Project.get!() + + {path, output} = DepsNix.run(opts, &Mix.Dep.Converger.converge/1) + + File.write!(path, output) + end +end diff --git a/lib/deps_nix/docs.ex b/lib/deps_nix/docs.ex new file mode 100644 index 0000000..af0d621 --- /dev/null +++ b/lib/deps_nix/docs.ex @@ -0,0 +1,84 @@ +defmodule DepsNix.Docs do + @spec markdown(String.t()) :: String.t() + def markdown(command) do + """ + Creates Nix derivations for Mix dependencies. + + When run without arguments, generates `deps.nix` in the current directory, but + only for `:prod` dependencies. + + The resulting file contains a Nix function that can be called with an empty + attribute set: + + ``` + pkgs.callPackages ./deps.nix { } + ``` + + That attribute set can optionally include overrides, which look like Nix + overlays: + + ``` + pkgs.callPackages ./deps.nix { + overrides = ( + final: prev: { + some_mix_dep = prev.some_mix_dep.override { + mixEnv = "dev"; + }; + } + ); + } + ``` + + ## Options + + * `--env ENV[=PKG,PKG]` - include an environment's dependencies, optionally + limited to the given packages. Repeatable. Defaults to `--env prod`. + * `--output PATH` - where to write the Nix expression. Defaults to + `deps.nix`. + * `--include-paths` - include `:path` dependencies. + * `--no-app-config` - omit `appConfigPath` from `buildMix` derivations. + * `--app-config-path PATH` - set `appConfigPath` for all derivations. + * `--help` - show this documentation. + * `--version` - show the deps_nix version. + + ## Example with all options + + This command creates derivations for everything in `:prod`, and only `ex_doc` + and `credo` in `:dev`. It will include `:path` dependencies (this option is + useful for repos with multiple Mix projects). It outputs the Nix expression + to `nix/deps.nix`. It specifies `appConfigPath` to be `./my-app/config` for + all nix dependency derivations. + + ``` + #{command} --include-paths --env prod --env dev=ex_doc,credo --output nix/deps.nix --app-config-path ./my-app/config + ``` + + ## Application config + + By default, every `buildMix` derivation is given an `appConfigPath` pointing + at your application's `config` directory, so that dependencies see your + compile-time configuration. This means a change to any config file + invalidates every dependency and forces a recompile. + + Pass `--no-app-config` to omit `appConfigPath` entirely. Dependencies then + build with an empty config and are no longer rebuilt when your config + changes. Individual dependencies that need compile-time config can be given + one back through the `overrides` argument of the generated file: + + ``` + some_dep = prev.some_dep.override { appConfigPath = ./config; }; + ``` + + ## Git dependencies + + `deps_nix` supports git dependencies. + + For open-source GitHub dependencies, `pkgs.fetchFromGitHub` is used. Hashes + are prefetched using [Mint](https://hexdocs.pm/mint) and hashed using `nix hash path`. + + `builtins.fetchGit` is used for non-GitHub projects, which doesn't require + any prefetching and relies on the git SHA as a unique identifier. However, it + suffers from [several disadvantages](https://discourse.nixos.org/t/fetchgit-vs-fetchgit/23325). + """ + end +end diff --git a/lib/mix/tasks/deps.nix.ex b/lib/mix/tasks/deps.nix.ex index 22ee0e7..a795222 100644 --- a/lib/mix/tasks/deps.nix.ex +++ b/lib/mix/tasks/deps.nix.ex @@ -1,71 +1,5 @@ defmodule Mix.Tasks.Deps.Nix do - @moduledoc """ - Creates Nix derivations for Mix dependencies. - - When run without arguments, generates `deps.nix` in the current directory, but - only for `:prod` dependencies. - - The resulting file contains a Nix function that can be called with an empty - attribute set: - - ``` - pkgs.callPackages ./deps.nix { } - ``` - - That attribute set can optionally include overrides, which look like Nix - overlays: - - ``` - pkgs.callPackages ./deps.nix { - overrides = ( - final: prev: { - some_mix_dep = prev.some_mix_dep.override { - mixEnv = "dev"; - }; - } - ); - } - ``` - - ## Example with all options - - This command creates derivations for everything in `:prod`, and only `ex_doc` - and `credo` in `:dev`. It will include `:path` dependencies (this option is - useful for repos with multiple Mix projects). It outputs the Nix expression - to `nix/deps.nix`. It specifies `appConfigPath` to be `./my-app/config` for - all nix dependency derivations. - - ``` - mix deps.nix --include-paths --env prod --env dev=ex_doc,credo --output nix/deps.nix --app-config-path ./my-app/config - ``` - - ## Application config - - By default, every `buildMix` derivation is given an `appConfigPath` pointing - at your application's `config` directory, so that dependencies see your - compile-time configuration. This means a change to any config file - invalidates every dependency and forces a recompile. - - Pass `--no-app-config` to omit `appConfigPath` entirely. Dependencies then - build with an empty config and are no longer rebuilt when your config - changes. Individual dependencies that need compile-time config can be given - one back through the `overrides` argument of the generated file: - - ``` - some_dep = prev.some_dep.override { appConfigPath = ./config; }; - ``` - - ## Git dependencies - - `deps_nix` supports git dependencies. - - For open-source GitHub dependencies, `pkgs.fetchFromGitHub` is used. Hashes - are prefetched using [Mint](https://hexdocs.pm/mint) and hashed using `nix hash path`. - - `builtins.fetchGit` is used for non-GitHub projects, which doesn't require - any prefetching and relies on the git SHA as a unique identifier. However, it - suffers from [several disadvantages](https://discourse.nixos.org/t/fetchgit-vs-fetchgit/23325). - """ + @moduledoc DepsNix.Docs.markdown("mix deps.nix") @shortdoc "Produce nix derivations for mix dependencies" @@ -73,15 +7,24 @@ defmodule Mix.Tasks.Deps.Nix do @impl Mix.Task def run(args) do + case DepsNix.parse_args(args) do + :help -> + Mix.Task.run("help", ["deps.nix"]) + + :version -> + Mix.shell().info("deps_nix #{DepsNix.version()}") + + opts -> + generate(opts) + end + end + + defp generate(opts) do {:ok, _started_apps} = Application.ensure_all_started(:mint) Mix.Project.get!() - {path, output} = - DepsNix.run( - DepsNix.parse_args(args), - &Mix.Dep.Converger.converge/1 - ) + {path, output} = DepsNix.run(opts, &Mix.Dep.Converger.converge/1) File.write!(path, output) end diff --git a/mix.exs b/mix.exs index a0987da..5e6a89b 100644 --- a/mix.exs +++ b/mix.exs @@ -9,6 +9,7 @@ defmodule DepsNix.MixProject do deps: deps(), description: "Mix task that converts Mix dependencies to Nix derivations", dialyzer: [plt_add_apps: [:mix]], + escript: [main_module: DepsNix.CLI], elixir: "~> 1.16", package: package(), start_permanent: Mix.env() == :prod, @@ -26,7 +27,7 @@ defmodule DepsNix.MixProject do # Run "mix help compile.app" to learn about applications. def application do [ - extra_applications: [] + extra_applications: [:mix] ] end diff --git a/package.nix b/package.nix new file mode 100644 index 0000000..930a0ad --- /dev/null +++ b/package.nix @@ -0,0 +1,39 @@ +{ + beamPackages, + callPackages, + gnutar, + lib, +}: +beamPackages.mixRelease (finalAttrs: { + pname = "deps_nix"; + version = "3.1.0"; + + src = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ + ./lib + ./mix.exs + ./mix.lock + ]; + }; + + mixNixDeps = callPackages ./deps.nix { + inherit lib beamPackages; + }; + + escriptBinName = "deps_nix"; + + postFixup = '' + wrapProgram $out/bin/deps_nix \ + --suffix ERL_LIBS : ${beamPackages.hex}/lib/erlang/lib \ + --suffix PATH : ${lib.makeBinPath [ gnutar ]} + ''; + + meta = { + description = "escript that converts Mix dependencies to Nix derivations"; + homepage = "https://github.com/code-supply/deps_nix"; + changelog = "https://github.com/code-supply/deps_nix/releases/tag/v${finalAttrs.version}"; + license = lib.licenses.mit; + mainProgram = "deps_nix"; + }; +}) diff --git a/test/deps_nix_test.exs b/test/deps_nix_test.exs index bfa56ed..2be76d4 100644 --- a/test/deps_nix_test.exs +++ b/test/deps_nix_test.exs @@ -53,6 +53,14 @@ defmodule DepsNixTest do assert %DepsNix.Options{envs: %{"prod" => :all}} = DepsNix.parse_args(~w()) end + test "asks for help" do + assert DepsNix.parse_args(~w(--help)) == :help + end + + test "asks for the version" do + assert DepsNix.parse_args(~w(--version)) == :version + end + test "can pick up a single env" do assert %DepsNix.Options{envs: %{"dev" => :all}} = DepsNix.parse_args(~w(--env dev)) end From f83b56abca21b4550cffc167ef02a18ece604dd5 Mon Sep 17 00:00:00 2001 From: "Adam C. Stephens" Date: Tue, 11 Aug 2026 19:13:34 -0400 Subject: [PATCH 2/2] Share release version between mix and nix --- VERSION | 1 + mix.exs | 4 +++- package.nix | 6 +++++- 3 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 VERSION diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..94ff29c --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +3.1.1 diff --git a/mix.exs b/mix.exs index 5e6a89b..1bd6cbd 100644 --- a/mix.exs +++ b/mix.exs @@ -4,6 +4,8 @@ defmodule DepsNix.MixProject do @scm_url "https://github.com/code-supply/deps_nix" def project do + version = "VERSION" |> Path.expand(__DIR__) |> File.read!() |> String.trim() + [ app: :deps_nix, deps: deps(), @@ -13,7 +15,7 @@ defmodule DepsNix.MixProject do elixir: "~> 1.16", package: package(), start_permanent: Mix.env() == :prod, - version: "3.1.1", + version: version, # Docs source_url: @scm_url, diff --git a/package.nix b/package.nix index 930a0ad..d091ef6 100644 --- a/package.nix +++ b/package.nix @@ -4,9 +4,12 @@ gnutar, lib, }: +let + version = lib.trim (builtins.readFile ./VERSION); +in beamPackages.mixRelease (finalAttrs: { pname = "deps_nix"; - version = "3.1.0"; + inherit version; src = lib.fileset.toSource { root = ./.; @@ -14,6 +17,7 @@ beamPackages.mixRelease (finalAttrs: { ./lib ./mix.exs ./mix.lock + ./VERSION ]; };