From 4d361aa5808da912732d79f019e5c99c92fd7f2b Mon Sep 17 00:00:00 2001 From: eureka-cpu Date: Mon, 9 Mar 2026 01:10:23 -0500 Subject: [PATCH 1/7] feat: Add subshell purity --- README.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ shell.nix | 1 + 2 files changed, 76 insertions(+) create mode 100644 shell.nix diff --git a/README.md b/README.md index 770069d..7a0d9cb 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,81 @@ nix profile add github:eureka-cpu/ns nix profile remove ns ``` +--- + +## Purity Constraints + +Nix makes certain reproducibility guarantees, but default behavior varies in ways that can trade +reproducibility for convenience or functionality. + +Without flakes, Nix does not enforce purity at evaluation time, which is why `builtins.currentSystem` works. +With flakes, evaluation is pure by default, which is why you must explicitly declare which systems +your flake supports rather than getting them at evaluation time. However, the `nix shell` and +`nix develop` commands introduced by the `nix-command` and `flakes` experimental features have no +equivalent to `nix-shell --pure` and there is no current way to get environment purity from these commands. + +For this reason, `ns` treats `nix-shell` as the only viable command when purity is a requirement. +If both flakes and environment purity are non-negotiable, the following pattern bridges the two: + +1. Define your `devShells` in `flake.nix`: + +```nix +{ + inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + outputs = { self, nixpkgs }: + let + inherit (nixpkgs.lib) genAttrs systems; + eachSystem = f: genAttrs systems.flakeExposed (system: f nixpkgs.legacyPackages.${system}); + in + { + devShells = eachSystem (pkgs: { + default = pkgs.mkShell { + packages = with pkgs; [ + hello + ]; + }; + }); + }; +} +``` + +2. Expose your flake's `devShell` via `shell.nix`: + +```nix +(builtins.getFlake (toString ./.)).outputs.devShells.${builtins.currentSystem}.default +``` + +`nix-shell --pure` will now use the pinned derivation from your flake. The tradeoff is that system +tools are no longer available in the shell: + +```sh +ns --pure +git --version +# bash: git: command not found +hello +# Hello, world! +``` + +This is easily resolved by adding the tools you need to `packages` in your `mkShell`. + +--- + +### macOS Sandboxing + +Nix on macOS sets `sandbox = false` in `nix.conf` by default, because sandboxing breaks certain +functionality on Darwin. It's worth being precise about what this affects: sandboxing governs how +packages are *built*, not the purity of the environment in which you use them. The flake evaluation +purity described above is a separate concern. + +`ns` does not attempt to work around this — it is a macOS-level constraint, not something a wrapper +can meaningfully solve. In most cases, simply knowing that packages are built without sandboxing on +macOS is enough context for debugging. For example, if a package behaves differently on macOS than on +Linux, the sandbox difference is a likely culprit. You can test this hypothesis for a one-off build +with `--option sandbox true`, but enabling it permanently in `nix.conf` is not recommended as it can +cause difficult-to-diagnose failures. + +--- + ## Contributing > [!Important] diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..a605591 --- /dev/null +++ b/shell.nix @@ -0,0 +1 @@ +(builtins.getFlake (toString ./.)).outputs.devShells.${builtins.currentSystem}.default From fcfd814771ac6e783aa9ada2684649f57e75be0a Mon Sep 17 00:00:00 2001 From: eureka-cpu Date: Mon, 9 Mar 2026 23:08:53 -0500 Subject: [PATCH 2/7] rm readme updates, add some options to the cli --- README.md | 75 ----------------------------------------------------- bin/main.ml | 2 +- lib/cli.ml | 36 +++++++++++++++++++++++-- lib/cmd.ml | 1 + 4 files changed, 36 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 7a0d9cb..770069d 100644 --- a/README.md +++ b/README.md @@ -40,81 +40,6 @@ nix profile add github:eureka-cpu/ns nix profile remove ns ``` ---- - -## Purity Constraints - -Nix makes certain reproducibility guarantees, but default behavior varies in ways that can trade -reproducibility for convenience or functionality. - -Without flakes, Nix does not enforce purity at evaluation time, which is why `builtins.currentSystem` works. -With flakes, evaluation is pure by default, which is why you must explicitly declare which systems -your flake supports rather than getting them at evaluation time. However, the `nix shell` and -`nix develop` commands introduced by the `nix-command` and `flakes` experimental features have no -equivalent to `nix-shell --pure` and there is no current way to get environment purity from these commands. - -For this reason, `ns` treats `nix-shell` as the only viable command when purity is a requirement. -If both flakes and environment purity are non-negotiable, the following pattern bridges the two: - -1. Define your `devShells` in `flake.nix`: - -```nix -{ - inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; - outputs = { self, nixpkgs }: - let - inherit (nixpkgs.lib) genAttrs systems; - eachSystem = f: genAttrs systems.flakeExposed (system: f nixpkgs.legacyPackages.${system}); - in - { - devShells = eachSystem (pkgs: { - default = pkgs.mkShell { - packages = with pkgs; [ - hello - ]; - }; - }); - }; -} -``` - -2. Expose your flake's `devShell` via `shell.nix`: - -```nix -(builtins.getFlake (toString ./.)).outputs.devShells.${builtins.currentSystem}.default -``` - -`nix-shell --pure` will now use the pinned derivation from your flake. The tradeoff is that system -tools are no longer available in the shell: - -```sh -ns --pure -git --version -# bash: git: command not found -hello -# Hello, world! -``` - -This is easily resolved by adding the tools you need to `packages` in your `mkShell`. - ---- - -### macOS Sandboxing - -Nix on macOS sets `sandbox = false` in `nix.conf` by default, because sandboxing breaks certain -functionality on Darwin. It's worth being precise about what this affects: sandboxing governs how -packages are *built*, not the purity of the environment in which you use them. The flake evaluation -purity described above is a separate concern. - -`ns` does not attempt to work around this — it is a macOS-level constraint, not something a wrapper -can meaningfully solve. In most cases, simply knowing that packages are built without sandboxing on -macOS is enough context for debugging. For example, if a package behaves differently on macOS than on -Linux, the sandbox difference is a likely culprit. You can test this hypothesis for a one-off build -with `--option sandbox true`, but enabling it permanently in `nix.conf` is not recommended as it can -cause difficult-to-diagnose failures. - ---- - ## Contributing > [!Important] diff --git a/bin/main.ml b/bin/main.ml index 35adfb5..5b30c82 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -3,7 +3,7 @@ open Ns.Cmd open Ns.Util let main - ({ installables; target_info; printcmd; force_experimental_features } : + ({ installables; target_info; printcmd; force_experimental_features; _ } : Cli.strategy) = let ({ entrypoint; attribute; subshell_dir } : Cli.target_info) = target_info in diff --git a/lib/cli.ml b/lib/cli.ml index 703b8c6..b0752b3 100644 --- a/lib/cli.ml +++ b/lib/cli.ml @@ -19,6 +19,9 @@ module Cli = struct ; target_info : target_info ; printcmd : bool ; force_experimental_features : string list option + ; sh : string + ; norc : bool + ; impure : bool } (** Positional URI arguments passed to the program via the command line. *) @@ -39,9 +42,34 @@ module Cli = struct Arg.(value & flag & info [ "force"; "f" ] ~doc) ;; + (** TODO: Check if the given arg is a dir, like /bin/zsh or /nix/store/... and if it + isn't then we should consider it a package and do the following expansion: + + $(nix-build '' -A %s --no-out-link --quiet)/bin/$(nix-instantiate '' --eval --raw -A %s.meta.mainProgram) + *) + let sh = + let doc = + "The shell to use in the subshell. Accepts a package name (e.g. zsh), a nix-store \ + path, or a local path (e.g. /bin/sh). Defaults to the current user's login shell." + in + Arg.(value & opt dirpath (Unix.shell ()) & info [ "sh" ] ~doc) + ;; + + (** TODO: Create an enum and function which matches this to add the correct arguments for the shell *) + let norc = + let doc = "Do not source shell rc files." in + Arg.(value & flag & info [ "norc" ] ~doc) + ;; + + (** TODO: Use this as the predicate to determine when to not use --pure or --ignore-env *) + let impure = + let doc = "Allow the current shell environment to bleed into the subshell." in + Arg.(value & flag & info [ "impure" ] ~doc) + ;; + (** Processes the args so that the main function knows what to do with them. *) let make_strategy = - let build original_args printcmd force_experimental_features = + let build original_args printcmd force_experimental_features sh norc impure = let installables, target_info = let default_installables = [] and default_target = @@ -111,9 +139,13 @@ module Cli = struct ; "nix-command" ] else None) + ; sh + ; norc + ; impure } in - Term.(const build $ uris $ printcmd $ force_experimental_features) + Term.( + const build $ uris $ printcmd $ force_experimental_features $ sh $ norc $ impure) ;; let cmd entrypoint = diff --git a/lib/cmd.ml b/lib/cmd.ml index f103e5b..52fe15b 100644 --- a/lib/cmd.ml +++ b/lib/cmd.ml @@ -34,6 +34,7 @@ module Cmd = struct |>+ [ "shell" ] |>+ installables |>+ Option.value ~default:[] force_experimental_features + |>+ [ "--command"; Unix.shell () ] ;; let legacy_nix_shell_from_installables installables = From adf0a8cd25afba8086c8be9c9dfce00ba4670bcc Mon Sep 17 00:00:00 2001 From: eureka-cpu Date: Mon, 9 Mar 2026 23:31:42 -0500 Subject: [PATCH 3/7] use filepath instead of dirpath --- lib/cli.ml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/cli.ml b/lib/cli.ml index b0752b3..156fa6f 100644 --- a/lib/cli.ml +++ b/lib/cli.ml @@ -52,7 +52,7 @@ module Cli = struct "The shell to use in the subshell. Accepts a package name (e.g. zsh), a nix-store \ path, or a local path (e.g. /bin/sh). Defaults to the current user's login shell." in - Arg.(value & opt dirpath (Unix.shell ()) & info [ "sh" ] ~doc) + Arg.(value & opt filepath (Unix.shell ()) & info [ "sh" ] ~doc) ;; (** TODO: Create an enum and function which matches this to add the correct arguments for the shell *) From 8769dfe543156ef6f852793da8977e3be4cbd7c8 Mon Sep 17 00:00:00 2001 From: eureka-cpu Date: Tue, 10 Mar 2026 14:23:04 -0500 Subject: [PATCH 4/7] wip get sh working --- bin/main.ml | 11 ++++++----- lib/cli.ml | 25 +++++++++---------------- lib/cmd.ml | 20 ++++++++++---------- lib/util.ml | 35 ++++++++++++++++++++++++++++++++++- 4 files changed, 59 insertions(+), 32 deletions(-) diff --git a/bin/main.ml b/bin/main.ml index 5b30c82..d857031 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -3,7 +3,7 @@ open Ns.Cmd open Ns.Util let main - ({ installables; target_info; printcmd; force_experimental_features; _ } : + ({ installables; target_info; printcmd; force_experimental_features; sh; _ } : Cli.strategy) = let ({ entrypoint; attribute; subshell_dir } : Cli.target_info) = target_info in @@ -17,19 +17,20 @@ let main | Some entrypoint -> if Unix.flake_exists_at entrypoint then - ( Cmd.nix_develop entrypoint attribute force_experimental_features + ( Cmd.nix_develop entrypoint attribute force_experimental_features sh , if Unix.shell_exists_at entrypoint - then Some (Cmd.legacy_nix_shell_from_entrypoint entrypoint attribute) + then Some (Cmd.legacy_nix_shell_from_entrypoint entrypoint attribute sh) else None ) else if Unix.shell_exists_at entrypoint - then Cmd.legacy_nix_shell_from_entrypoint entrypoint attribute, None + then Cmd.legacy_nix_shell_from_entrypoint entrypoint attribute sh, None else Error.handle_ns_error "no available devshell entrypoint: %s\n%!" entrypoint | None -> ( Cmd.nix_shell (List.map Uri.uri_to_string installables) force_experimental_features + sh , Option.map - (fun installables -> Cmd.legacy_nix_shell_from_installables installables) + (fun installables -> Cmd.legacy_nix_shell_from_installables installables sh) (Uri.combine_installables_tr installables) ) in { workdir; primary; fallback } diff --git a/lib/cli.ml b/lib/cli.ml index 156fa6f..4e402fa 100644 --- a/lib/cli.ml +++ b/lib/cli.ml @@ -20,7 +20,7 @@ module Cli = struct ; printcmd : bool ; force_experimental_features : string list option ; sh : string - ; norc : bool + ; sharg : string list ; impure : bool } @@ -42,26 +42,19 @@ module Cli = struct Arg.(value & flag & info [ "force"; "f" ] ~doc) ;; - (** TODO: Check if the given arg is a dir, like /bin/zsh or /nix/store/... and if it - isn't then we should consider it a package and do the following expansion: - - $(nix-build '' -A %s --no-out-link --quiet)/bin/$(nix-instantiate '' --eval --raw -A %s.meta.mainProgram) - *) let sh = let doc = "The shell to use in the subshell. Accepts a package name (e.g. zsh), a nix-store \ path, or a local path (e.g. /bin/sh). Defaults to the current user's login shell." in - Arg.(value & opt filepath (Unix.shell ()) & info [ "sh" ] ~doc) + Arg.(value & opt filepath (Unix.eval_sh (Unix.pw_shell ())) & info [ "sh" ] ~doc) ;; - (** TODO: Create an enum and function which matches this to add the correct arguments for the shell *) - let norc = - let doc = "Do not source shell rc files." in - Arg.(value & flag & info [ "norc" ] ~doc) + let sharg = + let doc = "Supply arguments to the shell." in + Arg.(value & opt_all string [] & info [ "sharg" ] ~doc) ;; - (** TODO: Use this as the predicate to determine when to not use --pure or --ignore-env *) let impure = let doc = "Allow the current shell environment to bleed into the subshell." in Arg.(value & flag & info [ "impure" ] ~doc) @@ -69,7 +62,7 @@ module Cli = struct (** Processes the args so that the main function knows what to do with them. *) let make_strategy = - let build original_args printcmd force_experimental_features sh norc impure = + let build original_args printcmd force_experimental_features sh sharg impure = let installables, target_info = let default_installables = [] and default_target = @@ -139,13 +132,13 @@ module Cli = struct ; "nix-command" ] else None) - ; sh - ; norc + ; sh = Unix.eval_sh sh + ; sharg ; impure } in Term.( - const build $ uris $ printcmd $ force_experimental_features $ sh $ norc $ impure) + const build $ uris $ printcmd $ force_experimental_features $ sh $ sharg $ impure) ;; let cmd entrypoint = diff --git a/lib/cmd.ml b/lib/cmd.ml index 52fe15b..e048839 100644 --- a/lib/cmd.ml +++ b/lib/cmd.ml @@ -15,33 +15,33 @@ module Cmd = struct | args -> List.fold_left (fun cmd arg -> Bos.Cmd.add_arg cmd arg) cmd args ;; - let nix_develop entrypoint attribute force_experimental_features = + let nix_develop entrypoint attribute force_experimental_features sh = Bos.Cmd.v "nix" |>+ [ "develop" ] @ [ Uri.sprintf_uri_attr_opt entrypoint attribute ] |>+ Option.value ~default:[] force_experimental_features - |>+ [ "--command"; Unix.shell () ] + |>+ [ "--ignore-env" ] + |>+ [ "--command"; sh ] ;; - let legacy_nix_shell_from_entrypoint entrypoint attribute = + let legacy_nix_shell_from_entrypoint entrypoint attribute sh = Bos.Cmd.v "nix-shell" |>+ Option.value ~default:[] (Option.map (fun attr -> [ "--attr"; attr ]) attribute) |>+ [ entrypoint ] - |>+ [ "--command"; Unix.shell () ] + |>+ [ "--pure" ] + |>+ [ "--command"; sh ] ;; - let nix_shell installables force_experimental_features = + let nix_shell installables force_experimental_features sh = Bos.Cmd.v "nix" |>+ [ "shell" ] |>+ installables |>+ Option.value ~default:[] force_experimental_features - |>+ [ "--command"; Unix.shell () ] + |>+ [ "--command"; sh ] ;; - let legacy_nix_shell_from_installables installables = + let legacy_nix_shell_from_installables installables sh = (* Need to combine/validate that the installables given are all Nixpkgs *) - Bos.Cmd.v "nix-shell" - |>+ [ "--packages" ] @ installables - |>+ [ "--command"; Unix.shell () ] + Bos.Cmd.v "nix-shell" |>+ [ "--packages" ] @ installables |>+ [ "--command"; sh ] ;; let print_strategy ({ workdir; primary; fallback } : strategy) = diff --git a/lib/util.ml b/lib/util.ml index 5b420f9..6987956 100644 --- a/lib/util.ml +++ b/lib/util.ml @@ -23,7 +23,7 @@ module Util = struct ;; (** Get the user's login shell *) - let shell () = (Unix.getpwuid (Unix.getuid ())).Unix.pw_shell + let pw_shell () = (Unix.getpwuid (Unix.getuid ())).Unix.pw_shell (** Whether a flake.nix file exists at the given directory *) let flake_exists_at dir = Sys.file_exists (Filename.concat dir "flake.nix") @@ -33,6 +33,39 @@ module Util = struct Sys.file_exists (Filename.concat dir "shell.nix") || Sys.file_exists (Filename.concat dir "default.nix") ;; + + (** Determines which shell to use. If the user gives a package + we build it and evaluate the main program to get the store path. + If there is no login shell, default to nixpkgs#bash. *) + let eval_sh sh = + let default = "bash" + and get_bin pkg = + let run_cmd cmd = + let ic = Unix.open_process_in cmd in + let result = Option.get (In_channel.input_line ic) in + (* TODO: Handle this potential error *) + let _ = Unix.close_process_in ic in + result + in + let store_path = + run_cmd (Printf.sprintf "nix-build '' -A %s --no-out-link --quiet" pkg) + and program = + run_cmd + (Printf.sprintf + "nix-instantiate '' --eval --raw -A %s.meta.mainProgram" + pkg) + in + let bin_path = Printf.sprintf "%s/bin/%s" store_path program in + bin_path + in + if sh = "/noshell" || sh = "/sbin/nologin" + then get_bin default + else ( + match String.split_on_char '/' sh with + | [ "" ] -> Error.handle_ns_error "invalid argument: shell cannot be empty\n%!" + | [ pkg ] -> get_bin pkg + | _ -> sh) + ;; end (** Types and functions for parsing, interacting and formatting URIs. *) From 46c7a654a4159c59662bd8334ea3b150e17f3580 Mon Sep 17 00:00:00 2001 From: eureka-cpu Date: Wed, 11 Mar 2026 15:44:35 -0500 Subject: [PATCH 5/7] wip rm ignore-env from develop --- lib/cli.ml | 3 ++- lib/cmd.ml | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/cli.ml b/lib/cli.ml index 4e402fa..67a9685 100644 --- a/lib/cli.ml +++ b/lib/cli.ml @@ -45,7 +45,8 @@ module Cli = struct let sh = let doc = "The shell to use in the subshell. Accepts a package name (e.g. zsh), a nix-store \ - path, or a local path (e.g. /bin/sh). Defaults to the current user's login shell." + path, or a local path (e.g. /bin/sh). Defaults to the current user's login shell \ + or bash if no user is logged in." in Arg.(value & opt filepath (Unix.eval_sh (Unix.pw_shell ())) & info [ "sh" ] ~doc) ;; diff --git a/lib/cmd.ml b/lib/cmd.ml index e048839..6f7cf9f 100644 --- a/lib/cmd.ml +++ b/lib/cmd.ml @@ -19,7 +19,6 @@ module Cmd = struct Bos.Cmd.v "nix" |>+ [ "develop" ] @ [ Uri.sprintf_uri_attr_opt entrypoint attribute ] |>+ Option.value ~default:[] force_experimental_features - |>+ [ "--ignore-env" ] |>+ [ "--command"; sh ] ;; From 705ba6a5468f44001b79b3e99d1184a31a0d1231 Mon Sep 17 00:00:00 2001 From: eureka-cpu Date: Wed, 11 Mar 2026 15:45:32 -0500 Subject: [PATCH 6/7] add default.nix --- default.nix | 1 + 1 file changed, 1 insertion(+) create mode 100644 default.nix diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..7e713e7 --- /dev/null +++ b/default.nix @@ -0,0 +1 @@ +(builtins.getFlake (toString ./.)).outputs.packages.${builtins.currentSystem}.default From fd60abf62e4f1446378348367793745986024b98 Mon Sep 17 00:00:00 2001 From: eureka-cpu Date: Wed, 11 Mar 2026 16:07:38 -0500 Subject: [PATCH 7/7] add todo --- lib/cmd.ml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/cmd.ml b/lib/cmd.ml index 6f7cf9f..2e5612b 100644 --- a/lib/cmd.ml +++ b/lib/cmd.ml @@ -15,10 +15,13 @@ module Cmd = struct | args -> List.fold_left (fun cmd arg -> Bos.Cmd.add_arg cmd arg) cmd args ;; + (* TODO: nix develop --ignore-env behaves differently than nix-shell --pure + which is causing none of the packages to show up. *) let nix_develop entrypoint attribute force_experimental_features sh = Bos.Cmd.v "nix" |>+ [ "develop" ] @ [ Uri.sprintf_uri_attr_opt entrypoint attribute ] |>+ Option.value ~default:[] force_experimental_features + (* |>+ [ "--ignore-env"; "-k"; "TERM"; "-k"; "TERMINFO"; "-k"; "HOME"; "-k"; "USER"; "-k"; "DISPLAY" ] *) |>+ [ "--command"; sh ] ;; @@ -39,7 +42,6 @@ module Cmd = struct ;; let legacy_nix_shell_from_installables installables sh = - (* Need to combine/validate that the installables given are all Nixpkgs *) Bos.Cmd.v "nix-shell" |>+ [ "--packages" ] @ installables |>+ [ "--command"; sh ] ;;