├── .gitignore ├── examples ├── .gitignore └── statedir │ └── envil-state.yaml ├── .gitattributes ├── assets ├── logo.png ├── logo.xcf └── demo.gif ├── .helix └── languages.toml ├── flake.lock ├── flake.nix ├── src ├── lib │ ├── nix-printer.nu │ ├── state.nu │ └── gen-flake.nu ├── envil-state-schema.json └── envil ├── CLAUDE.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | **/result -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | flakes/ 2 | .tmp-flake/ 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.xcf filter=lfs diff=lfs merge=lfs -text 2 | *.png filter=lfs diff=lfs merge=lfs -text 3 | *.gif filter=lfs diff=lfs merge=lfs -text 4 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:b65a4633a25dcc6234de277710db0be05426ad1126ed93bce977cce4eff638cf 3 | size 5966 4 | -------------------------------------------------------------------------------- /assets/logo.xcf: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:d12ccc094299f44e289ee4a3e383f414a641797109517d7906c68a8bc76525c1 3 | size 537764 4 | -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:7639601320481079b70ae5b9c55a3ea759a94ae3bf4cdf425956a253be4d71be 3 | size 2627201 4 | -------------------------------------------------------------------------------- /.helix/languages.toml: -------------------------------------------------------------------------------- 1 | [[language]] 2 | name = "nix" 3 | auto-format = true 4 | formatter.command = "nixfmt" 5 | 6 | # Do not use user's nushell config when starting nu as an LSP server 7 | [language-server.nu-lsp] 8 | command = "nu" 9 | args = ["-n", "--lsp"] 10 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1765270179, 6 | "narHash": "sha256-g2a4MhRKu4ymR4xwo+I+auTknXt/+j37Lnf0Mvfl1rE=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "677fbe97984e7af3175b6c121f3c39ee5c8d62c9", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "NixOS", 14 | "ref": "nixpkgs-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs" 22 | } 23 | } 24 | }, 25 | "root": "root", 26 | "version": 7 27 | } 28 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "A tool to forge custom, isolated & mergeable environments"; 3 | 4 | nixConfig = { 5 | extra-substituters = [ 6 | "https://cache.garnix.io" 7 | ]; 8 | extra-trusted-public-keys = [ 9 | "cache.garnix.io:CTFPyKSLcx5RMJKfLo5EEPUObbA78b0YQ2DTCJXqr9g=" 10 | ]; 11 | }; 12 | 13 | inputs = { 14 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 15 | }; 16 | 17 | outputs = 18 | inputs: 19 | let 20 | forEachSystem = inputs.nixpkgs.lib.genAttrs inputs.nixpkgs.lib.systems.flakeExposed; 21 | in 22 | { 23 | packages = forEachSystem ( 24 | system: 25 | let 26 | imp = builtins.mapAttrs ( 27 | _: input: input.packages.${system} or input.legacyPackages.${system} 28 | ) inputs; 29 | in 30 | rec { 31 | default = envil; 32 | 33 | envil = imp.nixpkgs.writeShellApplication { 34 | name = "envil"; 35 | runtimeInputs = with imp.nixpkgs; [ 36 | nushell 37 | jsonschema 38 | nixfmt-rfc-style 39 | ]; 40 | text = ''nu -n ${./src}/envil "$@"''; 41 | }; 42 | } 43 | ); 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /src/lib/nix-printer.nu: -------------------------------------------------------------------------------- 1 | # Literal string 2 | export def s [...args] { 3 | let args_ = $args | str join " " 4 | $"\"($args_)\"" 5 | } 6 | 7 | # Comment 8 | export def c [...args] { 9 | $args | str join " " | split row "\n" | each {$"# ($in)\n"} | str join "" 10 | } 11 | 12 | export def with [imported expr] { 13 | $"with ($imported); ($expr)" 14 | } 15 | 16 | export def inherit [--from (-f)="" n ...names] { 17 | let names_ = [$n] | append $names | str join " " 18 | $"inherit (if ($from != "") {$"\(($from))"} else {""}) ($names_);" 19 | } 20 | 21 | # Use a record as an attrset 22 | export def rec2a [--rec record={}] { 23 | mut res = if $rec {"rec {"} else {"{"} 24 | for elem in ($record | transpose k v) { 25 | $res = $"($res) ($elem.k) = ($elem.v);" 26 | } 27 | $res = $"($res) }" 28 | $res 29 | } 30 | 31 | # attrset 32 | export def a [...args] { 33 | rec2a ($args | chunks 2 | into record) 34 | } 35 | 36 | # list 37 | export def l [...args] { 38 | mut res = "[" 39 | for elem in $args { 40 | $res = $"($res) ($elem)" 41 | } 42 | $res = $"($res) ]" 43 | $res 44 | } 45 | 46 | export def let_ [defs scope] { 47 | mut $res = "let" 48 | for elem in ($defs | chunks 2) { 49 | $res = $"($res) ($elem.0) = ($elem.1);" 50 | } 51 | $res = $"($res) in ($scope)" 52 | $res 53 | } 54 | 55 | # Raw space-separated string concatenation, like for function calls 56 | export def r [...args] { 57 | $args | str join " " 58 | } 59 | 60 | # Same than 'r', but wraps the expression in parentheses 61 | export def p [...args] { 62 | let args_ = $args | str join " " 63 | $"\(($args_))" 64 | } 65 | 66 | # (Nested) lambda(s) 67 | export def f [...args] { 68 | mut res = "(" 69 | mut first = true 70 | for arg in $args { 71 | let colon = if $first {""} else {": "} 72 | $res = $"($res)($colon)($arg)" 73 | $first = false 74 | } 75 | $"($res)\)" 76 | } -------------------------------------------------------------------------------- /examples/statedir/envil-state.yaml: -------------------------------------------------------------------------------- 1 | ## This is an example of how to define an envil set of environments, 2 | ## each one with its set of packages (tools) 3 | 4 | includes: 5 | [] # You can refer to a list of other statedirs that will be included in the state. 6 | # If this yaml defines envs or inputs that are named the same than 7 | # inputs/envs defined by an imported statedir, the ones here will 8 | # override the imported ones. 9 | # Relative paths in "includes" are considered relative to this file 10 | inputs: # 'inputs' are "package sources" in nix flakes parlance 11 | pkgs: # Here we just give a name to an input 12 | "github:NixOS/nixpkgs/nixpkgs-unstable" 13 | # ^^ Here we tell envil & Nix where to fetch it. This is a nix flake URL 14 | nushellWith: 15 | "github:YPares/nushellWith": # A flake URL like above, but with some extra config 16 | nixpkgs: pkgs 17 | # ^^ This is a "follows" link. It means that the 'nushellWith' input 18 | # has a 'nixpkgs' input, that we ask to follow our own 'pkgs' input declared just above, 19 | # instead of using its own locked version 20 | envs: 21 | nix: # This declares an environment and names it 22 | contents: # Which packages should be provided by the environment when it is activated 23 | pkgs: # A package source (from 'inputs') 24 | - nil # Which packages to use from that source 25 | - nixfmt-classic 26 | - cachix 27 | description: # (optional) A quick comment about what the env is for 28 | "Tools to work with nix code in vscode" 29 | devops: 30 | description: "Tools to work with kube" 31 | extends: # This is how an environment "inherits" from others, so you 32 | # don't have to repeat yourself 33 | - nix 34 | contents: 35 | pkgs: 36 | - kind 37 | - k9s 38 | - kubectl 39 | - kubernetes-helm 40 | - jsonnet 41 | nushell: 42 | contents: 43 | nushellWith: 44 | - nushellWithStdPlugins 45 | pkgs: 46 | - jq 47 | vcs: 48 | description: "git & jj" 49 | contents: 50 | pkgs: 51 | - git 52 | - jujutsu 53 | vscode-bins: 54 | description: "Tools needed by some VSCode extensions" 55 | extends: 56 | - vcs 57 | - nix 58 | - nushell 59 | node: 60 | description: "Tools for JS & TS" 61 | contents: 62 | pkgs: 63 | - nodePackages: # A shortcut (every package nested here is under the "nodePackages" attr) 64 | - npm 65 | - yarn 66 | - typescript 67 | - typescript-language-server 68 | -------------------------------------------------------------------------------- /src/envil-state-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$id": "https://github.com/YPares/envil/tree/master/src/envil-state-schema.json", 4 | "title": "envil-state-schema", 5 | "type": "object", 6 | "properties": { 7 | "includes": { 8 | "type": "array", 9 | "items": { 10 | "type": "string" 11 | } 12 | }, 13 | "inputs": { 14 | "type": "object", 15 | "additionalProperties": false, 16 | "patternProperties": { 17 | "^[A-Za-z][A-Za-z0-9\\-_]*$": { 18 | "oneOf": [ 19 | { 20 | "type": "string" 21 | }, 22 | { 23 | "type": "object", 24 | "additionalProperties": { 25 | "type": "object", 26 | "minProperties": 1, 27 | "additionalProperties": { 28 | "type": "string" 29 | } 30 | } 31 | } 32 | ] 33 | } 34 | } 35 | }, 36 | "envs": { 37 | "type": "object", 38 | "additionalProperties": false, 39 | "patternProperties": { 40 | "^[A-Za-z][A-Za-z0-9\\-_]*$": { 41 | "type": "object", 42 | "properties": { 43 | "description": { 44 | "type": "string" 45 | }, 46 | "contents": { 47 | "type": "object", 48 | "additionalProperties": { 49 | "type": "array", 50 | "items": { 51 | "oneOf": [ 52 | { 53 | "type": "string" 54 | }, 55 | { 56 | "type": "object", 57 | "minProperties": 1, 58 | "maxProperties": 1 59 | } 60 | ] 61 | } 62 | } 63 | }, 64 | "extends": { 65 | "type": "array", 66 | "items": { 67 | "type": "string" 68 | } 69 | } 70 | }, 71 | "anyOf": [ 72 | { 73 | "required": [ 74 | "contents" 75 | ] 76 | }, 77 | { 78 | "required": [ 79 | "extends" 80 | ] 81 | } 82 | ], 83 | "additionalProperties": false 84 | } 85 | } 86 | } 87 | }, 88 | "required": [ 89 | "inputs", 90 | "envs" 91 | ], 92 | "additionalProperties": false 93 | } -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to AI agents when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | `envil` is a Nix-based environment management tool written in Nushell that manages environment "stacks" - composable, isolated toolkits of executables. It bridges Nix's power with YAML simplicity, allowing non-Nix users to benefit from reproducible environments while supporting Nix flakes directly. 8 | 9 | **Key concept**: Environments are managed as a _stack_. Multiple environments can be activated simultaneously, and they can extend/include one another. 10 | 11 | ## Architecture 12 | 13 | ### Core Components 14 | 15 | - **src/envil**: Main CLI entrypoint with all subcommands (shell, push, pop, switch, toggle, show, update, etc.) 16 | - **src/lib/state.nu**: State management - handles loading/saving environment configurations from yaml statedirs or flake statedirs, manages the "currents" (current stack state stored in `~/.envil/currents.nuon`) 17 | - **src/lib/gen-flake.nu**: Flake generation logic - converts yaml environment definitions into Nix flakes with proper `buildEnv` calls 18 | - **src/lib/nix-printer.nu**: DSL for generating Nix expressions from Nushell (functions like `r`, `a`, `f`, `s`, `l` for records, attrs, functions, strings, lists) 19 | - **src/envil-state-schema.json**: JSON Schema validating `envil-state.yaml` files (uses `jsonschema` CLI tool) 20 | 21 | ### Statedir Types 22 | 23 | 1. **Yaml statedir**: Contains `envil-state.yaml`. `envil` generates a `flakes/` subfolder with individual `flake.nix` + `flake.lock` per environment 24 | 2. **Flake statedir**: Contains `flake.nix`. `envil` treats `packages..*` attributes as environments (read-only mode) 25 | 3. **Remote flake URL**: Directly usable as statedir (e.g., `github:user/repo`) 26 | 27 | If both files exist in a directory, the flake takes precedence. 28 | 29 | ### Environment Resolution 30 | 31 | Environments support `extends` to inherit from other environments. The flake generator (gen-flake.nu) performs BFS traversal to resolve dependencies and generate nested `buildEnv` calls for all extended environments. 32 | 33 | ## Development Commands 34 | 35 | ### Running envil Locally 36 | 37 | ```nu 38 | # Run envil from the repository (but you'll need to have pre-installed the flake-packaged deps) 39 | nu src/envil 40 | 41 | # Or use nix run (better) 42 | nix run ".#envil" -- 43 | ``` 44 | 45 | ### Testing with Example Statedir 46 | 47 | ```nu 48 | # Use the example statedir to test functionality 49 | nix run ".#envil" -- shell -d examples/statedir 50 | nix run ".#envil" -- push -d examples/statedir 51 | ``` 52 | 53 | ### Linting & Formatting 54 | 55 | The flake includes `nixfmt-rfc-style` for formatting generated Nix code (applied automatically in gen-flake.nu:154). 56 | 57 | ## Key Implementation Details 58 | 59 | ### State Management (`~/.envil/`) 60 | 61 | - `~/.envil/currents.nuon`: Stores the current environment stack and active statedir 62 | - `~/.envil/current/`: Nix profile managed by `nix-env` containing binaries of active environments 63 | - Stack operations (`push`, `pop`, `toggle`) modify both the profile and the currents file atomically 64 | 65 | ### Nix Integration 66 | 67 | - Uses `nix-env` (not `nix profile`) for stack management because `--remove-all` flag availability 68 | - Flakes are generated with `builtins.getFlake` for loading inputs 69 | - Garbage collection: old generations are kept with `--delete-generations '+5'` 70 | - Binary caching supported via `cachix push` or `nix copy` 71 | 72 | ### Subshells 73 | 74 | - `envil shell` uses `nix shell` to create temporary environments 75 | - Exports `$SHELL_ENV` variable for prompt integration 76 | - Supports `--isolated` mode (standard UNIX paths only) and `--no-stack` (hide current stack) 77 | - Nested subshells are explicitly not supported (checked at runtime) 78 | 79 | ### YAML Schema Validation 80 | 81 | On every yaml statedir load, `jv` (jsonschema CLI) validates against `envil-state-schema.json`. Invalid files cause immediate errors with detailed output. 82 | 83 | ## Common Patterns 84 | 85 | ### Adding a New Subcommand 86 | 87 | 1. Add `def "main "` in `src/envil` 88 | 2. Follow the pattern of existing commands for state loading (`get-state $statedir`) 89 | 3. Use `select-envs` for interactive environment selection with the `--verb` parameter 90 | 4. Call `set-currents` to persist stack/statedir changes 91 | 5. Use `do-switch` or `do-remove` for Nix profile modifications 92 | 93 | ### Working with Nix Expressions 94 | 95 | Use the DSL in `nix-printer.nu`: 96 | - `r ...`: record/function call 97 | - `a ...`: attribute set 98 | - `f `: lambda function 99 | - `s `: string literal 100 | - `l ...items`: list 101 | - `rec2a `: convert Nushell record to Nix attrset 102 | 103 | ### Handling Follow Links 104 | 105 | Input "follows" (forcing inputs to share dependencies) are represented as nested records in yaml: 106 | ```yaml 107 | inputs: 108 | myinput: 109 | "github:user/repo": 110 | nixpkgs: pkgs # follow link: myinput's nixpkgs follows our 'pkgs' input 111 | ``` 112 | 113 | Parsed in gen-flake.nu:115-122 into `inputs..inputs..follows` Nix syntax. 114 | 115 | ## Updating envil 116 | 117 | Users update with: `nix profile upgrade envil --refresh` 118 | 119 | The flake uses garnix.io for CI/binary cache (see README badge and `nixConfig` in flake.nix). 120 | -------------------------------------------------------------------------------- /src/lib/state.nu: -------------------------------------------------------------------------------- 1 | export const nixpkgs_input = {nixpkgs: "github:NixOS/nixpkgs/nixpkgs-unstable"} 2 | 3 | const defstate = { 4 | inputs: $nixpkgs_input 5 | envs: { 6 | basic: { 7 | description: "Just a basic env" 8 | contents: { 9 | nixpkgs: [hello] 10 | } 11 | } 12 | } 13 | } 14 | 15 | export def envil-dir []: nothing -> path { 16 | if ($env.ENVIL_STACK? != null) { 17 | $env.ENVIL_STACK | path expand -n 18 | } else { 19 | [~ .envil] | path join | path expand -n 20 | } 21 | } 22 | 23 | def currents-path []: nothing -> path { 24 | mkdir (envil-dir) 25 | [(envil-dir) currents.nuon] | path join 26 | } 27 | 28 | export def with-resolved-statedir [ 29 | unresolved_statedir: string = "" 30 | --on-flake-url: closure 31 | --on-flake-state: closure 32 | --on-yaml-state: closure 33 | ] { 34 | let statedir = if $unresolved_statedir == "" { 35 | try { 36 | open (currents-path) | get statedir 37 | } catch { 38 | print $"(ansi red)No statedir is known. Run with `-d' to use or create a statedir(ansi reset)" 39 | error make {msg: "No statedir"} 40 | } 41 | } else { 42 | $unresolved_statedir 43 | } 44 | if ($statedir | str contains ":") { 45 | # statedir is already a flake URL 46 | do $on_flake_url $statedir 47 | } else if ($statedir | path join "flake.nix" | path exists) { 48 | # statedir is a path to a local flake 49 | do $on_flake_state $statedir 50 | } else { 51 | # statedir is yaml 52 | do $on_yaml_state $statedir 53 | } 54 | } 55 | 56 | export def get-state [ 57 | unresolved_statedir: string 58 | --should-exist 59 | ]: nothing -> record { 60 | ( with-resolved-statedir $unresolved_statedir 61 | --on-flake-url {|statedir| 62 | load-state-from-flake $statedir 63 | } 64 | --on-flake-state {|statedir| 65 | # We first resolve the full flake URL, because builtins.getFlake doesn't accept relative paths: 66 | let statedir = ^nix flake metadata $statedir --json | from json | get originalUrl 67 | load-state-from-flake $statedir 68 | } 69 | --on-yaml-state {|statedir| 70 | load-state-from-yaml ($statedir | path expand) $should_exist 71 | } 72 | ) 73 | } 74 | 75 | def load-state-from-flake [ 76 | statedir: string 77 | ] { 78 | let packages = ( 79 | ^nix eval --impure --json --expr 80 | $"with builtins; mapAttrs \(_k: pkg: pkg.name or \"no description\") \(getFlake \"($statedir)\").packages.${currentSystem}" 81 | ) | from json 82 | { 83 | inputs: { 84 | source: $statedir 85 | } 86 | envs: ($packages | columns | each {|attr| 87 | { 88 | $attr: { 89 | description: ($packages | get $attr) 90 | contents: { 91 | source: 92 | [$attr] 93 | } 94 | } 95 | } 96 | } | into record) 97 | statedir_is_flake: true 98 | statedir: $statedir 99 | } 100 | } 101 | 102 | def load-state-from-yaml [ 103 | statedir: string 104 | should_exist 105 | ]: nothing -> record { 106 | if (not ($statedir | path exists)) { 107 | if $should_exist { 108 | error make {msg: $"Dir ($statedir) does not exist"} 109 | } else { 110 | mkdir $statedir 111 | } 112 | } 113 | let statefile = [$statedir envil-state.yaml] | path join 114 | mut state = try { 115 | open $statefile 116 | } catch { 117 | print $"(ansi yellow)No envil-state.yaml found in the statedir. Generating `($statefile)'(ansi reset)" 118 | $defstate | save $statefile 119 | $defstate 120 | } 121 | 122 | let schema_path = [($env.CURRENT_FILE | path dirname) envil-state-schema.json] | path join 123 | let jv_out = ^jv $schema_path $statefile | complete 124 | if $jv_out.exit_code != 0 { 125 | print $"(ansi red)($statefile) is not a valid envil state file:(ansi reset)" 126 | print $"(ansi red)|(ansi reset)" 127 | print ($jv_out.stdout | lines | each { $"(ansi red)|(ansi reset) ($in)" } | str join (char newline)) 128 | print "" 129 | error make {msg: "Failed to validate state file"} 130 | } 131 | 132 | let includes = $state.includes? 133 | for otherdir in $includes { 134 | # Resolve otherdir relative to current $statedir 135 | let otherdir = do { 136 | cd $statedir 137 | $otherdir | path expand 138 | } 139 | let other = get-state $otherdir --should-exist 140 | $state = { 141 | inputs: ($other.inputs? | or-else {} | merge ($state.inputs? | or-else {})) 142 | envs: ($other.envs? | or-else {} | merge ($state.envs? | or-else {})) 143 | } 144 | } 145 | $state | insert statedir $statedir 146 | } 147 | 148 | export def set-currents [ 149 | --envstack: any = null 150 | --statedir: any = null 151 | ] { 152 | mut currents = get-currents 153 | if ($envstack != null) { 154 | $currents.envstack = $envstack 155 | } 156 | if ($statedir != null) { 157 | $currents.statedir = $statedir 158 | } 159 | $currents | save -f (currents-path) 160 | } 161 | 162 | export def get-currents [] { 163 | try { 164 | open (currents-path) 165 | } catch { 166 | {} 167 | } | 168 | upsert envstack {or-else []} | 169 | update envstack {upsert active {or-else true}} 170 | } 171 | -------------------------------------------------------------------------------- /src/lib/gen-flake.nu: -------------------------------------------------------------------------------- 1 | use nix-printer.nu * 2 | use state.nu nixpkgs_input 3 | 4 | export def or-else [defval] { 5 | if $in == null {$defval} else {$in} 6 | } 7 | 8 | def get-input-pkgs [prefix pkg_list] { 9 | $pkg_list | each {|p| 10 | if (($p | describe) == "string") { 11 | if ($p | str contains " ") { 12 | $"\(\(with ($prefix); ($p)))" 13 | } else { 14 | $"($prefix).($p)" 15 | } 16 | } else { # $p is a record 17 | let attr = $p | columns | get 0 18 | let vals = $p | values | get 0 19 | (get-input-pkgs $"($prefix).($attr)" $vals) 20 | } 21 | } | flatten 22 | } 23 | 24 | def gen-one-env [env_name env_desc]: nothing -> record, output: string, extends: list> { 25 | mut env_inputs = [] 26 | mut env_paths = [] 27 | for i in ($env_desc.contents? | default [] | transpose name pkgs) { 28 | $env_inputs = $env_inputs | append $i.name 29 | $env_paths = $env_paths | append (get-input-pkgs $"imp.($i.name)" $i.pkgs) 30 | } 31 | for i in $env_desc.extends? { 32 | $env_paths = $env_paths | append $"envs.($i)" 33 | } 34 | let output = (r 35 | buildEnv (a 36 | name (s $"($env_name)-envil") 37 | paths (l ...$env_paths) 38 | ) 39 | ) 40 | { 41 | inputs: $env_inputs 42 | output: $output 43 | extends: ($env_desc.extends? | or-else []) 44 | } 45 | } 46 | 47 | # Prints the flake part for $env_name from the data obtained from envil state 48 | export def output-flake [envname systems nixpkgs_key inputs outputs] { 49 | (r 50 | (c "This has been generated by `envil'\nDO NOT EDIT MANUALLY") 51 | (a 52 | description (s $"envil-generated flake for env ($envname)") 53 | inputs (rec2a $inputs) 54 | outputs (f inputs 55 | (let_ 56 | [forEachSystem (r $"inputs.($nixpkgs_key).lib.genAttrs" $systems)] 57 | (a packages (r forEachSystem (f system 58 | (let_ 59 | [imp (r (c "An attrset containing each input's packages for the current system") 60 | builtins.mapAttrs 61 | (f _ input 62 | "input.packages.${system} or input.legacyPackages.${system}") 63 | inputs) 64 | buildEnv (r (c "A function to make an environment") 65 | $"imp.($nixpkgs_key).buildEnv") 66 | envs (r (c $"Each environment used by env ($envname)") 67 | (rec2a $outputs))] 68 | envs)))))))) 69 | } 70 | 71 | # Prints out the flake that will generate the environment 72 | export def generate-flake [ 73 | envname: string 74 | state: record 75 | systems: list = [] 76 | ] { 77 | # We do a BFS through the existing envs to resolve the `extends', 78 | # and recursively generate a buildEnv target for each extended env: 79 | mut to_do = [$envname] 80 | mut already_done = {} 81 | mut generated_envs = {} 82 | while (not ($to_do | is-empty)) { 83 | let cur_name = $to_do | first 84 | $to_do = $to_do | skip 1 85 | let cur_env = try { 86 | $state.envs | get $cur_name 87 | } catch { 88 | print $"(ansi red)Env `($cur_name)' does not exist in statedir (ansi yellow)`($state.statedir)'(ansi reset)" 89 | error make {msg: $"Env `($cur_name)' not found"} 90 | } 91 | mut cur_done = gen-one-env $cur_name $cur_env 92 | let extends = $cur_done.extends 93 | $cur_done = $cur_done | reject extends 94 | $generated_envs = $generated_envs | insert $cur_name $cur_done 95 | for i in $extends { 96 | if (not ($i in $already_done) and not ($i in $to_do)) { 97 | $to_do = $to_do | append $i 98 | } 99 | } 100 | $already_done = $already_done | upsert $cur_name true 101 | } 102 | 103 | # Make a table with 3 columns: 'name', 'inputs' & 'output': 104 | let $envs_table = $generated_envs | transpose name _contents | flatten 105 | 106 | mut input_urls = {} 107 | mut input_follow_links = {} 108 | for i in ($envs_table | each {$in.inputs} | flatten | uniq) { 109 | mut url = try { 110 | $state.inputs | get $i 111 | } catch {|e| 112 | print $"(ansi red)Input `($i)' is not defined in the `inputs' section(ansi reset)" 113 | error make $e 114 | } 115 | if (($url | describe) | str starts-with "record") { 116 | # $input_url is a record `{"": [followed_input0, followed_input1, ...]}' 117 | let actual_url = $url | columns | get 0 118 | for follow_link in ($url | values) { 119 | $input_follow_links = $input_follow_links | upsert $i {or-else {} | merge $follow_link} 120 | } 121 | $url = $actual_url 122 | } 123 | # else $input_url is already just an URL string 124 | $input_urls = $input_urls | insert $i $url 125 | } 126 | 127 | let nixpkgs_key = match ($input_urls | transpose key url | where {$in.url | str downcase | str starts-with "github:nixos/nixpkgs"}) { 128 | [] => { 129 | $input_urls = $input_urls | insert "nixpkgs" $nixpkgs_input.nixpkgs 130 | "nixpkgs" 131 | } 132 | [$i] => $i.key 133 | [$i ...$_rest] => $i.key 134 | } 135 | 136 | let systems = if ($systems | is-empty) { 137 | $"inputs.($nixpkgs_key).lib.systems.flakeExposed" 138 | } else { 139 | (l ...($systems | each {s $in})) 140 | } 141 | 142 | mut inputs = {} 143 | for i in ($input_urls | transpose key url) { 144 | $inputs = $inputs | insert $"($i.key).url" (s $i.url) 145 | if ($i.key in $input_follow_links) { 146 | for l in ($input_follow_links | get $i.key | transpose src dest) { 147 | $inputs = $inputs | insert $"($i.key).inputs.($l.src).follows" (s $l.dest) 148 | } 149 | } 150 | } 151 | 152 | let $outputs = $envs_table | each {[$in.name $in.output]} | into record 153 | 154 | output-flake $envname $systems $nixpkgs_key $inputs $outputs | ^nixfmt 155 | } 156 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![built with garnix](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Fgarnix.io%2Fapi%2Fbadges%2FYPares%2Fenvil%3Fbranch%3Dmaster)](https://garnix.io/repo/YPares/envil) 2 | 3 | # envil 4 | 5 | 6 | 7 | `envil` is a tool to: 8 | 9 | - describe a set of environments ("toolkits") that contain executables you regularly want to access and hide rapidly (see for instance [here](./examples/statedir/envil-state.yaml)), 10 | - install and switch on/off such environments globally in an environment stack, or conversely: 11 | - start a subshell with a specific set of environments locally activated. 12 | 13 | `envil` does so by using [Nix](https://nixos.org), and by inspecting or creating _Nix flakes_ on the fly. 14 | In the Nix ecosystem, a _flake_ is a file that describes a set of programs (the flake _outputs_) for various possible systems, 15 | and how to download and build those programs and their dependencies (the flake _inputs_). Nix can then install those programs in an isolated store, 16 | ie. without risking potential conflicts with pre-existing tools on your system. Nix being a simple yet generic and powerful programming language, 17 | some seemingly simple use cases (such as listing a fixed set of pre-existing packages in a flake and installing them) may _appear_ more complex 18 | to tackle than they need to. 19 | 20 | `envil` targets some of those simple Nix use cases. It aims at providing people who are not regular Nix users a quick way to start with custom, 21 | isolated & reproducible (ie. "rebuildable identically elsewhere") environments, one of the major reasons to use Nix. 22 | 23 | It is important to note that `envil` is **not** a tool to: 24 | 25 | - install and manage Nix for you, 26 | - write complicated Nix logic for you. 27 | 28 | If you already know and use Nix, `envil` builds upon Nix profiles, to enable you and incite you to be selective about what you put in your `PATH`, to 29 | quickly switch between environments, and to start shells to avoid situations where you end up with two different versions 30 | of the same tool in your `PATH`, or things like two different `python` installations but each one configured with its own libraries, 31 | leaving you unable to select which one you want. 32 | 33 | `envil` has been developped with cooperation between Nix-users and non Nix-users in mind, notably in development teams where some people would 34 | want to introduce Nix to provision their local development environment(s) without too much friction. 35 | This is why `envil` introduces a simple and versionable yaml environment description, but also directly supports any flake as a package source, 36 | and also why it operates by outputting regular Nix flakes that are locked and versionable too. 37 | 38 | ![Demo GIF](./assets/demo.gif) 39 | 40 | ## Setup 41 | 42 | To install `envil`, first you need to [install Nix](https://determinate.systems/nix/). Then just do: 43 | 44 | ```sh 45 | nix profile install github:YPares/envil#envil 46 | ``` 47 | 48 | to have `envil` available in your `PATH`. Alternatively, you can run `nix run github:YPares/envil` everytime you want to use `envil`. 49 | 50 | Finally, add `$HOME/.envil/current/bin` to your `PATH`. This is the directory in which envil will manage a Nix profile for you, 51 | by swapping in and out the binaries of your current environment(s). 52 | 53 | Note: if you are using Linux, it's better to set your `PATH` in your `$HOME/.pam_environment` or `$HOME/.profile` so other programs than your terminal can see the updated `PATH`. 54 | Don't forget to log out and back in after. 55 | 56 | ## Usage 57 | 58 | `envil` manages environments as a _stack_. That means several envs can be activated at the same time. The main commands 59 | are `envil push` and `envil pop` to add or remove envs from the stack, and `envil show` to view the stack's current state. 60 | 61 | The second important concept is the notion of _statedir_. That is some directory that contains a configuration file that 62 | describes the desired _state_ of your environments. This file can be one of the following two: 63 | 64 | - either an `envil-state.yaml` ("yaml statedir"): `envil` will generate a flake for each environment inside the statedir 65 | (in the `flakes` subfolder). Each env has its own flake, so envs are locked separately. 66 | - or a `flake.nix` ("flake statedir"): `envil` will look at the attributes exported under `packages.` and 67 | consider each one to be an environment. All the envs are in the same flake, so envs are locked together. 68 | 69 | Therefore any regular Nix flake is directly usable as a statedir, and in such case `envil` will use it as is, in a readonly 70 | fashion, so it's up to you to write it and update its lockfile as you see fit. This allows to use remote flakes too (e.g. from Github) 71 | without having to pre-clone them locally. On the other hand, _yaml_ statedirs must be local, writeable directories. 72 | 73 | Note that if a statedir contains both files, `envil` will use the `flake.nix` and ignore the `envil-state.yaml`. 74 | 75 | Most `envil` subcommands take a `-d` argument to select which statedir to operate with (regardless of whether it is a yaml or flake statedir), 76 | and register it as the _current_ statedir so you don't have to repeat it everytime. 77 | 78 | If the folder given to `-d` does not exist or is empty, `envil` will create it with a default `envil-state.yaml` that you can then edit. 79 | 80 | Each environment present in your stack can come from a different statedir, thus allowing decomposition. You can for instance 81 | have your own personal statedir for tools only you use, install tools from public flakes on Github, and then have another statedir that is part of a git project 82 | and used by all the developers of that project. Nix `flake.lock` files ensure that all teammembers will use the exact same version 83 | of the same tools, and Nixpkgs `buildEnv` function will make sure you do not have conflicts (different executables with the same name) in your stack. 84 | 85 | Run `envil -h` to see all the commands available. For instance, if you clone that repository and `cd` into your local clone, 86 | you can run the following: 87 | 88 | - `envil shell -d examples/statedir`: show all the envs defined in the example statedir (`-d`) and select some of them. 89 | Then open those envs in a subshell. Register `examples/statedir` as the current statedir 90 | - `envil switch -d examples/statedir`: select several environments in the statedir, and then replace the whole stack with them. 91 | Register `examples/statedir` as the current statedir. 92 | - `envil push`: select an env from the current statedir (no `-d` was given) to add to the top of the env stack. 93 | Globally add to your `PATH` the executables it contains 94 | - `envil pop`: remove the last env added to the stack 95 | - `envil toggle`: toggle on/off some environments in the stack (same effect than push/pop, but it's easier to reactivate them afterwards) 96 | - `envil show` (or just `envil`): show the current statedir, the envs in the stack, the bins in the PATH, and the envs 97 | activated in the current subshell (if any). The source (statedir of origin) of each environment is also indicated, with a flake URL if the 98 | statedir is a Nix flake, and a regular absolute path if it is a yaml statedir 99 | - `envil update`: update the flake.lock files for some environments in the current statedir. This is only for yaml statedirs. 100 | This is the command to use if you want to generate the flakes for a yaml statedir ahead of time, without activating any env 101 | - `envil switch -r`: reload the current stack (for instance if you used `envil update` previously) 102 | 103 | Subshells started by `envil` export the `$SHELL_ENV` environment variable. You can use it in your shell prompt (eg. `PS1` for `bash`) so it shows 104 | which env(s) is (are) activated in the subshell. For instance if you use `bash`, add the following to your `.bashrc`: 105 | 106 | ```bash 107 | if [[ -n "$SHELL_ENV" || "$SHLVL" > 1 ]]; then 108 | shell_env_bit='\e[0;33m[$SHELL_ENV($SHLVL)]\e[0m' 109 | fi 110 | 111 | PS1="${shell_env_bit}...the rest of your prompt..." 112 | ``` 113 | 114 | (`$SHLVL` is a standard `bash` environment variable telling you how many levels of subshells you are currently in) 115 | 116 | ## Updating `envil` 117 | 118 | Run `nix profile upgrade envil --refresh` to update `envil` to the latest version. 119 | 120 | ## Pushing your current stack to a binary cache 121 | 122 | If you use `cachix` and if you set up a cache on , run: 123 | 124 | ```sh 125 | cachix push $HOME/.envil/current 126 | ``` 127 | 128 | Anybody who runs `cachix use ` and switches to the same env(s) than you will benefit from the binary cache. 129 | 130 | If you are using an [external store](https://nix.dev/manual/nix/2.25/store/types/) as a binary cache, you can use `nix copy`: 131 | 132 | ```sh 133 | nix copy $HOME/.envil/current --to "" 134 | ``` 135 | 136 | ## Notes about versioning and workflow 137 | 138 | In each statedir containing an `envil-state.yaml`, `envil` will generate a `flakes` folder, itself containing a `flake.nix` and `flake.lock` for each env. 139 | These are meant to be added to your version control system, so that any person using these envs too will have the exact 140 | same tool versions you do. 141 | 142 | Conversely, the contents of your `$HOME/.envil` are _not_ meant to be versioned. They represent the state of your _own_ stack, 143 | which is meant to be transient. If you often end up activating the exact same set of envs, you can declare a new env 144 | in your statedir that will _extend_ from them, ie. merging them all together, to make things more convenient. 145 | Statedirs can also _include_ one another to create envs that extend from envs from different statedirs. 146 | 147 | You have two possible workflows with `envil`: 148 | 149 | - Decomposition: smaller and separated envs, with frequent use of `envil push` and `pop` 150 | - Composition: bigger environments that _extend_ one another, with frequent use of `envil switch` 151 | 152 | ## Related tools & philosophy 153 | 154 | `envil` is related to [`niv`](https://github.com/nmattia/niv), [`devenv`](https://devenv.sh/), [`devbox`](https://www.jetify.com/docs/devbox/), 155 | [`flox`](https://flox.dev/), [`flakey-profile`](https://github.com/lf-/flakey-profile) and 156 | [`home-manager`](https://github.com/nix-community/home-manager) but with a focus on: 157 | 158 | - usability by people who do not write or write little Nix code; 159 | - compatibility with existing Nix tools, and no disruption of your existing Nix installation and configuration; 160 | - reusable and composable environments, meaning that: 161 | - any env can extend (or import, include, whatever you prefer) other envs, 162 | - statedirs can be imported and included into one another, 163 | - you can have several environments activated at the same time; 164 | - production of regular and (as much as possible) idiomatic Nix flakes that do not require `--impure`. 165 | 166 | Also, `envil` strongly encourages decomposition. If you write Nix code, then writing small & local Nix flakes to 167 | then reuse them in `envil` envs is perfectly encouraged. `envil` will not write complicated Nix logic for you, 168 | just the classic boilerplate needed to define a top-level flake with some `pkgs.buildEnv` calls. 169 | 170 | Contrary to `nix profile`, `envil` will not do anything to track versions of environments via a history. 171 | Given it represents its configuration as a simple yaml file or as flakes, versioning can just be done with `git`. 172 | -------------------------------------------------------------------------------- /src/envil: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env nu 2 | 3 | use lib/gen-flake.nu * 4 | use lib/state.nu * 5 | 6 | $env.config.color_config = { 7 | duration: default 8 | string: default 9 | cell-path: default 10 | binary: default 11 | separator: default 12 | list: default 13 | record: default 14 | float: default 15 | range: default 16 | int: default 17 | nothing: default 18 | block: default 19 | } 20 | 21 | def main [] { 22 | main show 23 | } 24 | 25 | def select-envs [ 26 | preselected: list 27 | unique: bool 28 | state: record 29 | --verb: string = "use" 30 | ]: nothing -> table { 31 | if ($preselected | is-empty) { 32 | let statedir_bit = if $state.statedir? != null { 33 | $" \(from (ansi yellow)`($state.statedir)'(ansi reset)) " 34 | } else { 35 | " " 36 | } 37 | let prompt = $"Select the env($statedir_bit)to ($verb):" 38 | let prompt_multi = $"Select the envs($statedir_bit)to ($verb) \(Use to \(de)select one, to \(de)select all)" 39 | 40 | $state.envs | 41 | transpose name desc | 42 | flatten | 43 | select name description? | 44 | update description {|e| 45 | let desc_bit = if $e.description? != null { 46 | $" (ansi grey)\(($e.description))(ansi reset)" 47 | } else { 48 | "" 49 | } 50 | $"($e.name)($desc_bit)" 51 | } | 52 | sort-by -i name | 53 | if $unique { 54 | input list -d description -f $prompt | 55 | if ($in == null) {[]} else {[$in]} 56 | } else { 57 | input list -d description -m $prompt_multi | 58 | default [] 59 | } | 60 | get name 61 | } else { 62 | $preselected 63 | } | 64 | wrap name | 65 | insert source $state.statedir? | 66 | insert active true 67 | } 68 | 69 | # Print out the flake that will generate an env 70 | def "main flake" [ 71 | envname: string = "" 72 | --statedir (-d): string = "" # Where to read the envil state from 73 | # 74 | # If empty, will use the statedir of the current stack 75 | --systems (-s): list = [] 76 | # Which systems should this flake build for. The flake will use `nixpkgs.lib.systems.flakeExposed' if is left empty 77 | ] { 78 | let state = get-state $statedir 79 | let envname = if ($envname == "") { 80 | select-envs [] true $state --verb "create a flake for" | get 0.name 81 | } else { 82 | $envname 83 | } 84 | generate-flake $envname $state $systems 85 | } 86 | 87 | def do-update [flakepath: path] { 88 | try { 89 | ^nix flake update --flake $flakepath --refresh 90 | } catch { 91 | # We may be on an older version of nix where the --flake arg does not exist yet 92 | ^nix flake update $flakepath --refresh 93 | } 94 | } 95 | 96 | def write-flakes [ 97 | environments: table 98 | ] : nothing -> table { 99 | $environments | each {|env_| 100 | if ($env_.active? != false) { 101 | let state = get-state $env_.source 102 | if $state.statedir_is_flake? == true { 103 | {envname: $env_.name, flakepath: $env_.source} 104 | } else { 105 | let flakepath = [$env_.source flakes $env_.name] | path join 106 | mkdir $flakepath 107 | generate-flake $env_.name $state | save -rf ([$flakepath flake.nix] | path join) 108 | if (not ([$flakepath flake.lock] | path join | path exists) or $env_.update? == true) { 109 | do-update $"path:($flakepath)" 110 | } 111 | {envname: $env_.name, flakepath: $"path:($flakepath)"} 112 | } 113 | } 114 | } 115 | } 116 | 117 | # Temporarily activate some envs by starting a subshell 118 | def "main shell" [ 119 | --unique (-u) # Whether to select just one environment (in which case the env picker will come with a searchbar) 120 | --statedir (-d): string = "" # Where to read the envil state from. If empty, will use the last used statedir 121 | --no-stack (-S) # Whether to remove the envs of the stack from the PATH while you are in the shell 122 | # Might not work as expected depending on your shell config (as it may add to your PATH again when starting) 123 | --isolated (-i) # Whether to limit the PATH of the shell so it can only see the tools from the selected envs and standard UNIX paths (implies -S) 124 | --empty (-e) # Whether to start a shell that contains nothing, to hide the current stack (implies -i) 125 | ...envnames: string # Envs to activate (will open a picker if none is given) 126 | ] { 127 | if ($env.SHELL_ENV? != null) { 128 | error make {msg: "Nested subshells are not supported"} 129 | } 130 | let isolated = $isolated or $empty 131 | let state = get-state $statedir 132 | let envstack = if $empty {[]} else {select-envs $envnames $unique $state --verb "use in the subshell"} 133 | let envs = write-flakes $envstack 134 | let shell_bit = if $isolated {"an isolated subshell"} else if $no_stack {"a subshell (hiding the stack)"} else {"a subshell"} 135 | print $"(ansi grey)Starting ($shell_bit) with env\(s) (ansi green)($envstack | get name)(ansi grey)...(ansi reset)" 136 | let merged_name = $envstack | get name | str join "+" 137 | let new_path = if $isolated { 138 | [/usr/local/sbin, /usr/local/bin, /usr/sbin, /usr/bin, /sbin, /bin] 139 | } else if $no_stack { 140 | $env.PATH | where {($in | path expand) != (nixprof | path expand)} 141 | } else { 142 | $env.PATH 143 | } 144 | set-currents --statedir $state.statedir 145 | try { 146 | let nix_exe = (which nix).path.0 147 | PATH=$new_path SHELL_ENV=$merged_name run-external $nix_exe shell ...( 148 | $envs | each {|e| 149 | $"($e.flakepath)#($e.envname)" 150 | } 151 | ) 152 | } catch { 153 | print $"(ansi red)Last subshell command exited with errcode (ansi yellow)($env.LAST_EXIT_CODE)(ansi reset)" 154 | } 155 | } 156 | 157 | # Run an executable provided by an env 158 | def "main run" [ 159 | envname: string # Which environment to look in 160 | bin = "" # Which bin to run. If empty will use `envname' as the bin name 161 | --statedir (-d): string = "" # Where to read the envil state from. If empty, will use the last used statedir 162 | ...args: string # The args to give to the command 163 | ] { 164 | let state = get-state $statedir 165 | let envs = write-flakes [{name: $envname, source: $state.statedir}] 166 | let output = ^nix build $"($envs.0.flakepath)#($envs.0.envname)" --print-out-paths --no-link 167 | let bin = if ($bin | is-empty) { 168 | $envname 169 | } else { 170 | $bin 171 | } 172 | let bin_path = $output | path join "bin" $bin 173 | if ($bin_path | path exists) { 174 | run-external $bin_path ...$args 175 | } else { 176 | print $"(ansi red)Could not find bin `($bin)' in env `($envname)'.(ansi reset) Other bins in that env:" 177 | ls-filenames ($output | path join "bin") 178 | } 179 | } 180 | 181 | def nixprof [] { 182 | use lib/state.nu envil-dir 183 | [(envil-dir) current] | path join 184 | } 185 | 186 | def do-switch [ 187 | envstack: table 188 | --append 189 | ] { 190 | use lib/nix-printer.nu * 191 | 192 | # We stick to 'nix-env' instead of the newest 'nix profile' 193 | # because the --remove-all flag does not exist (yet?) for 'nix profile' 194 | let exprs = write-flakes $envstack | each {|e| 195 | f _ ( [ 196 | (p builtins.getFlake (s $e.flakepath)) 197 | ".packages.${builtins.currentSystem}." 198 | $e.envname 199 | ] | str join "" 200 | ) 201 | } 202 | let profile = (nixprof) 203 | let envstack = $envstack | where active? 204 | let envs_bit = $"(ansi yellow)($envstack | get name | str join ' ')(ansi reset)" 205 | if $append { 206 | if not ($envstack | is-empty) { 207 | print $"Adding env\(s) ($envs_bit)" 208 | } 209 | } else { 210 | print $"Replacing stack with env\(s) ($envs_bit)" 211 | } 212 | ( ^nix-env --profile $profile --quiet 213 | ...(if not $append {[--remove-all]} else {[]}) 214 | --install --from-expression ...$exprs 215 | ) 216 | ^nix-env --profile $profile --delete-generations '+5' --quiet 217 | } 218 | 219 | def do-remove [removed: list] { 220 | if not ($removed | is-empty) { 221 | let profile = nixprof 222 | print $"Removing env\(s) (ansi yellow)($removed | str join ' ')(ansi reset)" 223 | ^nix-env --profile $profile --uninstall ...($removed | each {$"($in)-envil"}) --quiet 224 | ^nix-env --profile $profile --delete-generations '+5' --quiet 225 | } 226 | } 227 | 228 | # Switch to a new stack, or reload the current one from statedirs 229 | def "main switch" [ 230 | --unique (-u) # Whether to select just one environment (in which case the env picker will come with a searchbar) 231 | --statedir (-d): string = "" # Where to read the envil state from. If empty, will use the last used statedir 232 | --reload (-r) # Reload the current stack, ignoring other args 233 | ...envnames: string # Envs to activate (will open a picker if none is given) 234 | ] { 235 | let state = get-state $statedir 236 | let new_stack = if $reload { 237 | (get-currents).envstack 238 | } else { 239 | select-envs $envnames $unique $state --verb "switch to" 240 | } 241 | do-switch $new_stack 242 | set-currents --envstack $new_stack --statedir $state.statedir 243 | } 244 | 245 | # Add new envs on top of the stack 246 | def "main push" [ 247 | --unique (-u) # Whether to select just one env (in which case the env picker will come with a searchbar) 248 | --statedir (-d): string = "" # Where to read the envil state from. If empty, will use the last used statedir 249 | ...envnames: string # Envs to activate (will open a picker if none is given) 250 | ] { 251 | let state = get-state $statedir 252 | let stack = (get-currents).envstack 253 | let additions = select-envs $envnames $unique ($state | update envs {reject -o ...($stack | get name)}) --verb "add to the stack" 254 | do-switch --append $additions 255 | set-currents --envstack ($stack ++ $additions) --statedir $state.statedir 256 | } 257 | 258 | # Remove the top env(s) from the stack 259 | # 260 | # If no args are given, pops the first env on top of the stack 261 | def "main pop" [ 262 | --select (-s) # Open a selector menu, ignoring subsequent args 263 | --all (-a) # Pop the entire stack, ignoring subsequent args 264 | --number (-n): int = 0 # Pop a certain amount of envs from the top of the stack, ignoring subsequent args 265 | ...envnames: string # Which envs to pop 266 | ] { 267 | let currents = get-currents 268 | if ($currents.envstack | is-empty) { 269 | print $"(ansi grey)Nothing to pop(ansi reset)" 270 | } else { 271 | mut removed = [] 272 | mut new_stack = $currents.envstack 273 | if ((not $select) and ($all or $number > 0 or ($envnames | is-empty))) { 274 | let number = if $all { 275 | $currents.envstack | length 276 | } else if $number > 0 { 277 | $number 278 | } else { 279 | 1 280 | } 281 | $removed = $currents.envstack | slice ($number * -1).. | get name 282 | $new_stack = $currents.envstack | drop $number 283 | } else { 284 | let invalid = $envnames | where {not ($in in ($currents.envstack | get name))} 285 | if (not ($invalid | is-empty)) { 286 | print $"(ansi red)Env\(s) ($invalid) not in the stack(ansi reset)" 287 | error make {msg: "Invalid env names"} 288 | } 289 | # We don't select envs from the state but from the stack 290 | let fake_state = { envs: ($currents.envstack | get name | each {{$in: ""}} | into record) } 291 | $removed = select-envs $envnames false $fake_state --verb "remove" | get name 292 | $new_stack = $currents.envstack | where {not ($in.name in $removed)} 293 | } 294 | do-remove $removed 295 | set-currents --envstack $new_stack 296 | } 297 | } 298 | 299 | # Activate/deactivate some envs in the stack. Similar to push/pop, but with a quicker workflow 300 | def "main toggle" [ 301 | --unique (-u) # Whether to select just one environment (in which case the env picker will come with a searchbar) 302 | ...envnames: string # Which envs to toggle 303 | ] { 304 | let currents = get-currents 305 | let fake_state = { envs: ($currents.envstack | get name | each {{$in: ""}} | into record) } 306 | let selected = select-envs $envnames $unique $fake_state --verb "toggle" | get name 307 | let new_stack = $currents.envstack | 308 | insert selected {$in.name in $selected} | 309 | update active {|e| 310 | if ($e.selected) {not $e.active} else {$e.active} 311 | } 312 | do-remove ($new_stack | where {$in.selected and not $in.active} | get name) 313 | do-switch --append ($new_stack | where {$in.selected and $in.active}) 314 | set-currents --envstack ($new_stack | reject selected) 315 | } 316 | 317 | # Move an env of the stack to a certain position. Use --bottom to move it to the lowermost position 318 | def "main move" [ 319 | envname: string # Which env to move 320 | new_position = 0 # Where to put it 321 | --bottom (-b) # Place it to the bottom instead 322 | ] { 323 | let stack = (get-currents).envstack | reverse 324 | mut new_position = if $bottom {($stack | length) - 1} else {$new_position} 325 | let env_ = try { 326 | $stack | zip (0..) | where {$in.0.name == $envname} | get 0 327 | } catch { 328 | error make {msg: $"Env ($envname) not present in the stack"} 329 | } 330 | let env_position = $env_.1 331 | let env_ = $env_.0 332 | let stack = if $env_position == 0 { 333 | $stack | slice (1..) 334 | } else { 335 | $stack | slice (0..($env_position - 1)) | append ($stack | slice (($env_position + 1)..)) 336 | } 337 | let stack = $stack | insert $new_position $env_ 338 | set-currents --envstack ($stack | reverse) 339 | } 340 | 341 | # Make bulk updates to the stack via a temporary file 342 | def "main stack" [ 343 | --statedir (-d): string = "" # Statedir where to look for newly added envs 344 | ] { 345 | let state = get-state $statedir 346 | let stack = (get-currents).envstack 347 | let tmp_dir = mktemp -d 348 | let script = $tmp_dir | path join "envil-stack.yaml" 349 | [ 350 | "### Edit the following entries to add, remove or reorder envs from your stack:" 351 | "" 352 | ...($stack | reverse | each { 353 | if $in.source == $state.statedir { 354 | reject source 355 | } else { 356 | $in 357 | } | if $in.active { 358 | reject active 359 | } else { 360 | $in 361 | } | if ($in | columns) == ["name"] { 362 | get name 363 | } else { 364 | $in 365 | } 366 | } | to yaml | lines) 367 | "" 368 | $"### The following envs are available in ($state.statedir):" 369 | "" 370 | ...($state.envs | columns | each {$"# - ($in)"}) 371 | "" 372 | ] | str join "\n" | save $script 373 | run-external $env.EDITOR $script 374 | let new_stack = open $script | reverse | each {|entry| 375 | if ($entry | describe) == "string" { 376 | {name: $entry, source: $state.statedir, active: true} 377 | } else { 378 | $entry | default $state.statedir source | default true active 379 | } 380 | } 381 | rm -rf $tmp_dir 382 | let join = $new_stack | rename -c {active: in_new} | 383 | join --outer ($stack | rename -c {active: in_old}) name | 384 | default false in_new | default false in_old 385 | let to_rm = $join | where {|e| $e.in_old and not $e.in_new} 386 | let to_add = $join | where {|e| $e.in_new and not $e.in_old} 387 | do-remove ($to_rm | get name) 388 | do-switch --append ($to_add | insert active true) 389 | set-currents --envstack $new_stack --statedir $state.statedir 390 | } 391 | 392 | def grey-out-inactives [] { 393 | each {|env_| 394 | let fn = {if $env_.active? == true {$in} else {$"(ansi grey)($in)(ansi reset)"}} 395 | $env_ | update name $fn | update source $fn | update active $fn 396 | } 397 | } 398 | 399 | def ls-filenames [folder: path] { 400 | try { 401 | ls -s $folder | get name 402 | } catch { 403 | [] 404 | } 405 | } 406 | 407 | # Report if some bins are present several times in your PATH 408 | def "main duplicates" [ 409 | --all (-a) # Check for all bins, not just those in the nix store 410 | ] { 411 | let bins = $env.PATH | each {|p| try {ls $p} catch {[]} | insert source $p} | flatten | rename -c {name: path} | 412 | insert stem {$in.path | path parse | get stem} | update path {path expand} | 413 | if $all { $in } else { where path starts-with /nix/store } 414 | $bins | 415 | group-by stem --to-table | 416 | each {|bin| 417 | let items = $bin.items | uniq-by path | uniq-by source 418 | if (($items | length) >= 2) { 419 | {"Duplicated bins": $bin.group, "Sources": $items.source} 420 | } 421 | } 422 | } 423 | 424 | # Print out the current state of the stack & bins in the PATH, and the same for the current subshell (if any) 425 | def "main show" [] { 426 | let currents = get-currents 427 | let bindir = [(nixprof) bin] | path join 428 | { 429 | "Current statedir": $currents.statedir? 430 | "Env stack": (if ($bindir in $env.PATH) { 431 | $currents.envstack | reverse | grey-out-inactives 432 | } else { 433 | "(not in PATH)" 434 | }) 435 | "Bins in stack": (if ($bindir in $env.PATH) { 436 | ls-filenames $bindir | sort -i | str join ", " 437 | } else { 438 | $"(ansi yellow)`($bindir)' is not present in the PATH(ansi reset)" 439 | }) 440 | ...(if ($env.SHELL_ENV? != null) { 441 | { 442 | "Envs in subshell": ($env.SHELL_ENV | split row "+") 443 | "Bins in subshell": ($env.PATH | where {str ends-with "-envil/bin"} | ansi strip | 444 | each {ls-filenames $in} | flatten | sort -i | str join ", ") 445 | } 446 | } else { 447 | {} 448 | }) 449 | } 450 | } 451 | 452 | # Update selected envs' inputs (package sources) and associated lockfiles 453 | # 454 | # IMPORTANT: This does not touch the stack. If you want to update you current stack, run `envil switch -r' afterwards 455 | def "main update" [ 456 | --unique (-u) # Whether to select just one environment 457 | --statedir (-d): string = "" # Where to read the envil state from and write the updated flake.lock files. If empty, uses last used statedir 458 | ...envnames: string # Envs to update (will open a picker if none is given) 459 | ] { 460 | let state = get-state $statedir 461 | if $state.statedir_is_flake? == true { 462 | print $"(ansi red)Statedir is a flake. Run `nix flake update --flake ($state.statedir)` directly(ansi reset)" 463 | error make {msg: "Cannot update statedir"} 464 | } 465 | let envnames = select-envs $envnames $unique $state --verb "update" | insert update true 466 | write-flakes $envnames 467 | print $"(ansi grey)Updated flake.lock files for env\(s) (ansi green)($envnames | get name)(ansi reset)" 468 | } 469 | 470 | # Edit the current statedir config file and refresh the stack 471 | def "main state" [ 472 | --statedir (-d): string = "" # Where to read and write the envil state. If empty, uses last used statedir 473 | --no-refresh (-R) # Do not refresh the stack after editing 474 | ] { 475 | if ($env.EDITOR? | is-empty) { 476 | print $"(ansi red)Your EDITOR env var is not set(ansi reset)" 477 | return 478 | } 479 | let statefile = ( with-resolved-statedir $statedir 480 | --on-flake-url { 481 | print $"(ansi red)Cannot edit a statedir opened from a flake URL(ansi reset)" 482 | null 483 | } 484 | --on-flake-state {|statedir| 485 | $statedir | path join "flake.nix" 486 | } 487 | --on-yaml-state {|statedir| 488 | $statedir | path join "envil-state.yaml" 489 | } 490 | ) 491 | if ($statefile != null) { 492 | let orig = open -r $statefile 493 | run-external $env.EDITOR $statefile 494 | if (not $no_refresh) { 495 | if $orig == (open -r $statefile) { 496 | print "Nothing changed" 497 | } else { 498 | main switch -r 499 | } 500 | } 501 | } 502 | } 503 | --------------------------------------------------------------------------------