Repository: juspay/omnix Branch: main Commit: 9a4899083cd3 Files: 188 Total size: 444.3 KB Directory structure: gitextract_99yfqsb4/ ├── .envrc ├── .gitattributes ├── .github/ │ └── workflows/ │ ├── ci.yaml │ └── website.yaml ├── .gitignore ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── Cargo.toml ├── LICENSE ├── README.md ├── bacon.toml ├── crates/ │ ├── nix_rs/ │ │ ├── CHANGELOG.md │ │ ├── Cargo.toml │ │ ├── README.md │ │ ├── crate.nix │ │ └── src/ │ │ ├── arg.rs │ │ ├── command.rs │ │ ├── config.rs │ │ ├── copy.rs │ │ ├── detsys_installer.rs │ │ ├── env.rs │ │ ├── flake/ │ │ │ ├── command.rs │ │ │ ├── eval.rs │ │ │ ├── functions/ │ │ │ │ ├── README.md │ │ │ │ ├── addstringcontext/ │ │ │ │ │ ├── flake.nix │ │ │ │ │ └── mod.rs │ │ │ │ ├── core.rs │ │ │ │ ├── metadata/ │ │ │ │ │ ├── flake.nix │ │ │ │ │ └── mod.rs │ │ │ │ └── mod.rs │ │ │ ├── mod.rs │ │ │ ├── outputs.rs │ │ │ ├── schema.rs │ │ │ ├── system.rs │ │ │ └── url/ │ │ │ ├── attr.rs │ │ │ ├── core.rs │ │ │ └── mod.rs │ │ ├── info.rs │ │ ├── lib.rs │ │ ├── refs.rs │ │ ├── store/ │ │ │ ├── command.rs │ │ │ ├── mod.rs │ │ │ ├── path.rs │ │ │ └── uri.rs │ │ ├── system_list.rs │ │ ├── version.rs │ │ └── version_spec.rs │ ├── omnix-ci/ │ │ ├── CHANGELOG.md │ │ ├── Cargo.toml │ │ ├── LICENSE │ │ ├── README.md │ │ ├── crate.nix │ │ └── src/ │ │ ├── command/ │ │ │ ├── core.rs │ │ │ ├── gh_matrix.rs │ │ │ ├── mod.rs │ │ │ ├── run.rs │ │ │ └── run_remote.rs │ │ ├── config/ │ │ │ ├── core.rs │ │ │ ├── mod.rs │ │ │ ├── subflake.rs │ │ │ └── subflakes.rs │ │ ├── flake_ref.rs │ │ ├── github/ │ │ │ ├── actions.rs │ │ │ ├── matrix.rs │ │ │ ├── mod.rs │ │ │ └── pull_request.rs │ │ ├── lib.rs │ │ ├── nix/ │ │ │ ├── devour_flake.rs │ │ │ ├── lock.rs │ │ │ └── mod.rs │ │ └── step/ │ │ ├── build.rs │ │ ├── core.rs │ │ ├── custom.rs │ │ ├── flake_check.rs │ │ ├── lockfile.rs │ │ └── mod.rs │ ├── omnix-cli/ │ │ ├── Cargo.toml │ │ ├── crate.nix │ │ ├── src/ │ │ │ ├── args.rs │ │ │ ├── command/ │ │ │ │ ├── ci.rs │ │ │ │ ├── completion.rs │ │ │ │ ├── core.rs │ │ │ │ ├── develop.rs │ │ │ │ ├── health.rs │ │ │ │ ├── init.rs │ │ │ │ ├── mod.rs │ │ │ │ └── show.rs │ │ │ ├── lib.rs │ │ │ └── main.rs │ │ └── tests/ │ │ ├── command/ │ │ │ ├── ci.rs │ │ │ ├── core.rs │ │ │ ├── health.rs │ │ │ ├── init.rs │ │ │ ├── mod.rs │ │ │ └── show.rs │ │ ├── flake.nix │ │ └── test.rs │ ├── omnix-common/ │ │ ├── Cargo.toml │ │ ├── crate.nix │ │ └── src/ │ │ ├── check.rs │ │ ├── config.rs │ │ ├── fs.rs │ │ ├── lib.rs │ │ ├── logging.rs │ │ └── markdown.rs │ ├── omnix-develop/ │ │ ├── Cargo.toml │ │ ├── crate.nix │ │ └── src/ │ │ ├── config.rs │ │ ├── core.rs │ │ ├── lib.rs │ │ └── readme.rs │ ├── omnix-gui/ │ │ ├── Cargo.toml │ │ ├── Dioxus.toml │ │ ├── assets/ │ │ │ └── tailwind.css │ │ ├── build.rs │ │ ├── crate.nix │ │ ├── css/ │ │ │ └── input.css │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── flake.rs │ │ │ │ ├── health.rs │ │ │ │ ├── info.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── state/ │ │ │ │ │ ├── datum.rs │ │ │ │ │ ├── db.rs │ │ │ │ │ ├── error.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── refresh.rs │ │ │ │ └── widget.rs │ │ │ ├── cli.rs │ │ │ └── main.rs │ │ └── tailwind.config.js │ ├── omnix-health/ │ │ ├── CHANGELOG.md │ │ ├── Cargo.toml │ │ ├── README.md │ │ ├── crate.nix │ │ ├── failing/ │ │ │ ├── .envrc │ │ │ └── flake.nix │ │ ├── module/ │ │ │ ├── flake-module.nix │ │ │ └── flake.nix │ │ └── src/ │ │ ├── check/ │ │ │ ├── caches.rs │ │ │ ├── direnv.rs │ │ │ ├── flake_enabled.rs │ │ │ ├── homebrew.rs │ │ │ ├── max_jobs.rs │ │ │ ├── mod.rs │ │ │ ├── nix_version.rs │ │ │ ├── rosetta.rs │ │ │ ├── shell.rs │ │ │ └── trusted_users.rs │ │ ├── json.rs │ │ ├── lib.rs │ │ ├── report.rs │ │ └── traits.rs │ └── omnix-init/ │ ├── Cargo.toml │ ├── crate.nix │ ├── registry/ │ │ └── flake.nix │ └── src/ │ ├── action.rs │ ├── config.rs │ ├── core.rs │ ├── lib.rs │ ├── param.rs │ ├── registry.rs │ ├── template.rs │ └── test.rs ├── doc/ │ ├── .gitignore │ ├── config.md │ ├── flake.nix │ ├── history.md │ ├── index.md │ ├── index.yaml │ ├── mod.just │ ├── om/ │ │ ├── ci.md │ │ ├── develop/ │ │ │ └── omnixrc/ │ │ │ └── v1 │ │ ├── develop.md │ │ ├── health.md │ │ ├── init.md │ │ └── show.md │ └── om.md ├── flake.nix ├── justfile ├── nix/ │ ├── envs/ │ │ └── default.nix │ ├── flake-schemas/ │ │ └── flake.nix │ └── modules/ │ └── flake/ │ ├── closure-size.nix │ ├── devshell.nix │ ├── nixpkgs.nix │ ├── pre-commit.nix │ └── rust.nix ├── om.yaml └── rust-toolchain.toml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .envrc ================================================ source ./doc/om/develop/omnixrc/v1 # We don't watch on `flake/*.nix` to avoid changes in modules not relevant to devShell triggering reload watch_file \ ./doc/om/develop/omnixrc/v1 \ nix/modules/flake/nixpkgs.nix \ nix/modules/flake/rust.nix \ nix/modules/flake/devshell.nix \ nix/envs/default.nix \ ./crates/*/crate.nix \ *.nix \ om.yaml \ rust-toolchain.toml \ crates/omnix-init/registry/flake.* # Dogfood our own ./omnixrc! use omnix ================================================ FILE: .gitattributes ================================================ flake.lock linguist-generated=true crates/omnix-gui/assets/tailwind.css linguist-generated=true ================================================ FILE: .github/workflows/ci.yaml ================================================ name: "CI" on: push: branches: - "main" - "ci/**" pull_request: jobs: website: if: github.ref == 'refs/heads/main' needs: main uses: ./.github/workflows/website.yaml with: static-site-path: ${{ needs.main.outputs.OMWEBSITE }} secrets: inherit main: runs-on: ${{ matrix.system }} permissions: contents: read outputs: # It is important to match the matrix.system here # With that of website.yaml OMWEBSITE: ${{ steps.omci.outputs.OMWEBSITE_x86_64-linux }} strategy: matrix: system: [x86_64-linux, aarch64-linux, aarch64-darwin, x86_64-darwin] isMain: - ${{ contains(github.ref, 'main') }} # Excluded emulated builds on PRs exclude: - system: aarch64-linux isMain: false - system: x86_64-darwin isMain: false fail-fast: false steps: - uses: actions/checkout@v4 # Build omnix first, so we can use it to build the rest of the flake outputs. # This also separates the CI log for both these obviously distinct steps. - name: Build Omnix package run: nix build --no-link --print-out-paths --accept-flake-config # Build flake outputs # Run omnix using self. - name: Omnix CI run: | nix --accept-flake-config run . -- ci run \ --extra-access-tokens ${{ secrets.GITHUB_TOKEN }} \ --systems "${{ matrix.system }}" \ --results=$HOME/omci.json \ -- --accept-flake-config - name: Omnix results id: omci run: | cat $HOME/omci.json | jq # Retrieve the store path for the given package out of the given subflake. get_output() { subflake=$1 name=$2 \ jq -r '.result.[$ENV.subflake].build.byName.[$ENV.name]' < $HOME/omci.json } echo "OMCIJSON_PATH=$HOME/omci.json" >> "$GITHUB_OUTPUT" echo "OMCIJSON=$(cat $HOME/omci.json)" >> "$GITHUB_OUTPUT" echo "OMPACKAGE=$(get_output omnix omnix-cli)" >> "$GITHUB_OUTPUT" echo "OMWEBSITE_${{ matrix.system }}=$(get_output doc emanote-static-website-default)" >> "$GITHUB_OUTPUT" - name: "Omnix: Upload results" uses: actions/upload-artifact@v4 with: name: omci-${{ matrix.system }}.json path: ${{ steps.omci.outputs.OMCIJSON_PATH }} if-no-files-found: error # Login to the Attic with the token that allows pushing Nix store objects to the cache - name: Attic login if: github.ref == 'refs/heads/main' run: attic login chutney https://cache.nixos.asia ${{ secrets.ATTIC_LOGIN_TOKEN }} # Push the Nix cache - name: Push to attic if: github.ref == 'refs/heads/main' run: attic push chutney:oss $HOME/omci.json ================================================ FILE: .github/workflows/website.yaml ================================================ name: Website Deploy on: workflow_call: inputs: static-site-path: type: string required: true description: "Path to the static site to deploy" jobs: upload: runs-on: x86_64-linux steps: - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: ${{ inputs.static-site-path }} deploy: runs-on: x86_64-linux needs: upload environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: contents: read pages: write id-token: write # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. concurrency: group: "pages" cancel-in-progress: false steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 ================================================ FILE: .gitignore ================================================ /target result result-lib dist .direnv /assets/tailwind.css /.vscode/spellright.dict /.pre-commit-config.yaml ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "rust-lang.rust-analyzer", "mkhl.direnv", "jnoortheen.nix-ide", "bradlc.vscode-tailwindcss", "tamasfe.even-better-toml", "fill-labs.dependi" ] } ================================================ FILE: .vscode/settings.json ================================================ { "nixEnvSelector.nixFile": "${workspaceRoot}/shell.nix", "rust-analyzer.cargo.features": "all", "rust-analyzer.check.command": "clippy", "editor.formatOnSave": true, // https://twitter.com/sridca/status/1674947342607216641 "editor.inlayHints.enabled": "offUnlessPressed", "emmet.includeLanguages": { "rust": "html", "*.rs": "html" }, "tailwindCSS.includeLanguages": { "rust": "html", "*.rs": "html" }, "tailwindCSS.experimental.classRegex": [ "class: \"(.*)\"" ], "files.associations": { "*.css": "tailwindcss" } } ================================================ FILE: Cargo.toml ================================================ [workspace] resolver = "2" members = [ "crates/omnix-common", "crates/omnix-cli", "crates/omnix-init", "crates/omnix-develop", "crates/nix_rs", "crates/omnix-ci", "crates/omnix-health", ] [workspace.dependencies] anyhow = "1.0.75" async-walkdir = "2.0.0" bytesize = { version = "1.3.0", features = ["serde"] } cfg-if = "1" clap = { version = "4.3", features = ["derive", "env"] } clap-verbosity-flag = "2.2.0" colored = { version = "2.0" } console = "0.15.8" console_error_panic_hook = "0.1" console_log = "1" direnv = "0.1.1" fermi = "0.4.3" futures-lite = "2.3.0" glob = "0.3.1" globset = { version = "0.4", features = ["serde1"] } http = "0.2" human-panic = "1.1.5" inquire = "0.7.5" itertools = "0.13" is_proc_translated = { version = "0.1.1" } lazy_static = "1.4.0" pulldown-cmark-mdcat = "2.5.0" pulldown-cmark = { version = "0.12.1", default-features = false } nix_rs = { version = "1.0.0", path = "./crates/nix_rs" } nonempty = { version = "0.10.0", features = ["serialize"] } omnix-ci = { version = "1.0.0", path = "./crates/omnix-ci" } omnix-common = { version = "1.0.0", path = "./crates/omnix-common" } omnix-develop = { version = "1.0.0", path = "./crates/omnix-develop" } omnix-health = { version = "1.0.0", path = "./crates/omnix-health" } omnix-init = { version = "1.0.0", path = "./crates/omnix-init" } os_info = "3.7.0" reqwest = { version = "0.11", features = ["blocking", "json"] } regex = "1.9.3" semver = { version = "1.0.22", features = ["serde"] } serde = { version = "1.0.197", features = ["derive"] } serde_qs = "0.13.0" serde_json = "1.0" serde_repr = "0.1.18" serde_with = { version = "3.2", features = ["json"] } serde_yaml = "0.9" shell-words = { version = "1.1.0" } sysinfo = "0.29.10" syntect = { version = "5.3.0", features = ["default-syntaxes"] } tabled = "0.15" tempfile = "3" termimad = "0.30.0" thiserror = "1.0" tokio = { version = "1.43.1", features = ["full"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } try-guard = "0.2.0" url = { version = "2.4", features = ["serde"] } urlencoding = "2.1.3" uuid = { version = "1.3.0", features = ["serde", "v4", "js"] } which = { version = "4.4.2" } clap_complete = "4.5.0" clap_complete_nushell = "4.5" whoami = "1.5.2" [profile.release] strip = true # Automatically strip symbols from the binary. opt-level = "z" # Optimize for size. lto = true ================================================ FILE: LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: README.md ================================================ [![project chat](https://img.shields.io/github/discussions/juspay/omnix)](https://github.com/juspay/omnix/discussions) [![Naiveté Compass of Mood](https://img.shields.io/badge/naïve-FF10F0)](https://compass.naivete.me/ "This project follows the 'Naiveté Compass of Mood'") # omnix *Pronounced [`/ɒmˈnɪks/`](https://ipa-reader.com/?text=%C9%92m%CB%88n%C9%AAks&voice=Geraint)* Omnix aims to supplement the [Nix](https://nixos.asia/en/nix) CLI to improve developer experience. ## Usage See ## Developing 1. [Install Nix](https://nixos.asia/en/install) 1. [Setup `direnv`](https://nixos.asia/en/direnv) 1. Clone this repo, `cd` to it, and run `direnv allow`. This will automatically activate the nix develop shell. Open VSCode and install recommended extensions, ensuring that direnv activates in VSCode as well. ### Running locally To run `omnix-cli`, ```sh just watch # Or `just w`; you can also pass args, e.g.: `just w show` ``` ### Nix workflows Inside the nix develop shell (activated by direnv) you can use any of the `cargo` or `rustc` commands, as well as [`just`](https://just.systems/) workflows. Nix specific commands can also be used to work with the project: ```sh # Full nix build of CLI nix build # Build and run the CLI nix run ``` ### Contributing >[!TIP] > Run `just pca` to autoformat the source tree. - Run `just ci` to **run CI locally**. - Add **documentation** wherever useful. - Run `just doc run` to preview website docs; edit, and run `just doc check` - To preview Rust API docs, run `just doc cargo`. - Changes must accompany a corresponding `history.md` entry.[^cc] [^cc]: We don't use any automatic changelog generator for this repo. ### Release HOWTO Begin with a release PR: - Pick a version - Update `history.md` to make sure new release header is present - Run [`cargo workspace publish --force omnix-cli`](https://github.com/pksunkara/cargo-workspaces?tab=readme-ov-file#publish) in devShell, using the picked version. ================================================ FILE: bacon.toml ================================================ # This is a configuration file for the bacon tool # # Bacon repository: https://github.com/Canop/bacon # Complete help on configuration: https://dystroy.org/bacon/config/ # You can also check bacon's own bacon.toml file # as an example: https://github.com/Canop/bacon/blob/main/bacon.toml default_job = "check" [jobs.check] command = ["cargo", "check", "--color", "always"] need_stdout = false [jobs.check-all] command = ["cargo", "check", "--all-targets", "--color", "always"] need_stdout = false # Run clippy on the default target [jobs.clippy] command = ["cargo", "clippy", "--color", "always"] need_stdout = false # Run clippy on all targets # To disable some lints, you may change the job this way: # [jobs.clippy-all] # command = [ # "cargo", "clippy", # "--all-targets", # "--color", "always", # "--", # "-A", "clippy::bool_to_int_with_if", # "-A", "clippy::collapsible_if", # "-A", "clippy::derive_partial_eq_without_eq", # ] # need_stdout = false [jobs.clippy-all] command = ["cargo", "clippy", "--all-targets", "--color", "always"] need_stdout = false # This job lets you run # - all tests: bacon test # - a specific test: bacon test -- config::test_default_files # - the tests of a package: bacon test -- -- -p config [jobs.test] command = [ "cargo", "test", "--color", "always", "--", "--color", "always", # see https://github.com/Canop/bacon/issues/124 ] need_stdout = true [jobs.doc] command = ["cargo", "doc", "--color", "always", "--no-deps"] need_stdout = false # If the doc compiles, then it opens in your browser and bacon switches # to the previous job [jobs.doc-open] command = ["cargo", "doc", "--color", "always", "--no-deps", "--open"] need_stdout = false on_success = "back" # so that we don't open the browser at each change # You can run your application and have the result displayed in bacon, # *if* it makes sense for this crate. # Don't forget the `--color always` part or the errors won't be # properly parsed. # If your program never stops (eg a server), you may set `background` # to false to have the cargo run output immediately displayed instead # of waiting for program's end. [jobs.run] command = [ "cargo", "run", "--color", "always", # put launch parameters for your program behind a `--` separator ] need_stdout = true allow_warnings = true background = true [jobs.health-failing] command = [ "cargo", "run", "--color", "always", "--", "health", "./crates/omnix-health/failing", ] need_stdout = true allow_warnings = true [jobs.develop] command = [ "cargo", "run", "--color", "always", "--", "develop", ".", ] need_stdout = true allow_warnings = true # You may define here keybindings that would be specific to # a project, for example a shortcut to launch a specific job. # Shortcuts to internal functions (scrolling, toggling, etc.) # should go in your personal global prefs.toml file instead. [keybindings] # alt-m = "job:my-job" c = "job:clippy-all" # comment this to have 'c' run clippy on only the default target h = "job:health-failing" ================================================ FILE: crates/nix_rs/CHANGELOG.md ================================================ # Changelog ## Unreleased - **`flake::url`**: - Remove `qualified_attr` module - **`eval::nix_eval`** - Display evaluation progress - Decrease logging verbosity - **`flake::schema`** - Don't hardcode flake schema types - **`config`** - Don't enable flakes during `NixConfig::get` - Support Nix 2.20 - **`flake::url`** - Add `without_attr`, `get_attr` - Simplify the return type of `RootQualifiedAttr::eval_flake` - Add `AsRef`, `Deref`, `From<&Path>` instances for `FlakeUrl` - `Path` instances for `FlakeUrl` no longer use the `path:` prefix (to avoid store copying) - **`attr`**: - Add `FlakeAttr::new` and `FlakeAttr::none` constructors - `qualified_attr` - vastly simplify module - `flake::functions`: - Add new module - **`flake::command`**: - Add module, for `nix run`, `nix build` and `nix develop` - **`store`**: - Add module (upstreamed from nixci) - Add `StoreURI` - Avoid running `nix-store` multiple times. - **`copy`**: - Takes `NixCopyOptions` now. - **`env`**: - use `whoami` crate to find the current user instead of depending on environment variable `USER` - `NixEnv::detect`'s logging uses DEBUG level now (formerly INFO) - Add Nix installer to `NixEnv` - **`command` - `run_with_args` is now `run_with`, and takes a function that mutates the `Command` at will. - Add `trace_cmd_with` - **`version`**: - Add `NixVersion::get` - **`system_list`**: New module - **version_spec**: New `NixVersion` spec module ## 1.0.0 - **DeterminateSystems/flake-schemas** - Allow overriding the `nix` CLI command. - Switch to flake schema given by - **`flake::schema::FlakeSchema`** - Add `nixos_configurations` - **`flake::url`** - `Flake::from_nix` explicitly takes `NixConfig` as argument, rather than implicitly running nix to get it. - Remove string convertion implementations; use `std::parse` instead, and handle errors explicitly. - Split attr code to its own module, `flake::url::attr` - Introduce `flake::url::qualified_attr` module - **`eval`** - `nix_eval_attr_json` - No longer takes `default_if_missing`; instead (always) returns `None` if attribute is missing. - Rename to `nix_eval_maybe` (as there is no non-JSON variant) - **`env::NixEnv`** - Clarify error message when `$USER` is not set - **``command`** - Add `NixCmd::get()` to return flakes-enabled global command - `NixCmd::default()` returns the bare command (no experimental features enabled) - ``config`` - Add `NixConfig::get()` to get the once-created static value of `NixConfig` - `info` - Add `NixInfo::get()` to get the once-created static value of `NixInfo` - Rename `NixInfo::from_nix()` to `NixInfo::new()`; the latter explicitly takes `NixConfig` ## [0.5.0](https://github.com/juspay/nix-rs/compare/0.4.0...0.5.0) (2024-06-05) ### Features - Improve `with_flakes` to transform existing `NixCmd` ([f936e54](https://github.com/juspay/nix-rs/commit/f936e5401d1bc9b82084cf7b49402a5ee1a3b733)) - Add support for clap deriving ([f61bd2c](https://github.com/juspay/nix-rs/commit/f61bd2c740a23a10bbb89dfbd3b77fd4b2a49bac)) - Add `NixCmd::extra_access_tokens` ([a287ab2](https://github.com/juspay/nix-rs/commit/a287ab2ad2d21db6ac89e4ce94c55446a02af241)) ## [0.4.0](https://github.com/juspay/nix-rs/compare/0.3.3...0.4.0) (2024-06-03) ### Features - add `NixCmd::run_with_args` ([47f3170](https://github.com/juspay/nix-rs/commit/47f3170d57b72089eb977620217613571c52f456)) - add `FlakeUrl::with_attr` ([1ff343d](https://github.com/juspay/nix-rs/commit/1ff343d25f1a633c3caf2d6f723bbd1c9e352cbc)) ### [0.3.3](https://github.com/juspay/nix-rs/compare/0.3.2...0.3.3) (2024-04-17) #### Features - **eval:** nix_eval_attr_json explicitly takes NixCmd ([cccdb43](https://github.com/juspay/nix-rs/commit/cccdb437f4f2b31d32778e9cf3de2ab1a61d9331)) - **command:** Add `with_flakes` returning smarter nix CLI with flakes enabled ([f7f217a](https://github.com/juspay/nix-rs/commit/f7f217a12acefc3992b5ff8ba59d861f5cc2abcb)) ### 0.3.2 (2024-04-04) ================================================ FILE: crates/nix_rs/Cargo.toml ================================================ [package] name = "nix_rs" # Important: remember to update the top-level Cargo.toml if updating major version version = "1.3.2" license = "Apache-2.0" repository = "https://github.com/juspay/omnix" description = "Rust library for interacting with the Nix command" edition = "2021" [lib] crate-type = ["cdylib", "rlib"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] cfg-if = { workspace = true } regex = { workspace = true } os_info = { workspace = true } thiserror = { workspace = true } serde = { workspace = true } serde_qs = { workspace = true } serde_json = { workspace = true } serde_with = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } url = { workspace = true } colored = { workspace = true } shell-words = { workspace = true } is_proc_translated = { workspace = true } sysinfo = { workspace = true } tempfile = { workspace = true } bytesize = { workspace = true } clap = { workspace = true, optional = true } nonempty = { workspace = true } whoami = { workspace = true } lazy_static = { workspace = true } which = { workspace = true } [features] clap = ["dep:clap"] ================================================ FILE: crates/nix_rs/README.md ================================================ # nix_rs [![Crates.io](https://img.shields.io/crates/v/nix_rs.svg)](https://crates.io/crates/nix_rs) A Rust crate to interact with the [Nix](https://nixos.asia/en/nix) command. `nix_rs` also provides the needed Rust types which are guaranteed to compile in wasm. ================================================ FILE: crates/nix_rs/crate.nix ================================================ { autoWire = [ ]; crane = { args = { nativeBuildInputs = [ # nix # Tests need nix cli ]; }; }; } ================================================ FILE: crates/nix_rs/src/arg.rs ================================================ //! Nix command's arguments use std::collections::HashMap; use serde::{Deserialize, Serialize}; /// All arguments you can pass to the `nix` command /// /// This struct is clap-friendly for using in your subcommands. The clap options will mirror that of `nix`. /// /// To convert to `Command` args list, use `into_iter`. #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] #[cfg_attr(feature = "clap", derive(clap::Parser))] pub struct NixArgs { /// Append to the experimental-features setting of Nix. #[cfg_attr(feature = "clap", arg(long))] pub extra_experimental_features: Vec, /// Append to the access-tokens setting of Nix. #[cfg_attr(feature = "clap", arg(long))] pub extra_access_tokens: Vec, /// Additional arguments to pass through to `nix` /// /// NOTE: Arguments irrelevant to a nix subcommand will automatically be ignored. #[cfg_attr(feature = "clap", arg(last = true))] pub extra_nix_args: Vec, } impl NixArgs { /// Convert this [NixCmd] configuration into a list of arguments for /// [Command] pub fn to_args(&self, subcommands: &[&str]) -> Vec { let mut args = vec![]; if !self.extra_experimental_features.is_empty() { args.push("--extra-experimental-features".to_string()); args.push(self.extra_experimental_features.join(" ")); } if !self.extra_access_tokens.is_empty() { args.push("--extra-access-tokens".to_string()); args.push(self.extra_access_tokens.join(" ")); } let mut extra_nix_args = self.extra_nix_args.clone(); remove_nonsense_args_when_subcommand(subcommands, &mut extra_nix_args); args.extend(extra_nix_args); args } /// Enable flakes on this [NixCmd] configuration pub fn with_flakes(&mut self) { self.extra_experimental_features .append(vec!["nix-command".to_string(), "flakes".to_string()].as_mut()); } /// Enable nix-command on this [NixCmd] configuration pub fn with_nix_command(&mut self) { self.extra_experimental_features .append(vec!["nix-command".to_string()].as_mut()); } } /// Certain options, like --rebuild, is not supported by all subcommands (e.g. /// `nix develop`). We remove them here. Yes, this is a bit of HACK! fn remove_nonsense_args_when_subcommand(subcommands: &[&str], args: &mut Vec) { let unsupported = non_sense_options(subcommands); for (option, count) in unsupported { remove_arguments(args, option, count); } } fn non_sense_options<'a>(subcommands: &[&str]) -> HashMap<&'a str, usize> { let rebuild = ("--rebuild", 0); let override_input = ("--override-input", 2); match subcommands { ["eval"] => HashMap::from([rebuild, override_input]), ["flake", "lock"] => HashMap::from([rebuild, override_input]), ["flake", "check"] => HashMap::from([rebuild]), ["develop"] => HashMap::from([rebuild]), ["run"] => HashMap::from([rebuild]), _ => HashMap::new(), } } fn remove_arguments(vec: &mut Vec, arg: &str, next: usize) { let mut i = 0; while i < vec.len() { if vec[i] == arg && i + next < vec.len() { vec.drain(i..i + next + 1); } else { i += 1; } } } ================================================ FILE: crates/nix_rs/src/command.rs ================================================ //! Nix base command configuration //! //! # Example //! //! ```ignore //! use nix_rs::command::NixCmd; //! let cmd = NixCmd::default(); //! cmd.run_with_args_returning_stdout(&["--version"]); //! ``` use std::{ fmt::{self, Display}, process::Stdio, }; use serde::{Deserialize, Serialize}; use thiserror::Error; use tokio::{process::Command, sync::OnceCell}; use tracing::instrument; #[cfg(feature = "clap")] use clap; use crate::{arg::NixArgs, config::NixConfig}; /// The `nix` command's global options. /// /// See [available global /// options](https://nixos.org/manual/nix/stable/command-ref/new-cli/nix#options) #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] #[cfg_attr(feature = "clap", derive(clap::Parser))] pub struct NixCmd { /// The arguments to pass to `nix` #[cfg_attr(feature = "clap", clap(flatten))] pub args: NixArgs, } static NIXCMD: OnceCell = OnceCell::const_new(); /// Trace a user-copyable command line /// /// [tracing::info!] the given [tokio::process::Command] with human-readable /// command-line string that can generally be copy-pasted by the user. /// /// The command will be highlighted to distinguish it (for copying) from the /// rest of the instrumentation parameters. #[instrument(name = "command")] pub fn trace_cmd(cmd: &tokio::process::Command) { trace_cmd_with("❄️ ", cmd); } /// Like [trace_cmd] but with a custom icon #[instrument(name = "command")] pub fn trace_cmd_with(icon: &str, cmd: &tokio::process::Command) { use colored::Colorize; tracing::info!("{}", format!("{} {}️", icon, to_cli(cmd)).dimmed()); } impl NixCmd { /// Return a global `NixCmd` instance with flakes enabled. pub async fn get() -> &'static NixCmd { NIXCMD .get_or_init(|| async { let cfg = NixConfig::get().await.as_ref().unwrap_or_else(|err| { panic!("Unable to get Nix config. Is your nix.conf valid?\n{}", err) }); let mut cmd = NixCmd::default(); if !cfg.is_flakes_enabled() { cmd.args.with_flakes() } cmd }) .await } /// Return a [Command] for this [NixCmd] configuration /// /// Arguments: /// - `subcommands`: Optional subcommands to pass. Note that `NixArgs` will /// be passed *after* these subcommands. pub fn command(&self, subcommands: &[&str]) -> Command { let mut cmd = Command::new("nix"); cmd.kill_on_drop(true); cmd.args(subcommands); cmd.args(self.args.to_args(subcommands)); cmd } /// Run nix with given args, interpreting stdout as JSON, parsing into `T` pub async fn run_with_args_expecting_json( &self, subcommands: &[&str], args: &[&str], ) -> Result where T: serde::de::DeserializeOwned, { let stdout: Vec = self .run_with_returning_stdout(subcommands, |c| { c.args(args); }) .await?; let v = serde_json::from_slice::(&stdout)?; Ok(v) } /// Run nix with given args, interpreting parsing stdout, via [std::str::FromStr], into `T` pub async fn run_with_args_expecting_fromstr( &self, subcommands: &[&str], args: &[&str], ) -> Result where T: std::str::FromStr, ::Err: std::fmt::Display, { let stdout = self .run_with_returning_stdout(subcommands, |c| { c.args(args); }) .await?; let v = &String::from_utf8_lossy(&stdout); let v = T::from_str(v.trim()).map_err(|e| FromStrError(e.to_string()))?; Ok(v) } /// Like [Self::run_with] but returns stdout as a [`Vec`] pub async fn run_with_returning_stdout( &self, subcommands: &[&str], f: F, ) -> Result, CommandError> where F: FnOnce(&mut Command), { let mut cmd = self.command(subcommands); f(&mut cmd); trace_cmd(&cmd); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); let child = cmd.spawn()?; let out = child.wait_with_output().await?; if out.status.success() { Ok(out.stdout) } else { let stderr = String::from_utf8_lossy(&out.stderr).to_string(); Err(CommandError::ProcessFailed { stderr, exit_code: out.status.code(), }) } } /// Run Nix with given [Command] customizations, while also tracing the command being run. /// /// Return the stdout bytes returned by [tokio::process::Child::wait_with_output]. In order to capture stdout, you must call `cmd.stdout(Stdio::piped());` inside the handler. pub async fn run_with(&self, subcommands: &[&str], f: F) -> Result, CommandError> where F: FnOnce(&mut Command), { let mut cmd = self.command(subcommands); f(&mut cmd); trace_cmd(&cmd); let out = cmd.spawn()?.wait_with_output().await?; if out.status.success() { Ok(out.stdout) } else { let stderr = String::from_utf8_lossy(&out.stderr).to_string(); Err(CommandError::ProcessFailed { stderr, exit_code: out.status.code(), }) } } } /// Convert a Command to user-copyable CLI string fn to_cli(cmd: &tokio::process::Command) -> String { use std::ffi::OsStr; let program = cmd.as_std().get_program().to_string_lossy().to_string(); let args = cmd .as_std() .get_args() .collect::>() .into_iter() .map(|s| s.to_string_lossy().to_string()) .collect::>(); let cli = vec![program] .into_iter() .chain(args) .collect::>(); shell_words::join(cli) } /// Errors when running and interpreting the output of a nix command #[derive(Error, Debug)] pub enum NixCmdError { /// A [CommandError] #[error("Command error: {0}")] CmdError(#[from] CommandError), /// Failed to unicode-decode the output of a command #[error("Failed to decode command stdout (utf8 error): {0}")] DecodeErrorUtf8(#[from] std::string::FromUtf8Error), /// Failed to parse the output of a command #[error("Failed to decode command stdout (from_str error): {0}")] DecodeErrorFromStr(#[from] FromStrError), /// Failed to parse the output of a command as JSON #[error("Failed to decode command stdout (json error): {0}")] DecodeErrorJson(#[from] serde_json::Error), } /// Errors when parsing a string into a type #[derive(Debug)] pub struct FromStrError(String); impl Display for FromStrError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "Failed to parse string: {}", self.0) } } impl std::error::Error for FromStrError {} /// Errors when running a command #[derive(Error, Debug)] pub enum CommandError { /// Error when spawning a child process #[error("Child process error: {0}")] ChildProcessError(#[from] std::io::Error), /// Child process exited unsuccessfully #[error( "Process exited unsuccessfully. exit_code={:?} stderr={}", exit_code, stderr )] ProcessFailed { /// The stderr of the process, if available. stderr: String, /// The exit code of the process exit_code: Option, }, /// Failed to decode the stderr of a command #[error("Failed to decode command stderr: {0}")] Decode(#[from] std::string::FromUtf8Error), } ================================================ FILE: crates/nix_rs/src/config.rs ================================================ //! Rust module for `nix show-config` use std::{convert::Infallible, str::FromStr}; use serde::{Deserialize, Serialize}; use serde_with::DeserializeFromStr; use tokio::sync::OnceCell; use tracing::instrument; use url::Url; use crate::{ command::{NixCmd, NixCmdError}, version::NixVersion, }; use super::flake::system::System; /// Nix configuration spit out by `nix show-config` #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct NixConfig { /// Number of CPU cores used for nix builds pub cores: ConfigVal, /// Experimental features currently enabled pub experimental_features: ConfigVal>, /// Extra platforms to build for pub extra_platforms: ConfigVal>, /// The flake registry to use to lookup atomic flake inputs pub flake_registry: ConfigVal, /// Maximum number of jobs to run in parallel pub max_jobs: ConfigVal, /// Cache substituters pub substituters: ConfigVal>, /// Current system pub system: ConfigVal, /// Trusted users pub trusted_users: ConfigVal>, } /// The value for each 'nix show-config --json' key. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ConfigVal { /// Current value in use. pub value: T, /// Default value by Nix. pub default_value: T, /// Description of this config item. pub description: String, } static NIX_CONFIG: OnceCell> = OnceCell::const_new(); static NIX_2_20_0: NixVersion = NixVersion { major: 2, minor: 20, patch: 0, }; impl NixConfig { /// Get the once version of `NixConfig`. #[instrument(name = "show-config(once)")] pub async fn get() -> &'static Result { NIX_CONFIG .get_or_init(|| async { let mut cmd = NixCmd::default(); cmd.args.with_nix_command(); // Enable nix-command, since don't yet know if it is already enabled. let nix_ver = NixVersion::get().await.as_ref()?; let cfg = NixConfig::from_nix(&cmd, nix_ver).await?; Ok(cfg) }) .await } /// Get the output of `nix show-config` #[instrument(name = "show-config")] pub async fn from_nix( nix_cmd: &super::command::NixCmd, nix_version: &NixVersion, ) -> Result { let v = if nix_version >= &NIX_2_20_0 { nix_cmd .run_with_args_expecting_json(&["config", "show"], &["--json"]) .await? } else { nix_cmd .run_with_args_expecting_json(&["show-config"], &["--json"]) .await? }; Ok(v) } /// Is flakes and command features enabled? pub fn is_flakes_enabled(&self) -> bool { self.experimental_features .value .contains(&"nix-command".to_string()) && self .experimental_features .value .contains(&"flakes".to_string()) } } /// Error type for `NixConfig` #[derive(thiserror::Error, Debug)] pub enum NixConfigError { /// A [NixCmdError] #[error("Nix command error: {0}")] NixCmdError(#[from] NixCmdError), /// A [NixCmdError] with a static lifetime #[error("Nix command error: {0}")] NixCmdErrorStatic(#[from] &'static NixCmdError), } /// Accepted value for "trusted-users" in nix.conf #[derive(Debug, Clone, PartialEq, Eq, Serialize, DeserializeFromStr)] pub enum TrustedUserValue { /// All users are trusted All, /// A specific user is trusted User(String), /// Users belonging to a specific group are trusted Group(String), } impl TrustedUserValue { fn from_str(s: &str) -> Self { // In nix.conf, groups are prefixed with '@'. '*' means all users are // trusted. if s == "*" { return Self::All; } match s.strip_prefix('@') { Some(s) => Self::Group(s.to_string()), None => Self::User(s.to_string()), } } /// Display the nix.conf original string pub fn display_original(val: &[TrustedUserValue]) -> String { val.iter() .map(|x| match x { TrustedUserValue::All => "*".to_string(), TrustedUserValue::User(x) => x.to_string(), TrustedUserValue::Group(x) => format!("@{}", x), }) .collect::>() .join(" ") } } impl From for TrustedUserValue { fn from(s: String) -> Self { Self::from_str(&s) } } impl FromStr for TrustedUserValue { type Err = Infallible; fn from_str(s: &str) -> Result { Ok(Self::from_str(s)) } } #[tokio::test] async fn test_nix_config() -> Result<(), crate::command::NixCmdError> { let v = NixConfig::get().await.as_ref().unwrap(); println!("Max Jobs: {}", v.max_jobs.value); Ok(()) } ================================================ FILE: crates/nix_rs/src/copy.rs ================================================ //! Rust module for `nix copy`. use crate::{ command::{CommandError, NixCmd}, store::uri::StoreURI, }; use std::{ffi::OsStr, path::Path}; /// Options for `nix copy`. #[derive(Debug, Clone, Default)] pub struct NixCopyOptions { /// The URI of the store to copy from. pub from: Option, /// The URI of the store to copy to. pub to: Option, /// Do not check signatures. pub no_check_sigs: bool, } /// Copy store paths to a remote Nix store using `nix copy`. /// /// # Arguments /// /// * `cmd` - The `nix` command /// * `host` - The remote host to copy to /// * `paths` - The paths to copy. Limit this to be within the limit of Unix process arguments size limit. pub async fn nix_copy( cmd: &NixCmd, options: NixCopyOptions, paths: I, ) -> Result<(), CommandError> where I: IntoIterator, P: AsRef + AsRef, { cmd.run_with(&["copy"], |cmd| { cmd.arg("-v"); if let Some(uri) = options.from { cmd.arg("--from").arg(uri.to_string()); } if let Some(uri) = options.to { cmd.arg("--to").arg(uri.to_string()); } if options.no_check_sigs { cmd.arg("--no-check-sigs"); } cmd.args(paths); }) .await?; Ok(()) } ================================================ FILE: crates/nix_rs/src/detsys_installer.rs ================================================ //! DetSys installer detection // TODO: Move this under 'env' module. use serde::{Deserialize, Serialize}; use std::{fmt::Display, io::ErrorKind, path::Path, str::FromStr}; use regex::Regex; use thiserror::Error; /// The installer from #[derive(Debug, Serialize, Deserialize, PartialEq, PartialOrd, Eq, Clone)] pub struct DetSysNixInstaller { version: InstallerVersion, } impl DetSysNixInstaller { /// Detects if the DetSys nix-installer is installed pub fn detect() -> Result, BadInstallerVersion> { let nix_installer_path = Path::new("/nix/nix-installer"); if nix_installer_path.exists() { Ok(Some(DetSysNixInstaller { version: InstallerVersion::get_version(nix_installer_path)?, })) } else { Ok(None) } } } impl Display for DetSysNixInstaller { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "DetSys nix-installer ({})", self.version) } } // The version of Detsys/nix-installer #[derive(Debug, Serialize, Deserialize, PartialEq, PartialOrd, Eq, Clone)] struct InstallerVersion { major: u32, minor: u32, patch: u32, } impl Display for InstallerVersion { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}.{}.{}", self.major, self.minor, self.patch) } } /// Errors that can occur when trying to get the [DetSysNixInstaller] version #[derive(Error, Debug)] pub enum BadInstallerVersion { /// Regex error #[error("Regex error: {0}")] Regex(#[from] regex::Error), /// Failed to decode installer output #[error("Failed to decode installer output: {0}")] Decode(#[from] std::string::FromUtf8Error), /// Failed to parse installer version #[error("Failed to parse installer version: {0}")] Parse(#[from] std::num::ParseIntError), /// Failed to fetch installer version #[error("Failed to fetch installer version: {0}")] Command(std::io::Error), } impl FromStr for InstallerVersion { type Err = BadInstallerVersion; fn from_str(s: &str) -> Result { let re = Regex::new(r"(\d+)\.(\d+)\.(\d+)")?; let captures = re .captures(s) .ok_or(BadInstallerVersion::Command(std::io::Error::new( ErrorKind::InvalidData, "Failed to capture regex", )))?; let major = captures[1].parse::()?; let minor = captures[2].parse::()?; let patch = captures[3].parse::()?; Ok(InstallerVersion { major, minor, patch, }) } } impl InstallerVersion { pub fn get_version(executable_path: &Path) -> Result { let output = std::process::Command::new(executable_path) .arg("--version") .output() .map_err(BadInstallerVersion::Command)?; let version_str = String::from_utf8(output.stdout)?; version_str.parse() } } ================================================ FILE: crates/nix_rs/src/env.rs ================================================ //! Information about the environment in which Nix will run // TODO: Make this a package, and split (alongn with detsys_installer.rs) use std::{fmt::Display, path::Path}; use bytesize::ByteSize; use os_info; use serde::{Deserialize, Serialize}; use serde_with::SerializeDisplay; use std::process::Command; use tracing::instrument; use whoami; /// The environment in which Nix operates #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct NixEnv { /// Current user ($USER) pub current_user: String, /// Current user groups pub current_user_groups: Vec, /// Underlying OS in which Nix runs pub os: OS, /// Total disk space of the volume where /nix exists. /// /// This is either root volume or the dedicated /nix volume. pub total_disk_space: ByteSize, /// Total memory pub total_memory: ByteSize, /// The installer used to install Nix pub installer: NixInstaller, } impl NixEnv { /// Determine [NixEnv] on the user's system #[instrument] pub async fn detect() -> Result { use sysinfo::{DiskExt, SystemExt}; tracing::debug!("Detecting Nix environment"); let os = OS::detect().await; tokio::task::spawn_blocking(|| { let current_user = whoami::username(); let sys = sysinfo::System::new_with_specifics( sysinfo::RefreshKind::new().with_disks_list().with_memory(), ); let total_disk_space = to_bytesize(get_nix_disk(&sys)?.total_space()); let total_memory = to_bytesize(sys.total_memory()); let current_user_groups = get_current_user_groups()?; let installer = NixInstaller::detect()?; Ok(NixEnv { current_user, current_user_groups, os, total_disk_space, total_memory, installer, }) }) .await .unwrap() } } /// Get the current user's groups fn get_current_user_groups() -> Result, NixEnvError> { let output = Command::new("groups") .output() .map_err(NixEnvError::GroupsError)?; let group_info = &String::from_utf8_lossy(&output.stdout); Ok(group_info .as_ref() .split_whitespace() .map(|v| v.to_string()) .collect()) } /// Get the disk where /nix exists fn get_nix_disk(sys: &sysinfo::System) -> Result<&sysinfo::Disk, NixEnvError> { use sysinfo::{DiskExt, SystemExt}; let by_mount_point: std::collections::HashMap<&Path, &sysinfo::Disk> = sys .disks() .iter() .map(|disk| (disk.mount_point(), disk)) .collect(); // Lookup /nix first, then /. by_mount_point .get(Path::new("/nix")) .copied() .or_else(|| by_mount_point.get(Path::new("/")).copied()) .ok_or(NixEnvError::NoDisk) } /// The system under which Nix is installed and operates #[derive(Debug, Clone, PartialEq, Eq, SerializeDisplay, Deserialize)] pub enum OS { /// On macOS MacOS { /// Using nix-darwin nix_darwin: bool, /// Architecture arch: Option, /// https://developer.apple.com/documentation/apple-silicon/about-the-rosetta-translation-environment proc_translated: bool, }, /// On NixOS NixOS, /// Nix is individually installed on Linux or macOS Other(os_info::Type), } // The [Display] instance affects how [OS] is displayed to the app user impl Display for OS { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { OS::MacOS { nix_darwin, arch: _, proc_translated: _, } => { if *nix_darwin { write!(f, "macOS (nix-darwin)") } else { write!(f, "macOS") } } OS::NixOS => write!(f, "NixOS"), OS::Other(os_type) => write!(f, "{}", os_type), } } } impl OS { /// Detect the OS pub async fn detect() -> Self { let os_info = tokio::task::spawn_blocking(os_info::get).await.unwrap(); let os_type = os_info.os_type(); let arch = os_info.architecture(); async fn is_symlink(file_path: &str) -> std::io::Result { let metadata = tokio::fs::symlink_metadata(file_path).await?; Ok(metadata.file_type().is_symlink()) } match os_type { os_info::Type::Macos => { // To detect that we are on NixDarwin, we check if /etc/nix/nix.conf // is a symlink (which nix-darwin manages like NixOS does) let nix_darwin = is_symlink("/etc/nix/nix.conf").await.unwrap_or(false); OS::MacOS { nix_darwin, arch: arch.map(|s| s.to_string()), proc_translated: is_proc_translated::is_proc_translated(), } } os_info::Type::NixOS => OS::NixOS, _ => OS::Other(os_type), } } /// Return the label for nix-darwin or NixOS system pub fn nix_system_config_label(&self) -> Option { // TODO: This should return Markdown match self { OS::MacOS { nix_darwin, arch: _, proc_translated: _, } if *nix_darwin => Some("nix-darwin configuration".to_string()), OS::NixOS => Some("nixos configuration".to_string()), _ => None, } } /// Return the label for where Nix is configured pub fn nix_config_label(&self) -> String { self.nix_system_config_label() .unwrap_or("/etc/nix/nix.conf".to_string()) } } /// The installer used to install Nix (applicable only for non-NixOS systems) #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] #[serde(tag = "type")] pub enum NixInstaller { /// The Determinate Systems installer DetSys(super::detsys_installer::DetSysNixInstaller), /// Either offical installer or from a different package manager Other, } impl Display for NixInstaller { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { NixInstaller::DetSys(installer) => write!(f, "{}", installer), NixInstaller::Other => { write!(f, "Unknown installer") } } } } impl NixInstaller { /// Detect the Nix installer pub fn detect() -> Result { match super::detsys_installer::DetSysNixInstaller::detect()? { Some(installer) => Ok(NixInstaller::DetSys(installer)), None => Ok(NixInstaller::Other), } } } /// Errors while trying to fetch [NixEnv] #[derive(thiserror::Error, Debug)] pub enum NixEnvError { /// Unable to find user groups #[error("Failed to fetch groups: {0}")] GroupsError(std::io::Error), /// Unable to find /nix volume #[error("Unable to find root disk or /nix volume")] NoDisk, /// Unable to find Nix installer #[error("Failed to detect Nix installer: {0}")] InstallerError(#[from] super::detsys_installer::BadInstallerVersion), /// `nix` command not found #[error("`nix` not found in PATH: {0}")] NixPathError(#[from] which::Error), } /// Convert bytes to a closest [ByteSize] /// /// Useful for displaying disk space and memory which are typically in GBs / TBs fn to_bytesize(bytes: u64) -> ByteSize { let kb = bytes / 1024; let mb = kb / 1024; let gb = mb / 1024; if gb > 0 { ByteSize::gib(gb) } else if mb > 0 { ByteSize::mib(mb) } else if kb > 0 { ByteSize::kib(kb) } else { ByteSize::b(bytes) } } /// Test for [to_bytesize] #[test] fn test_to_bytesize() { assert_eq!(to_bytesize(0), ByteSize::b(0)); assert_eq!(to_bytesize(1), ByteSize::b(1)); assert_eq!(to_bytesize(1023), ByteSize::b(1023)); assert_eq!(to_bytesize(1024), ByteSize::kib(1)); assert_eq!(to_bytesize(1024 * 1024), ByteSize::mib(1)); assert_eq!(to_bytesize(1024 * 1024 * 1024), ByteSize::gib(1)); } ================================================ FILE: crates/nix_rs/src/flake/command.rs ================================================ //! Nix commands for working with flakes use std::{ collections::{BTreeMap, HashMap}, path::PathBuf, }; use nonempty::NonEmpty; use serde::{Deserialize, Serialize}; use tokio::process::Command; use crate::command::{CommandError, NixCmd, NixCmdError}; use super::url::FlakeUrl; /// Run `nix run` on the given flake app. pub async fn run( nixcmd: &NixCmd, opts: &FlakeOptions, url: &FlakeUrl, args: Vec, ) -> Result<(), CommandError> { nixcmd .run_with(&["run"], |cmd| { opts.use_in_command(cmd); cmd.args([url.to_string(), "--".to_string()]); cmd.args(args); }) .await?; Ok(()) } /// Run `nix develop` on the given flake devshell. pub async fn develop( nixcmd: &NixCmd, opts: &FlakeOptions, url: &FlakeUrl, command: NonEmpty, ) -> Result<(), CommandError> { nixcmd .run_with(&["develop"], |cmd| { opts.use_in_command(cmd); cmd.args([url.to_string(), "-c".to_string()]); cmd.args(command); }) .await?; Ok(()) } /// Run `nix build` pub async fn build( cmd: &NixCmd, opts: &FlakeOptions, url: FlakeUrl, ) -> Result, NixCmdError> { let stdout: Vec = cmd .run_with_returning_stdout(&["build"], |c| { opts.use_in_command(c); c.args(["--no-link", "--json", &url]); }) .await?; let v = serde_json::from_slice::>(&stdout)?; Ok(v) } /// Run `nix flake lock` pub async fn lock( cmd: &NixCmd, opts: &FlakeOptions, args: &[&str], url: &FlakeUrl, ) -> Result<(), NixCmdError> { cmd.run_with(&["flake", "lock"], |c| { c.arg(url.to_string()); opts.use_in_command(c); c.args(args); }) .await?; Ok(()) } /// Run `nix flake check` pub async fn check(cmd: &NixCmd, opts: &FlakeOptions, url: &FlakeUrl) -> Result<(), NixCmdError> { cmd.run_with(&["flake", "check"], |c| { c.arg(url.to_string()); opts.use_in_command(c); }) .await?; Ok(()) } /// A path built by nix, as returned by --print-out-paths #[derive(Serialize, Deserialize)] pub struct OutPath { /// The derivation that built these outputs #[serde(rename = "drvPath")] pub drv_path: PathBuf, /// Build outputs pub outputs: HashMap, } impl OutPath { /// Return the first build output, if any pub fn first_output(&self) -> Option<&PathBuf> { self.outputs.values().next() } } /// Nix CLI options when interacting with a flake #[derive(Debug, Clone, Default)] pub struct FlakeOptions { /// The --override-input option to pass to Nix pub override_inputs: BTreeMap, /// Pass --no-write-lock-file pub no_write_lock_file: bool, /// The directory from which to run our nix command (such that relative flake URLs resolve properly) pub current_dir: Option, } impl FlakeOptions { /// Apply these options to a (Nix) [Command] pub fn use_in_command(&self, cmd: &mut Command) { if let Some(curent_dir) = &self.current_dir { cmd.current_dir(curent_dir); } for (name, url) in self.override_inputs.iter() { cmd.arg("--override-input").arg(name).arg(url.to_string()); } if self.no_write_lock_file { cmd.arg("--no-write-lock-file"); } } } ================================================ FILE: crates/nix_rs/src/flake/eval.rs ================================================ //! Work with `nix eval` use std::process::Stdio; use crate::command::{CommandError, NixCmd, NixCmdError}; use super::{command::FlakeOptions, url::FlakeUrl}; /// Run `nix eval --json` and parse its JSON pub async fn nix_eval( nixcmd: &NixCmd, opts: &FlakeOptions, url: &FlakeUrl, ) -> Result where T: serde::de::DeserializeOwned, { nix_eval_(nixcmd, opts, url, false).await } /// Like [nix_eval] but return `None` if the attribute is missing pub async fn nix_eval_maybe( cmd: &NixCmd, opts: &FlakeOptions, url: &FlakeUrl, ) -> Result, NixCmdError> where T: Default + serde::de::DeserializeOwned, { let result = nix_eval_(cmd, opts, url, true).await; match result { Ok(v) => Ok(Some(v)), Err(err) if error_is_missing_attribute(&err) => { Ok(None) // Attr is missing } Err(err) => Err(err), } } async fn nix_eval_( nixcmd: &NixCmd, opts: &FlakeOptions, url: &FlakeUrl, capture_stderr: bool, ) -> Result where T: serde::de::DeserializeOwned, { let stdout = nixcmd .run_with(&["eval"], |cmd| { cmd.stdout(Stdio::piped()); if capture_stderr { cmd.stderr(Stdio::piped()); } cmd.args(["--json"]); opts.use_in_command(cmd); cmd.arg(url.to_string()); // Avoid Nix from dumping logs related to `--override-input` use. Yes, this requires *double* use of `--quiet`. cmd.args(["--quiet", "--quiet"]); }) .await?; let v = serde_json::from_slice::(&stdout)?; Ok(v) } /// Check that [NixCmdError] is a missing attribute error fn error_is_missing_attribute(err: &NixCmdError) -> bool { if let NixCmdError::CmdError(CommandError::ProcessFailed { stderr, .. }) = err { if stderr.contains("does not provide attribute") { return true; } } false } ================================================ FILE: crates/nix_rs/src/flake/functions/README.md ================================================ ## Rust + Nix FFI https://github.com/srid/devour-flake introduced the idea of defining "functions" in Nix flake, that can be called from any external process. The flake's package derivation acts as the function "body", with its `inputs` acting as function "arguments"; the built output of that derivation is the function's "output". This Rust package, `nix_rs::flake::functions`, provides the Rust FFI adapter to work with such Nix functions in Rust, using simpler API. You define your input & output structs in Rust, implement the `FlakeFn` trait and voilà ! In effect, this generalizes `devour-flake` to be able to define such functions. See `devour_flake.rs` in this repo for an example. ## Inspiration - [devour-flake](https://github.com/srid/devour-flake): Original use of this pattern. - [inspect](https://github.com/DeterminateSystems/inspect) works similar to `devour-flake`, but is tied to flake schemas, and the function body is hardcoded (just as `devour-flake`). ================================================ FILE: crates/nix_rs/src/flake/functions/addstringcontext/flake.nix ================================================ { inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; flake-parts.url = "github:hercules-ci/flake-parts"; jsonfile = { flake = false; }; }; outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } { systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; perSystem = { pkgs, lib, ... }: let json = builtins.fromJSON (builtins.readFile inputs.jsonfile); jsonWithPathContext = lib.flip lib.mapAttrsRecursive json (k: v: if lib.lists.last k == "outPaths" || lib.lists.last k == "allDeps" then builtins.map (path: builtins.storePath path) v else v ); in { packages.default = pkgs.writeText "addstringcontext.json" (builtins.toJSON jsonWithPathContext); }; }; } ================================================ FILE: crates/nix_rs/src/flake/functions/addstringcontext/mod.rs ================================================ //! Transform a JSON file with Nix store paths such that the resultant JSON file path will track those paths as dependencies. This requires use of `--impure`. /// /// Only values of keys called `outPaths` in the JSON will be transformed. /// /// https://nix.dev/manual/nix/2.23/language/string-context use super::core::FlakeFn; use crate::{command::NixCmd, flake::url::FlakeUrl}; use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::{path::Path, path::PathBuf}; struct AddStringContextFn; lazy_static! { /// URL to our flake function static ref FLAKE_ADDSTRINGCONTEXT: FlakeUrl = { let path = env!("FLAKE_ADDSTRINGCONTEXT"); Into::::into(Path::new(path)).with_attr("default") }; } impl FlakeFn for AddStringContextFn { type Input = AddStringContextInput; type Output = Value; // We don't care to parse the output fn flake() -> &'static FlakeUrl { &FLAKE_ADDSTRINGCONTEXT } } /// Input to FlakeMetadata #[derive(Serialize, Deserialize, Debug)] struct AddStringContextInput { /// The JSON file to process jsonfile: FlakeUrl, } /// Add string context to `outPath`s in a JSON file. /// /// Resultant JSON file will track those paths as dependencies. Additionally, an out-link will be created at `out_link` if provided. pub async fn addstringcontext( cmd: &NixCmd, jsonfile: &Path, out_link: Option<&Path>, ) -> Result { const IMPURE: bool = true; // Our flake.nix uses builtin.storePath // We have to use relative paths to avoid a Nix issue on macOS witih /tmp paths. let jsonfile_parent = jsonfile.parent().unwrap(); let jsonfile_name = jsonfile.file_name().unwrap().to_string_lossy(); let pwd = Some(jsonfile_parent); let current_pwd = std::env::current_dir()?; let out_link_absolute: Option = out_link.map(|p| current_pwd.join(p)); let input = AddStringContextInput { jsonfile: FlakeUrl(format!("path:{}", jsonfile_name)), }; let (path_with_string_context, _json_value) = AddStringContextFn::call( cmd, IMPURE, pwd, out_link_absolute.as_ref().map(PathBuf::as_ref), vec![], input, ) .await?; Ok(path_with_string_context) } ================================================ FILE: crates/nix_rs/src/flake/functions/core.rs ================================================ //! Flake function trait use crate::{command::NixCmd, flake::url::FlakeUrl}; use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::{ env, ffi::OsString, os::unix::ffi::OsStringExt, path::{Path, PathBuf}, process::Stdio, }; lazy_static! { static ref TRUE_FLAKE: FlakeUrl = { let path = env!("TRUE_FLAKE"); Into::::into(Path::new(path)) }; static ref FALSE_FLAKE: FlakeUrl = { let path = env!("FALSE_FLAKE"); Into::::into(Path::new(path)) }; } /// Trait for flake functions pub trait FlakeFn { /// Input type, corresponding to flake inputs /// /// A field named `flake` will be treated special (extra args' --override-inputs operates on this flake) type Input; /// Output generated by building the flake fn type Output; /// Get the flake URL referencing this function fn flake() -> &'static FlakeUrl; /// Initialize the type after reading from Nix build fn init(_out: &mut Self::Output) {} /// Call the flake function, taking `Self::Input`, returning `Self::Output` along with the built store path output as `PathBuf`. /// /// The store path output can be useful for further processing, if you need it with its entire closure (for e.g., to `nix copy` everything in `Self::Output` at once). /// /// Arguments: /// - `nixcmd`: The Nix command to use /// - `verbose`: Whether to avoid the --override-input noise suppression. /// - `extra_args`: Extra arguments to pass to `nix build`. --override-input is treated specially, to account for the flake input named `flake` (as defined in `Self::Input`) /// - `input`: The input arguments to the flake function. fn call( nixcmd: &NixCmd, // FIXME: Don't do this; instead take dyn trait options impure: bool, pwd: Option<&Path>, m_out_link: Option<&Path>, extra_args: Vec, input: Self::Input, ) -> impl std::future::Future> + Send where Self::Input: Serialize + Send + Sync, Self::Output: Sync + for<'de> Deserialize<'de>, { async move { let mut cmd = nixcmd.command(&["build"]); cmd.args([Self::flake(), "-L", "--print-out-paths"]); if impure { cmd.arg("--impure"); } if let Some(out_link) = m_out_link { cmd.arg("--out-link"); cmd.arg(out_link); } else { cmd.arg("--no-link"); } let input_vec = to_vec(&input); for (k, v) in input_vec { cmd.arg("--override-input"); cmd.arg(k); cmd.arg(v); } cmd.args(transform_override_inputs(&extra_args)); if let Some(pwd) = pwd { cmd.current_dir(pwd); } crate::command::trace_cmd(&cmd); let output_fut = cmd.stdout(Stdio::piped()).spawn()?; let output = output_fut.wait_with_output().await?; if output.status.success() { let store_path = PathBuf::from(OsString::from_vec(output.stdout.trim_ascii_end().into())); let mut v: Self::Output = serde_json::from_reader(std::fs::File::open(&store_path)?)?; Self::init(&mut v); Ok((store_path, v)) } else { Err(Error::NixBuildFailed(output.status.code())) } } } } /// Transform `--override-input` arguments to use `flake/` prefix, which /// devour_flake expects. /// /// NOTE: This assumes that Input struct contains a field named exactly "flake" referring to the flake. We should probably be smart about this. fn transform_override_inputs(args: &[String]) -> Vec { let mut new_args = Vec::with_capacity(args.len()); let mut iter = args.iter().peekable(); while let Some(arg) = iter.next() { new_args.push(arg.clone()); if arg == "--override-input" { if let Some(next_arg) = iter.next() { new_args.push(format!("flake/{}", next_arg)); } } } new_args } /// Convert a struct of uniform value types (Option allowed, however) into a vector of fields. The value should be of String kind. fn to_vec(value: &T) -> Vec<(String, String)> where T: Serialize, { let map = serde_json::to_value(value) .unwrap() .as_object() .unwrap_or_else(|| panic!("Bad struct for FlakeFn")) .clone(); map.into_iter() .filter_map(|(k, v)| match v { Value::String(s) => Some((k, s.to_string())), Value::Bool(b) => Some(( k, if b { TRUE_FLAKE.to_string() } else { FALSE_FLAKE.to_string() } .to_string(), )), _ => None, }) .collect() } /// Errors associated with `FlakeFn::call` #[derive(thiserror::Error, Debug)] pub enum Error { /// IO error #[error("IO error: {0}")] IOError(#[from] std::io::Error), /// Non-zero exit code #[error("`nix build` failed; exit code: {0:?}")] NixBuildFailed(Option), /// JSON error #[error("JSON error: {0}")] JSONError(#[from] serde_json::Error), } ================================================ FILE: crates/nix_rs/src/flake/functions/metadata/flake.nix ================================================ { inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; flake-parts.url = "github:hercules-ci/flake-parts"; flake = { }; include-inputs = { }; }; outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } { systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; perSystem = { pkgs, lib, ... }: let include-inputs = inputs.include-inputs.value; fn = if include-inputs then "nix_rs-metadata-full.json" else "nix_rs-metadata-flakeonly.json"; in { packages = { default = pkgs.writeText fn (builtins.toJSON { # *All* nested inputs are flattened into a single list of inputs. inputs = if !include-inputs then null else let inputsFor = visited: prefix: f: let here = builtins.unsafeDiscardStringContext "${f.outPath}"; in # Keep track of visited nodes to workaround a nasty Nix design wart that leads to infinite recursion otherwise. # https://github.com/NixOS/nix/issues/7807 # https://github.com/juspay/omnix/pull/389 lib.optionals (!lib.hasAttr here visited) (lib.concatLists (lib.mapAttrsToList (k: v: [{ name = "${prefix}__${k}"; path = v.outPath; }] ++ (lib.optionals (lib.hasAttr "inputs" v)) (inputsFor (visited // { "${here}" = true; }) "${prefix}/${k}" v)) f.inputs)); in inputsFor { } "flake" inputs.flake; flake = inputs.flake.outPath; }); }; }; }; } ================================================ FILE: crates/nix_rs/src/flake/functions/metadata/mod.rs ================================================ //! Retrieve metadata for a flake. use super::core::FlakeFn; use crate::{command::NixCmd, flake::url::FlakeUrl}; use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; use std::{path::Path, path::PathBuf}; /// Flake metadata computed in Nix. pub struct FlakeMetadataFn; lazy_static! { /// URL to our flake function static ref FLAKE_METADATA: FlakeUrl = { let path = env!("FLAKE_METADATA"); Into::::into(Path::new(path)).with_attr("default") }; } impl FlakeFn for FlakeMetadataFn { type Input = FlakeMetadataInput; type Output = FlakeMetadata; fn flake() -> &'static FlakeUrl { &FLAKE_METADATA } } /// Input to FlakeMetadata #[derive(Serialize, Deserialize, Debug)] pub struct FlakeMetadataInput { /// The flake to operate on pub flake: FlakeUrl, /// Included flake inputs transitively in the result /// /// NOTE: This makes evaluation more expensive. #[serde(rename = "include-inputs")] pub include_inputs: bool, } /// Flake metadata /// /// See [Nix doc](https://nix.dev/manual/nix/2.18/command-ref/new-cli/nix3-flake-metadata) #[derive(Serialize, Deserialize, Debug)] pub struct FlakeMetadata { /// Store path to this flake pub flake: PathBuf, /// Store path to each flake input /// /// Only available if `FlakeInput::include_inputs` is enabled. pub inputs: Option>, } /// A flake input #[derive(Serialize, Deserialize, Debug)] pub struct FlakeInput { /// Unique identifier pub name: String, /// Local path to the input pub path: PathBuf, } impl FlakeMetadata { /// Get the [FlakeMetadata] for the given flake pub async fn from_nix( cmd: &NixCmd, input: FlakeMetadataInput, ) -> Result<(PathBuf, FlakeMetadata), super::core::Error> { FlakeMetadataFn::call(cmd, false, None, None, vec![], input).await } } ================================================ FILE: crates/nix_rs/src/flake/functions/mod.rs ================================================ //! Calling Nix functions (defined in a flake) from Rust, as if to provide FFI. // // This model provides a simpler alternative to Flake Schemas, but it can also do more than Flake Schemas can (such as building derivations). pub mod addstringcontext; pub mod core; pub mod metadata; ================================================ FILE: crates/nix_rs/src/flake/mod.rs ================================================ //! Rust module for Nix flakes pub mod command; pub mod eval; pub mod functions; pub mod outputs; pub mod schema; pub mod system; pub mod url; use schema::FlakeSchemas; use serde::{Deserialize, Serialize}; use system::System; use tracing::instrument; use self::{outputs::FlakeOutputs, url::FlakeUrl}; use crate::{ command::{NixCmd, NixCmdError}, config::NixConfig, }; /// All the information about a Nix flake #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Flake { /// The flake url which this struct represents pub url: FlakeUrl, /// Flake outputs derived from [FlakeSchemas] pub output: FlakeOutputs, // TODO: Add `nix flake metadata` info. } impl Flake { /// Get [Flake] info for the given flake url #[instrument(name = "flake", skip(nix_cmd))] pub async fn from_nix( nix_cmd: &NixCmd, nix_config: &NixConfig, url: FlakeUrl, ) -> Result { let schemas = FlakeSchemas::from_nix(nix_cmd, &url, &nix_config.system.value).await?; Ok(Flake { url, output: schemas.into(), }) } } ================================================ FILE: crates/nix_rs/src/flake/outputs.rs ================================================ //! Nix flake outputs use serde::{Deserialize, Serialize}; use std::collections::HashMap; use super::schema::{FlakeSchemas, Val}; /// Outputs of a flake #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(untagged)] pub enum FlakeOutputs { /// Terminal value that is not an attrset. Val(Val), /// An attrset of nested [FlakeOutputs] Attrset(HashMap), } impl FlakeOutputs { /// Get the terminal value pub fn get_val(&self) -> Option<&Val> { match self { Self::Val(v) => Some(v), _ => None, } } /// Get the attrset pub fn get_attrset(&self) -> Option<&HashMap> { match self { Self::Val(_) => None, Self::Attrset(map) => Some(map), } } /// Get the attrset as a vector of key-value pairs /// /// **NOTE**: Only terminal values are included! pub fn get_attrset_of_val(&self) -> Vec<(String, Val)> { self.get_attrset().map_or(vec![], |map| { map.iter() .filter_map(|(k, v)| v.get_val().map(|val| (k.clone(), val.clone()))) .collect() }) } /// Lookup the given path, returning a reference to the value if it exists. /// /// # Example /// ```no_run /// let tree : &nix_rs::flake::outputs::FlakeOutputs = todo!(); /// let val = tree.get_by_path(&["aarch64-darwin", "default"]); /// ``` pub fn get_by_path(&self, path: &[&str]) -> Option<&Self> { let mut current = self; for key in path { let map = current.get_attrset()?; current = map.get(*key)?; } Some(current) } } impl From for FlakeOutputs { fn from(schema: FlakeSchemas) -> Self { schema.to_flake_outputs() } } ================================================ FILE: crates/nix_rs/src/flake/schema.rs ================================================ //! Nix flake-schemas use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; use std::{ collections::{BTreeMap, HashMap}, fmt::Display, path::Path, }; use crate::system_list::SystemsListFlakeRef; use super::{command::FlakeOptions, eval::nix_eval, outputs::FlakeOutputs, url::FlakeUrl}; lazy_static! { /// Flake URL of the default flake schemas /// /// We expect this environment to be set in Nix build and shell. pub static ref DEFAULT_FLAKE_SCHEMAS: FlakeUrl = { Into::::into(Path::new(env!("DEFAULT_FLAKE_SCHEMAS"))) }; /// Flake URL of the flake that defines functions for inspecting flake outputs /// /// We expect this environment to be set in Nix build and shell. pub static ref INSPECT_FLAKE: FlakeUrl = { Into::::into(Path::new(env!("INSPECT_FLAKE"))) }; } /// Represents the schema of a given flake evaluated using [static@INSPECT_FLAKE] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FlakeSchemas { /// Each key in the map represents either a top-level flake output or other metadata (e.g. `docs`) pub inventory: HashMap, } /// A tree-like structure representing each flake output or metadata in [FlakeSchemas] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(untagged)] pub enum InventoryItem { /// Represents a terminal node in the tree Leaf(Leaf), /// Represents a non-terminal node in the tree Attrset(HashMap), } impl FlakeSchemas { /// Get the [FlakeSchemas] for the given flake /// /// This uses [static@INSPECT_FLAKE] and [static@DEFAULT_FLAKE_SCHEMAS] pub async fn from_nix( nix_cmd: &crate::command::NixCmd, flake_url: &super::url::FlakeUrl, system: &super::System, ) -> Result { let inspect_flake: FlakeUrl = INSPECT_FLAKE // Why `exculdingOutputPaths`? // This function is much faster than `includingOutputPaths` and also solves // Also See: https://github.com/DeterminateSystems/inspect/blob/7f0275abbdc46b3487ca69e2acd932ce666a03ff/flake.nix#L139 // // // Note: We might need to use `includingOutputPaths` in the future, when replacing `devour-flake`. // In which case, `om ci` and `om show` can invoke the appropriate function from `INSPECT_FLAKE`. // .with_attr("contents.excludingOutputPaths"); let systems_flake = SystemsListFlakeRef::from_known_system(system) // TODO: don't use unwrap .unwrap() .0 .clone(); let flake_opts = FlakeOptions { no_write_lock_file: true, override_inputs: BTreeMap::from_iter([ ( "flake-schemas".to_string(), DEFAULT_FLAKE_SCHEMAS.to_owned(), ), ("flake".to_string(), flake_url.clone()), ("systems".to_string(), systems_flake), ]), ..Default::default() }; let v = nix_eval::(nix_cmd, &flake_opts, &inspect_flake).await?; Ok(v) } /// Convert [FlakeSchemas] to [FlakeOutputs] pub(crate) fn to_flake_outputs(&self) -> FlakeOutputs { FlakeOutputs::Attrset( self.inventory .iter() .filter_map(|(k, v)| Some((k.clone(), v.to_flake_outputs()?))) .collect(), ) } } impl InventoryItem { fn to_flake_outputs(&self) -> Option { match self { Self::Leaf(leaf) => leaf.get_val().cloned().map(FlakeOutputs::Val), Self::Attrset(map) => { if let Some(children) = map.get("children") { children.to_flake_outputs() } else { let filtered: HashMap<_, _> = map .iter() .filter_map(|(k, v)| Some((k.clone(), v.to_flake_outputs()?))) .collect(); if filtered.is_empty() { None } else { Some(FlakeOutputs::Attrset(filtered)) } } } } } } /// A terminal value of a flake schema #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(untagged)] pub enum Leaf { #[allow(missing_docs)] Val(Val), /// Represents description for a flake output /// (e.g. `Doc` for `formatter` will be "The `formatter` output specifies the package to use to format the project.") Doc(String), } impl Leaf { /// Get the [Val] if any fn get_val(&self) -> Option<&Val> { match self { Self::Val(v) => Some(v), _ => None, } } } /// A terminal value of a flake output #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Val { #[serde(rename = "what")] /// Represents the type of the flake output pub type_: Type, /// If the flake output is a derivation, this will be the name of the derivation pub derivation_name: Option, /// A short description derived from `meta.description` of the derivation with [Val::derivation_name] pub short_description: Option, } impl Default for Val { fn default() -> Self { Self { type_: Type::Unknown, derivation_name: None, short_description: None, } } } /// The type of a flake output [Val] /// /// These types can differ based on [static@DEFAULT_FLAKE_SCHEMAS]. /// The types here are based on /// For example, see [NixosModule type](https://github.com/DeterminateSystems/flake-schemas/blob/0a5c42297d870156d9c57d8f99e476b738dcd982/flake.nix#L268) #[allow(missing_docs)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum Type { #[serde(rename = "NixOS module")] NixosModule, #[serde(rename = "NixOS configuration")] NixosConfiguration, #[serde(rename = "nix-darwin configuration")] DarwinConfiguration, #[serde(rename = "package")] Package, #[serde(rename = "development environment")] DevShell, #[serde(rename = "CI test")] Check, #[serde(rename = "app")] App, #[serde(rename = "template")] Template, #[serde(other)] Unknown, } impl Type { /// Get the icon for this type pub fn to_icon(&self) -> &'static str { match self { Self::NixosModule => "❄️", Self::NixosConfiguration => "🔧", Self::DarwinConfiguration => "🍎", Self::Package => "📦", Self::DevShell => "🐚", Self::Check => "🧪", Self::App => "📱", Self::Template => "🏗️", Self::Unknown => "❓", } } } impl Display for Type { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&format!("{:?}", self)) } } ================================================ FILE: crates/nix_rs/src/flake/system.rs ================================================ //! Nix system types use std::{ convert::Infallible, fmt::{Display, Formatter}, str::FromStr, }; use serde::{Deserialize, Serialize}; use serde_with::{DeserializeFromStr, SerializeDisplay}; /// The system for which a derivation will build /// /// The enum includes the four standard systems, as well as a fallback to /// capture the rest. #[derive( Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, SerializeDisplay, DeserializeFromStr, )] pub enum System { /// macOS system Darwin(Arch), /// Linux system Linux(Arch), /// Other system Other(String), } /// CPU architecture in the system #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub enum Arch { /// aarch64 Aarch64, /// x86_64 X86_64, } impl FromStr for System { type Err = Infallible; fn from_str(s: &str) -> Result { Ok(Self::from(s)) } } impl From<&str> for System { fn from(s: &str) -> Self { match s { "aarch64-linux" => Self::Linux(Arch::Aarch64), "x86_64-linux" => Self::Linux(Arch::X86_64), "x86_64-darwin" => Self::Darwin(Arch::X86_64), "aarch64-darwin" => Self::Darwin(Arch::Aarch64), _ => Self::Other(s.to_string()), } } } impl From for System { fn from(s: String) -> Self { Self::from(s.as_str()) } } impl AsRef for System { fn as_ref(&self) -> &str { match self { System::Linux(Arch::Aarch64) => "aarch64-linux", System::Linux(Arch::X86_64) => "x86_64-linux", System::Darwin(Arch::X86_64) => "x86_64-darwin", System::Darwin(Arch::Aarch64) => "aarch64-darwin", System::Other(s) => s, } } } impl From for String { fn from(s: System) -> Self { s.as_ref().to_string() } } impl Display for System { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.as_ref()) } } impl System { /// Return the human readable title for the Nix system pub fn human_readable(&self) -> String { match self { System::Linux(arch) => format!("Linux ({})", arch.human_readable()), System::Darwin(arch) => format!("macOS ({})", arch.human_readable()), System::Other(s) => s.clone(), } } } impl Arch { /// Return the human readable title for the CPU architecture pub fn human_readable(&self) -> &'static str { match self { Self::Aarch64 => "ARM", Self::X86_64 => "Intel", } } } ================================================ FILE: crates/nix_rs/src/flake/url/attr.rs ================================================ //! Work with flake attributes use serde::{Deserialize, Serialize}; /// The (optional) attribute output part of a [super::FlakeUrl] /// /// Example: `foo` in `.#foo`. #[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct FlakeAttr(pub Option); impl FlakeAttr { /// Create a new [FlakeAttr] pub fn new(attr: &str) -> Self { FlakeAttr(Some(attr.to_owned())) } /// A missing flake attribute pub fn none() -> Self { FlakeAttr(None) } /// Get the attribute name. /// /// If no such attribute exists, return "default". pub fn get_name(&self) -> String { self.0.clone().unwrap_or_else(|| "default".to_string()) } /// Whether an explicit attribute is not set pub fn is_none(&self) -> bool { self.0.is_none() } /// Return nested attrs if the user specified one is separated by '.' pub fn as_list(&self) -> Vec { self.0 .clone() .map(|s| s.split('.').map(|s| s.to_string()).collect()) .unwrap_or_default() } } ================================================ FILE: crates/nix_rs/src/flake/url/core.rs ================================================ //! Flake URL types //! //! See use std::{ fmt::{Display, Formatter}, ops::Deref, path::{Path, PathBuf}, str::FromStr, }; use serde::{Deserialize, Serialize}; use crate::{ command::NixCmd, flake::functions::metadata::{FlakeMetadata, FlakeMetadataInput}, }; use super::attr::FlakeAttr; /// A flake URL /// /// See [syntax here](https://nixos.org/manual/nix/stable/command-ref/new-cli/nix3-flake.html#url-like-syntax). /// /// Use `FromStr` to parse a string into a `FlakeUrl`. Or `From` or `Into` if /// you know the URL is valid. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct FlakeUrl(pub String); impl AsRef for FlakeUrl { fn as_ref(&self) -> &str { &self.0 } } impl Deref for FlakeUrl { type Target = str; fn deref(&self) -> &Self::Target { &self.0 } } impl FlakeUrl { /// Return the local path if the flake URL is a local path /// /// Applicable only if the flake URL uses the [Path-like /// syntax](https://nixos.org/manual/nix/stable/command-ref/new-cli/nix3-flake.html#path-like-syntax) pub fn as_local_path(&self) -> Option<&Path> { let s = self.0.strip_prefix("path:").unwrap_or(&self.0); if s.starts_with('.') || s.starts_with('/') { // Strip query (`?..`) and attrs (`#..`) let s = s.split('?').next().unwrap_or(s); let s = s.split('#').next().unwrap_or(s); Some(Path::new(s)) } else { None } } /// Return the flake as local path. If the flake is a remote reference, catch it to local Nix store first. pub async fn as_local_path_or_fetch( &self, cmd: &NixCmd, ) -> Result { if let Some(path) = self.as_local_path() { Ok(path.to_path_buf()) } else { let (_, meta) = FlakeMetadata::from_nix( cmd, FlakeMetadataInput { flake: self.clone(), include_inputs: false, // Don't care about inputs }, ) .await?; Ok(meta.flake) } } /// Split the [super::attr::FlakeAttr] out of the [FlakeUrl] pub fn split_attr(&self) -> (Self, FlakeAttr) { match self.0.split_once('#') { Some((url, attr)) => (FlakeUrl(url.to_string()), FlakeAttr(Some(attr.to_string()))), None => (self.clone(), FlakeAttr(None)), } } /// Return the [super::attr::FlakeAttr] of the [FlakeUrl] pub fn get_attr(&self) -> FlakeAttr { self.split_attr().1 } /// Return the flake URL without the attribute pub fn without_attr(&self) -> Self { let (url, _) = self.split_attr(); url } /// Return the flake URL with the given attribute pub fn with_attr(&self, attr: &str) -> Self { let (url, _) = self.split_attr(); FlakeUrl(format!("{}#{}", url.0, attr)) } /// Return the flake URL pointing to the sub-flake pub fn sub_flake_url(&self, dir: String) -> FlakeUrl { if dir == "." { self.clone() } else if let Some(path) = self.as_local_path() { // Local path; just join the dir let path_with_dir = path.join(dir); FlakeUrl::from(path_with_dir) } else { // Non-path URL; append `dir` query parameter let mut url = self.0.clone(); if url.contains('?') { url.push_str("&dir="); } else { url.push_str("?dir="); } url.push_str(&dir); FlakeUrl(url) } } } impl From for FlakeUrl { fn from(path: PathBuf) -> Self { FlakeUrl::from(path.as_ref()) } } impl From<&Path> for FlakeUrl { fn from(path: &Path) -> Self { // We do not use `path:` here, because that will trigger copying to the Nix store. FlakeUrl(format!("{}", path.display())) } } impl FromStr for FlakeUrl { type Err = FlakeUrlError; fn from_str(s: &str) -> Result { let s = s.trim(); if s.is_empty() { Err(FlakeUrlError::Empty) } else { Ok(FlakeUrl(s.to_string())) } } } /// Error type for parsing a [FlakeUrl] #[derive(thiserror::Error, Debug)] pub enum FlakeUrlError { /// Empty string is not a valid Flake URL #[error("Empty string is not a valid Flake URL")] Empty, } impl Display for FlakeUrl { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_flake_url_and_attr() { let url = FlakeUrl("github:srid/nixci".to_string()); assert_eq!(url.split_attr(), (url.clone(), FlakeAttr(None))); assert_eq!(url.split_attr().1.as_list(), [] as [&str; 0]); let url = FlakeUrl("github:srid/nixci#extra-tests".to_string()); assert_eq!( url.split_attr(), ( FlakeUrl("github:srid/nixci".to_string()), FlakeAttr(Some("extra-tests".to_string())) ) ); assert_eq!( url.split_attr().1.as_list(), vec!["extra-tests".to_string()] ); let url = FlakeUrl(".#foo.bar.qux".to_string()); assert_eq!( url.split_attr(), ( FlakeUrl(".".to_string()), FlakeAttr(Some("foo.bar.qux".to_string())) ) ); assert_eq!( url.split_attr().1.as_list(), vec!["foo".to_string(), "bar".to_string(), "qux".to_string()] ) } #[test] fn test_as_local_path() { let url = FlakeUrl("github:srid/nixci".to_string()); assert_eq!(url.as_local_path(), None); let url = FlakeUrl(".".to_string()); assert_eq!(url.as_local_path().map(|p| p.to_str().unwrap()), Some(".")); let url = FlakeUrl("/foo".to_string()); assert_eq!(url.as_local_path(), Some(std::path::Path::new("/foo"))); let url = FlakeUrl("./foo?q=bar".to_string()); assert_eq!(url.as_local_path(), Some(std::path::Path::new("./foo"))); let url = FlakeUrl("./foo#attr".to_string()); assert_eq!(url.as_local_path(), Some(std::path::Path::new("./foo"))); let url = FlakeUrl("/foo?q=bar#attr".to_string()); assert_eq!(url.as_local_path(), Some(std::path::Path::new("/foo"))); let url = FlakeUrl("path:.".to_string()); assert_eq!(url.as_local_path(), Some(std::path::Path::new("."))); let url = FlakeUrl("path:./foo".to_string()); assert_eq!(url.as_local_path(), Some(std::path::Path::new("./foo"))); let url = FlakeUrl("path:./foo?q=bar".to_string()); assert_eq!(url.as_local_path(), Some(std::path::Path::new("./foo"))); let url = FlakeUrl("path:./foo#attr".to_string()); assert_eq!(url.as_local_path(), Some(std::path::Path::new("./foo"))); let url = FlakeUrl("path:/foo?q=bar#attr".to_string()); assert_eq!(url.as_local_path(), Some(std::path::Path::new("/foo"))); /* FIXME! let url = FlakeUrl("/project?dir=bar".to_string()); assert_eq!( url.as_local_path(), Some(std::path::Path::new("/project/bar")) ); */ } #[test] fn test_sub_flake_url() { // Path refs let url = FlakeUrl(".".to_string()); assert_eq!(url.sub_flake_url(".".to_string()), url.clone()); assert_eq!( url.sub_flake_url("sub".to_string()), FlakeUrl("./sub".to_string()) ); // URI refs let url = FlakeUrl("github:srid/nixci".to_string()); assert_eq!(url.sub_flake_url(".".to_string()), url.clone()); assert_eq!( url.sub_flake_url("dev".to_string()), FlakeUrl("github:srid/nixci?dir=dev".to_string()) ); } #[test] fn test_sub_flake_url_with_query() { let url = FlakeUrl("git+https://example.org/my/repo?ref=master".to_string()); assert_eq!(url.sub_flake_url(".".to_string()), url.clone()); assert_eq!( url.sub_flake_url("dev".to_string()), FlakeUrl("git+https://example.org/my/repo?ref=master&dir=dev".to_string()) ); } #[test] fn test_with_attr() { let url = FlakeUrl("github:srid/nixci".to_string()); assert_eq!( url.with_attr("foo"), FlakeUrl("github:srid/nixci#foo".to_string()) ); let url: FlakeUrl = "github:srid/nixci#foo".parse().unwrap(); assert_eq!( url.with_attr("bar"), FlakeUrl("github:srid/nixci#bar".to_string()) ); } } ================================================ FILE: crates/nix_rs/src/flake/url/mod.rs ================================================ //! Work with flake URLs pub mod attr; mod core; pub use core::*; ================================================ FILE: crates/nix_rs/src/info.rs ================================================ //! Information about the user's Nix installation use serde::{Deserialize, Serialize}; use tokio::sync::OnceCell; use crate::{config::NixConfig, env::NixEnv, version::NixVersion}; /// All the information about the user's Nix installation #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct NixInfo { /// Nix version string pub nix_version: NixVersion, /// nix.conf configuration pub nix_config: NixConfig, /// Environment in which Nix was installed pub nix_env: NixEnv, } static NIX_INFO: OnceCell> = OnceCell::const_new(); impl NixInfo { /// Get the once version of `NixInfo` pub async fn get() -> &'static Result { NIX_INFO .get_or_init(|| async { let nix_version = NixVersion::get().await.as_ref()?; let nix_config = NixConfig::get().await.as_ref()?; let info = NixInfo::new(*nix_version, nix_config.clone()).await?; Ok(info) }) .await } /// Determine [NixInfo] on the user's system pub async fn new( nix_version: NixVersion, nix_config: NixConfig, ) -> Result { let nix_env = NixEnv::detect().await?; Ok(NixInfo { nix_version, nix_config, nix_env, }) } } /// Error type for [NixInfo] #[derive(thiserror::Error, Debug)] pub enum NixInfoError { /// A [crate::command::NixCmdError] #[error("Nix command error: {0}")] NixCmdError(#[from] crate::command::NixCmdError), /// A [crate::command::NixCmdError] with a static lifetime #[error("Nix command error: {0}")] NixCmdErrorStatic(#[from] &'static crate::command::NixCmdError), /// A [crate::env::NixEnvError] #[error("Nix environment error: {0}")] NixEnvError(#[from] crate::env::NixEnvError), /// A [crate::config::NixConfigError] #[error("Nix config error: {0}")] NixConfigError(#[from] &'static crate::config::NixConfigError), } ================================================ FILE: crates/nix_rs/src/lib.rs ================================================ //! Rust crate to interact with Nix //! //! This crate exposes various types representing what nix command gives us, //! along with a `from_nix` command to evaluate them. #![warn(missing_docs)] pub mod arg; pub mod command; pub mod config; pub mod copy; pub mod detsys_installer; pub mod env; pub mod flake; pub mod info; pub mod refs; pub mod store; pub mod system_list; pub mod version; pub mod version_spec; ================================================ FILE: crates/nix_rs/src/refs.rs ================================================ //! Links to Nix manual and other documentation /// Link to information about the various Nix versions pub const RELEASE_HISTORY: &str = "https://nixos.org/manual/nix/stable/release-notes/release-notes.html"; ================================================ FILE: crates/nix_rs/src/store/command.rs ================================================ //! Rust wrapper for `nix-store` use std::path::{Path, PathBuf}; use crate::command::{CommandError, NixCmdError}; use serde::{Deserialize, Serialize}; use tempfile::TempDir; use thiserror::Error; use tokio::process::Command; use super::path::StorePath; /// The `nix-store` command /// See documentation for [nix-store](https://nixos.org/manual/nix/stable/command-ref/nix-store.html) #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] pub struct NixStoreCmd; impl NixStoreCmd { /// Get the associated [Command] pub fn command(&self) -> Command { let mut cmd = Command::new("nix-store"); cmd.kill_on_drop(true); cmd } } impl NixStoreCmd { /// Fetch all build and runtime dependencies of given derivation outputs. /// /// This is done by querying the deriver of each derivation output /// using [NixStoreCmd::nix_store_query_deriver] and then querying all /// dependencies of each deriver using /// [NixStoreCmd::nix_store_query_requisites_with_outputs]. Finally, all /// dependencies of each deriver are collected and returned as /// `Vec`. pub async fn fetch_all_deps( &self, out_paths: &[StorePath], ) -> Result, NixStoreCmdError> { let all_drvs = self.nix_store_query_deriver(out_paths).await?; let all_outs = self .nix_store_query_requisites_with_outputs(&all_drvs) .await?; Ok(all_outs) } /// Return the derivations used to build the given build output. pub async fn nix_store_query_deriver( &self, out_paths: &[StorePath], ) -> Result, NixStoreCmdError> { let mut cmd = self.command(); cmd.args(["--query", "--valid-derivers"]) .args(out_paths.iter().map(StorePath::as_path)); let stdout = run_awaiting_stdout(&mut cmd).await?; let drv_paths: Vec = String::from_utf8(stdout)? .lines() .map(PathBuf::from) .collect(); if drv_paths.contains(&PathBuf::from("unknown-deriver")) { return Err(NixStoreCmdError::UnknownDeriver); } Ok(drv_paths) } /// Given the derivation paths, this function recursively queries and return all /// of its dependencies in the Nix store. pub async fn nix_store_query_requisites_with_outputs( &self, drv_paths: &[PathBuf], ) -> Result, NixStoreCmdError> { let mut cmd = self.command(); cmd.args(["--query", "--requisites", "--include-outputs"]) .args(drv_paths); let stdout = run_awaiting_stdout(&mut cmd).await?; Ok(String::from_utf8(stdout)? .lines() .map(|line| StorePath::new(PathBuf::from(line))) .collect()) } /// Create a file in the Nix store such that it escapes garbage collection. /// /// Return the nix store path added. pub async fn add_file_permanently( &self, symlink: &Path, contents: &str, ) -> Result { let temp_dir = TempDir::with_prefix("omnix-ci-")?; let temp_file = temp_dir.path().join("om.json"); std::fs::write(&temp_file, contents)?; let path = self.nix_store_add(&temp_file).await?; self.nix_store_add_root(symlink, &[&path]).await?; Ok(path) } /// Run `nix-store --add` on the give path and return the store path added. pub async fn nix_store_add(&self, path: &Path) -> Result { let mut cmd = self.command(); cmd.arg("--add"); // nix-store is unable to accept absolute paths if it involves a symlink // https://github.com/juspay/omnix/issues/363 // To workaround this, we pass the file directly. if let Some(parent) = path.parent() { cmd.current_dir(parent); cmd.arg(path.file_name().unwrap()); } else { cmd.arg(path); } let stdout = run_awaiting_stdout(&mut cmd).await?; Ok(StorePath::new(PathBuf::from( String::from_utf8(stdout)?.trim_end(), ))) } /// Run `nix-store --add-root` on the given paths and return the store path added. pub async fn nix_store_add_root( &self, symlink: &Path, paths: &[&StorePath], ) -> Result<(), NixStoreCmdError> { let mut cmd = self.command(); cmd.arg("--add-root") .arg(symlink) .arg("--realise") .args(paths); run_awaiting_stdout(&mut cmd).await?; Ok(()) } } async fn run_awaiting_stdout(cmd: &mut Command) -> Result, NixStoreCmdError> { crate::command::trace_cmd(cmd); let out = cmd.output().await?; if out.status.success() { Ok(out.stdout) } else { let stderr = String::from_utf8_lossy(&out.stderr).to_string(); let exit_code = out.status.code(); Err(CommandError::ProcessFailed { stderr, exit_code }.into()) } } /// `nix-store` command errors #[derive(Error, Debug)] pub enum NixStoreCmdError { /// A [NixCmdError] #[error(transparent)] NixCmdError(#[from] NixCmdError), /// nix-store returned "unknown-deriver" #[error("Unknown deriver")] UnknownDeriver, } impl From for NixStoreCmdError { fn from(err: std::io::Error) -> Self { let cmd_error: CommandError = err.into(); cmd_error.into() } } impl From for NixStoreCmdError { fn from(err: std::string::FromUtf8Error) -> Self { let cmd_error: CommandError = err.into(); cmd_error.into() } } impl From for NixStoreCmdError { fn from(err: CommandError) -> Self { let nixcmd_error: NixCmdError = err.into(); nixcmd_error.into() } } ================================================ FILE: crates/nix_rs/src/store/mod.rs ================================================ //! Dealing with the Nix store pub mod command; pub mod path; pub mod uri; ================================================ FILE: crates/nix_rs/src/store/path.rs ================================================ //! Store path management use std::{ convert::Infallible, fmt, path::{Path, PathBuf}, str::FromStr, }; use serde_with::{DeserializeFromStr, SerializeDisplay}; /// Represents a path in the Nix store, see: #[derive( Debug, Ord, PartialOrd, Eq, PartialEq, Clone, Hash, DeserializeFromStr, SerializeDisplay, )] pub enum StorePath { /// Derivation path (ends with `.drv`). Drv(PathBuf), /// Other paths in the Nix store, such as build outputs. /// This won't be a derivation path. Other(PathBuf), } impl FromStr for StorePath { type Err = Infallible; fn from_str(s: &str) -> Result { Ok(StorePath::new(PathBuf::from(s))) } } impl AsRef for StorePath { fn as_ref(&self) -> &Path { self.as_path().as_ref() } } impl AsRef for StorePath { fn as_ref(&self) -> &std::ffi::OsStr { self.as_path().as_os_str() } } impl StorePath { /// Create a new `StorePath` from the given path pub fn new(path: PathBuf) -> Self { if path.ends_with(".drv") { StorePath::Drv(path) } else { StorePath::Other(path) } } /// Drop store path type distinction, returning the inner path. pub fn as_path(&self) -> &PathBuf { match self { StorePath::Drv(p) => p, StorePath::Other(p) => p, } } } impl fmt::Display for StorePath { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.as_path().display()) } } ================================================ FILE: crates/nix_rs/src/store/uri.rs ================================================ //! Store URI management use std::{fmt, str::FromStr}; use serde::{Deserialize, Serialize}; use thiserror::Error; use url::Url; /// Refers to a Nix store somewhere. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum StoreURI { /// Nix store accessible over SSH. SSH(SSHStoreURI, Opts), } /// User passed options for a store URI #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Opts { /// Whether to copy all flake inputs recursively /// /// If disabled, we copy only the flake source itself. Enabling this option is useful when there are private Git inputs but the target machine does not have access to them. #[serde(rename = "copy-inputs", default = "bool::default")] pub copy_inputs: bool, } /// Remote SSH store URI #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SSHStoreURI { /// SSH user pub user: Option, /// SSH host pub host: String, } /// Error parsing a store URI #[derive(Error, Debug)] pub enum StoreURIParseError { /// Parse error #[error(transparent)] ParseError(#[from] url::ParseError), /// Unsupported scheme #[error("Unsupported scheme: {0}")] UnsupportedScheme(String), /// Missing host #[error("Missing host")] MissingHost, /// Query string parse error #[error(transparent)] QueryParseError(#[from] serde_qs::Error), } impl StoreURI { /// Parse a Nix store URI /// /// Currently only supports `ssh` scheme pub fn parse(uri: &str) -> Result { let url = Url::parse(uri)?; match url.scheme() { "ssh" => { let host = url .host_str() .ok_or(StoreURIParseError::MissingHost)? .to_string(); let user = if !url.username().is_empty() { Some(url.username().to_string()) } else { None }; let opts = serde_qs::from_str(url.query().unwrap_or(""))?; let ssh_uri = SSHStoreURI { user, host }; let store_uri = StoreURI::SSH(ssh_uri, opts); Ok(store_uri) } // Add future schemes here scheme => Err(StoreURIParseError::UnsupportedScheme(scheme.to_string())), } } /// Get the options for this store URI pub fn get_options(&self) -> &Opts { match self { StoreURI::SSH(_, opts) => opts, } } } impl FromStr for StoreURI { type Err = StoreURIParseError; fn from_str(s: &str) -> Result { StoreURI::parse(s) } } impl fmt::Display for SSHStoreURI { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if let Some(user) = &self.user { write!(f, "{}@{}", user, self.host) } else { write!(f, "{}", self.host) } } } impl fmt::Display for StoreURI { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { StoreURI::SSH(uri, _opts) => { // This should construct a valid store URI. write!(f, "ssh://{}", uri) } } } } ================================================ FILE: crates/nix_rs/src/system_list.rs ================================================ //! Dealing with system lists use std::{collections::HashMap, convert::Infallible, str::FromStr}; use crate::{ command::{NixCmd, NixCmdError}, flake::{system::System, url::FlakeUrl}, }; use lazy_static::lazy_static; lazy_static! { /// Builtin list of [SystemsListFlakeRef] pub static ref NIX_SYSTEMS: HashMap = { serde_json::from_str(env!("NIX_SYSTEMS")).unwrap() }; } /// A flake referencing a [SystemsList] #[derive(Debug, Clone, PartialEq, Eq)] pub struct SystemsListFlakeRef(pub FlakeUrl); impl SystemsListFlakeRef { /// Lookup a known [SystemsListFlakeRef] that will not require network calls pub fn from_known_system(system: &System) -> Option { NIX_SYSTEMS .get(&system.to_string()) .map(|url| SystemsListFlakeRef(url.clone())) } } impl FromStr for SystemsListFlakeRef { type Err = Infallible; fn from_str(s: &str) -> Result { let system = System::from(s); match SystemsListFlakeRef::from_known_system(&system) { Some(url) => Ok(url), None => Ok(SystemsListFlakeRef(FlakeUrl(s.to_string()))), } } } /// A list of [System]s pub struct SystemsList(pub Vec); impl SystemsList { /// Load the list of systems from a [SystemsListFlakeRef] pub async fn from_flake(cmd: &NixCmd, url: &SystemsListFlakeRef) -> Result { // Nix eval, and then return the systems match SystemsList::from_known_flake(url) { Some(systems) => Ok(systems), None => SystemsList::from_remote_flake(cmd, url).await, } } async fn from_remote_flake( cmd: &NixCmd, url: &SystemsListFlakeRef, ) -> Result { let systems = nix_import_flake::>(cmd, &url.0).await?; Ok(SystemsList(systems)) } /// Handle known repos of thereby avoiding /// network calls. fn from_known_flake(url: &SystemsListFlakeRef) -> Option { let system = NIX_SYSTEMS .iter() .find_map(|(v, u)| if u == &url.0 { Some(v) } else { None })?; Some(SystemsList(vec![system.clone().into()])) } } /// Evaluate `import ` and return the result JSON parsed. async fn nix_import_flake(cmd: &NixCmd, url: &FlakeUrl) -> Result where T: Default + serde::de::DeserializeOwned, { let flake_path = nix_eval_impure_expr::(cmd, format!("builtins.getFlake \"{}\"", url.0)).await?; let v = nix_eval_impure_expr(cmd, format!("import {}", flake_path)).await?; Ok(v) } async fn nix_eval_impure_expr(cmd: &NixCmd, expr: String) -> Result where T: Default + serde::de::DeserializeOwned, { let v = cmd .run_with_args_expecting_json::(&["eval"], &["--impure", "--json", "--expr", &expr]) .await?; Ok(v) } ================================================ FILE: crates/nix_rs/src/version.rs ================================================ //! Rust module for `nix --version` use regex::Regex; use serde_with::{DeserializeFromStr, SerializeDisplay}; use std::{fmt, str::FromStr}; use thiserror::Error; use tokio::sync::OnceCell; use tracing::instrument; use crate::command::{NixCmd, NixCmdError}; /// Nix version as parsed from `nix --version` #[derive(Clone, Copy, PartialOrd, PartialEq, Eq, Debug, SerializeDisplay, DeserializeFromStr)] pub struct NixVersion { /// Major version pub major: u32, /// Minor version pub minor: u32, /// Patch version pub patch: u32, } /// Error type for parsing `nix --version` #[derive(Error, Debug, Clone, PartialEq)] pub enum BadNixVersion { /// Regex error #[error("Regex error: {0}")] Regex(#[from] regex::Error), /// Parse error #[error("Parse error (regex): `nix --version` cannot be parsed")] Parse(#[from] std::num::ParseIntError), /// Command error #[error("Parse error (int): `nix --version` cannot be parsed")] Command, } impl FromStr for NixVersion { type Err = BadNixVersion; /// Parse the string output of `nix --version` into a [NixVersion] fn from_str(s: &str) -> Result { // NOTE: The parser is lenient in allowing pure nix version (produced // by [Display] instance), so as to work with serde_with instances. let re = Regex::new(r"(?:nix \(Nix\) )?(\d+)\.(\d+)\.(\d+)(?:\+(\d+))?$")?; let captures = re.captures(s).ok_or(BadNixVersion::Command)?; let major = captures[1].parse::()?; let minor = captures[2].parse::()?; let patch = captures[3].parse::()?; Ok(NixVersion { major, minor, patch, }) } } static NIX_VERSION: OnceCell> = OnceCell::const_new(); impl NixVersion { /// Get the once version of `NixVersion`. #[instrument(name = "show-config(once)")] pub async fn get() -> &'static Result { NIX_VERSION .get_or_init(|| async { let cmd = NixCmd::default(); let nix_ver = NixVersion::from_nix(&cmd).await?; Ok(nix_ver) }) .await } /// Get the output of `nix --version` #[instrument(name = "version")] pub async fn from_nix(cmd: &NixCmd) -> Result { let v = cmd .run_with_args_expecting_fromstr(&[], &["--version"]) .await?; Ok(v) } } /// The String view for [NixVersion] impl fmt::Display for NixVersion { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}.{}.{}", self.major, self.minor, self.patch) } } #[tokio::test] async fn test_run_nix_version() { let nix_version = NixVersion::from_nix(&NixCmd::default()).await.unwrap(); println!("Nix version: {}", nix_version); } #[tokio::test] async fn test_parse_nix_version() { assert_eq!( NixVersion::from_str("nix (Nix) 2.13.0"), Ok(NixVersion { major: 2, minor: 13, patch: 0 }) ); // Parse simple nix version assert_eq!( NixVersion::from_str("2.13.0"), Ok(NixVersion { major: 2, minor: 13, patch: 0 }) ); // Parse Determinate Nix Version assert_eq!( NixVersion::from_str("nix (Determinate Nix 3.6.6) 2.29.0"), Ok(NixVersion { major: 2, minor: 29, patch: 0 }) ); } ================================================ FILE: crates/nix_rs/src/version_spec.rs ================================================ //! Version requirement spec for [NixVersion] use std::{fmt, str::FromStr}; use regex::Regex; use serde::{Deserialize, Serialize}; use serde_with::{DeserializeFromStr, SerializeDisplay}; use thiserror::Error; use crate::version::NixVersion; /// An individual component of [NixVersionReq] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum NixVersionSpec { /// Version must be greater than the specified version Gt(NixVersion), /// Version must be greater than or equal to the specified version Gteq(NixVersion), /// Version must be less than the specified version Lt(NixVersion), /// Version must be less than or equal to the specified version Lteq(NixVersion), /// Version must not equal the specified version Neq(NixVersion), } /// Version requirement for [NixVersion] /// /// Example: /// ">=2.8, <2.14, 12.13.4" #[derive(Debug, Clone, PartialEq, SerializeDisplay, DeserializeFromStr)] pub struct NixVersionReq { /// List of version specifications pub specs: Vec, } /// Errors that can occur while parsing or validating version specifications #[derive(Error, Debug)] pub enum BadNixVersionSpec { /// Regex error #[error("Regex error: {0}")] Regex(#[from] regex::Error), /// Invalid [NixVersionSpec] #[error("Parse error(regex): Invalid version spec format")] InvalidFormat, /// Parse error (Int) #[error("Parse error(int): Invalid version spec format")] Parse(#[from] std::num::ParseIntError), /// An unknown comparison operator was used #[error("Unknown operator in the Nix version spec: {0}")] UnknownOperator(String), } impl FromStr for NixVersionReq { type Err = BadNixVersionSpec; fn from_str(s: &str) -> Result { let specs = s .split(',') .map(str::trim) .map(NixVersionSpec::from_str) .collect::, _>>()?; Ok(NixVersionReq { specs }) } } impl NixVersionSpec { /// Checks if a given Nix version satisfies this version specification pub fn matches(&self, version: &NixVersion) -> bool { match self { NixVersionSpec::Gt(v) => version > v, NixVersionSpec::Gteq(v) => version >= v, NixVersionSpec::Lt(v) => version < v, NixVersionSpec::Lteq(v) => version <= v, NixVersionSpec::Neq(v) => version != v, } } } impl FromStr for NixVersionSpec { type Err = BadNixVersionSpec; fn from_str(s: &str) -> Result { use NixVersionSpec::{Gt, Gteq, Lt, Lteq, Neq}; let re = Regex::new( r#"(?x) ^ (?P>=|<=|>|<|!=) (?P\d+) (?:\. (?P\d+) )? (?:\. (?P\d+) )? $ "#, )?; let captures = re.captures(s).ok_or(BadNixVersionSpec::InvalidFormat)?; let op = captures .name("op") .ok_or(BadNixVersionSpec::InvalidFormat)? .as_str(); let major: u32 = captures .name("major") .ok_or(BadNixVersionSpec::InvalidFormat)? .as_str() .parse()?; let minor = captures .name("minor") .map_or(Ok(0), |m| m.as_str().parse::())?; let patch = captures .name("patch") .map_or(Ok(0), |m| m.as_str().parse::())?; let nix_version = NixVersion { major, minor, patch, }; match op { ">=" => Ok(Gteq(nix_version)), "<=" => Ok(Lteq(nix_version)), ">" => Ok(Gt(nix_version)), "<" => Ok(Lt(nix_version)), "!=" => Ok(Neq(nix_version)), unknown_op => Err(BadNixVersionSpec::UnknownOperator(unknown_op.to_string())), } } } impl fmt::Display for NixVersionSpec { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { NixVersionSpec::Gt(v) => write!(f, ">{}", v), NixVersionSpec::Gteq(v) => write!(f, ">={}", v), NixVersionSpec::Lt(v) => write!(f, "<{}", v), NixVersionSpec::Lteq(v) => write!(f, "<={}", v), NixVersionSpec::Neq(v) => write!(f, "!={}", v), } } } impl fmt::Display for NixVersionReq { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, "{}", self.specs .iter() .map(|s| s.to_string()) .collect::>() .join(", ") ) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse() { assert_eq!( NixVersionSpec::from_str(">2.8").unwrap(), NixVersionSpec::Gt(NixVersion { major: 2, minor: 8, patch: 0 }) ); assert_eq!( NixVersionSpec::from_str(">2").unwrap(), NixVersionSpec::Gt(NixVersion { major: 2, minor: 0, patch: 0 }) ); } #[test] fn test_matches() { let req = NixVersionReq::from_str("!=2.9, >2.8").unwrap(); let version = NixVersion::from_str("2.9.0").unwrap(); assert!(!req.specs.iter().all(|spec| spec.matches(&version))); let version = NixVersion::from_str("2.9.1").unwrap(); assert!(req.specs.iter().all(|spec| spec.matches(&version))); } } ================================================ FILE: crates/omnix-ci/CHANGELOG.md ================================================ ## Unreleased - Crate renamed to `omnix-ci` - New - Introduced notion of 'steps'. Renamed 'build' to 'run'. - Added a step to run `nix flake check` - Support for custom steps - `config.rs`: Refactored to change API. - Locally cache `github:nix-systems` (to avoid Github API rate limit) - The default subflake now uses `ROOT` instead `` as the key. ## 1.1.0 - Remove executable - `nixci` executable will no longer be updated past 1.0.0; use `omnix` (`om ci`) instead. - Port to newer `nix_rs` - Use `om.ci` as configuration key - tests: Removed, and moved to omnix-cli crate. - Fix: - Passing `.#foo` where "foo" is missing now errors out, instead of silently defaulting. ## [1.0.0](https://github.com/srid/nixci/compare/0.5.0...1.0.0) (2024-07-23) ### Features * add shell completions (#87) ([1b2caf3](https://github.com/srid/nixci/commit/1b2caf369c739382e2f1c22bfb32096f65addfba)), closes [#87](https://github.com/srid/nixci/issues/87) * **build:** Check for minimum nix version before running nixci (#75) ([ac5a011](https://github.com/srid/nixci/commit/ac5a011c76e9537426e0265b20e46f8efea44d40)), closes [#75](https://github.com/srid/nixci/issues/75) * **cli:** Allow `--override-input` to refer to flake name without `flake/` prefix of devour_flake (#74) ([c17f42f](https://github.com/srid/nixci/commit/c17f42f3480b4b265bac0d94e7169ca01201fb9d)), closes [#74](https://github.com/srid/nixci/issues/74) ### Fixes * `--print-all-dependencies` should ignore `unknown-deriver` (#76) ([d26bab1](https://github.com/srid/nixci/commit/d26bab116f19ac248a7073de9de3ae8a3ac0271f)), closes [#76](https://github.com/srid/nixci/issues/76) * `--print-all-dependencies` should handle `unknown-deriver` (#70) ([16815b6](https://github.com/srid/nixci/commit/16815b6c9e476defd993368d0957335f86f9c055)), closes [#70](https://github.com/srid/nixci/issues/70) ## [0.5.0](https://github.com/srid/nixci/compare/0.4.0...0.5.0) (2024-06-15) ### Features * Avoid fetching for known `--system` combinations ([6164d6c](https://github.com/srid/nixci/commit/6164d6c6d37ccab02ddc4943962fd7c21828054c)) * **api:** Pass `NixCmd` explicitly around ([6a672e2](https://github.com/srid/nixci/commit/6a672e28811f716a8cff5108dc720269d897d246)) * Accept global options to pass to Nix ([cca8b98](https://github.com/srid/nixci/commit/cca8b988e24d5d4e7d76e6d2398a0f2e0b686abf)) * **cli:** add `--print-all-depedencies` to `nixci build` subcommand (#60) ([4109ce9](https://github.com/srid/nixci/commit/4109ce9982ad2f54e769c302ab044f16f8bd865c)), closes [#60](https://github.com/srid/nixci/issues/60) ## 0.4.0 (Apr 19, 2024) - New features - Add new config `nixci.*.*.systems` acting as a whitelist of systems to build that subflake. - Add `nixci build --systems` option to build on an arbitrary systems (\#39) - Allow selecting sub-flake to build, e.g.: `nixci .#default.myflake` (\#45) - Add subcommand to generate Github Actions matrix (\#50) - Consequently, you must run `nixci build` instead of `nixci` now. - Pass `--extra-experimental-features` only when necessary. Simplifies logging. (#46) - Fixes - Fix regression in Nix 2.19+ (`devour-flake produced an outpath with no outputs`) (\#35) - Evaluate OS configurations for current system only (\#38) - Fail correctly if nixci is passed a missing flake attribute (\#44) ## 0.2.0 (Sep 14, 2023) - Breaking changes - Change flake schema: evaluates `nixci.default` instead of `nixci`; this allows more than one configuration (#20) - Pass the rest of CLI arguments after `--` as-is to `nix build` - Consequently, remove `--rebuild`, `--no-refresh` and `--system` options, because these can be specified using the new CLI spec. - Bug fixes - Fix nixci breaking if branch name of a PR has `#` (#17) - Misc changes - Iterate configs in a deterministic order - stdout outputs are uniquely printed, in sorted order - stderr output is now logged using the `tracing` crate. - Pass `--extra-experimental-features` to enable flakes - `nixci` can now be used as a Rust library - `nixci` no longer depends on `devour-flake` the *executable package*, only on the flake. ## 0.1.3 - Pass `-j auto` to nix builds. ## 0.1.2 Initial release ================================================ FILE: crates/omnix-ci/Cargo.toml ================================================ [package] authors = ["Sridhar Ratnakumar "] edition = "2021" # If you change the name here, you must also do it in flake.nix (and run `cargo generate-lockfile` afterwards) name = "omnix-ci" version = "1.3.2" license = "AGPL-3.0-only" readme = "README.md" description = "Define and build CI for Nix projects anywhere" homepage = "https://omnix.page" repository = "https://github.com/juspay/omnix" keywords = ["nix"] [lib] crate-type = ["cdylib", "rlib"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = { workspace = true } clap = { workspace = true } colored = { workspace = true } futures-lite = { workspace = true } lazy_static = { workspace = true } omnix-health = { workspace = true } nix_rs = { workspace = true, features = ["clap"] } nonempty = { workspace = true } omnix-common = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } shell-words = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } try-guard = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } ================================================ FILE: crates/omnix-ci/LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: crates/omnix-ci/README.md ================================================ # `omnix-ci` [![Crates.io](https://img.shields.io/crates/v/omnix-ci.svg)](https://crates.io/crates/omnix-ci) The Rust crate responsible for [`om ci`](https://omnix.page/om/ci.html). ================================================ FILE: crates/omnix-ci/crate.nix ================================================ { pkgs , lib , ... }: { autoWire = [ ]; crane = { args = { nativeBuildInputs = with pkgs; lib.optionals stdenv.isDarwin [ libiconv pkg-config ]; buildInputs = lib.optionals pkgs.stdenv.isLinux [ pkgs.openssl ]; # Disable tests due to sandboxing issues; we run them on CI # instead. doCheck = false; }; }; } ================================================ FILE: crates/omnix-ci/src/command/core.rs ================================================ //! The `om ci` subcommands use clap::Subcommand; use colored::Colorize; use nix_rs::command::NixCmd; use omnix_common::config::OmConfig; use tracing::instrument; use crate::flake_ref::FlakeRef; use super::{gh_matrix::GHMatrixCommand, run::RunCommand}; /// Top-level commands for `om ci` #[derive(Debug, Subcommand, Clone)] pub enum Command { /// Run all CI steps for all or given subflakes Run(RunCommand), /// Print the Github Actions matrix configuration as JSON #[clap(name = "gh-matrix")] DumpGithubActionsMatrix(GHMatrixCommand), } impl Default for Command { fn default() -> Self { Self::Run(Default::default()) } } impl Command { /// Run the command #[instrument(name = "run", skip(self))] pub async fn run(self) -> anyhow::Result<()> { tracing::info!("{}", "\n👟 Reading om.ci config from flake".bold()); let url = self.get_flake_ref().to_flake_url().await?; let cfg = OmConfig::get(self.nixcmd(), &url).await?; tracing::debug!("OmConfig: {cfg:?}"); match self { Command::Run(cmd) => cmd.run(cfg).await, Command::DumpGithubActionsMatrix(cmd) => cmd.run(cfg).await, } } fn nixcmd(&self) -> &NixCmd { match self { Command::Run(cmd) => &cmd.nixcmd, Command::DumpGithubActionsMatrix(cmd) => &cmd.nixcmd, } } /// Get the [FlakeRef] associated with this subcommand fn get_flake_ref(&self) -> &FlakeRef { match self { Command::Run(cmd) => &cmd.flake_ref, Command::DumpGithubActionsMatrix(cmd) => &cmd.flake_ref, } } /// Convert this type back to the user-facing command line arguments pub fn to_cli_args(&self) -> Vec { let mut args = vec!["ci".to_string(), "run".to_string()]; match self { Command::Run(cmd) => { args.extend(cmd.to_cli_args()); } Command::DumpGithubActionsMatrix(_cmd) => { unimplemented!("Command::DumpGithubActionsMatrix::to_cli_args") } } args } } ================================================ FILE: crates/omnix-ci/src/command/gh_matrix.rs ================================================ //! The gh-matrix command use clap::Parser; use nix_rs::{command::NixCmd, flake::system::System}; use omnix_common::config::OmConfig; use crate::{config::subflakes::SubflakesConfig, flake_ref::FlakeRef, github}; /// Command to generate a Github Actions matrix #[derive(Parser, Debug, Clone)] pub struct GHMatrixCommand { /// Flake URL or github URL /// /// A specific omnix-ci configuration can be specified /// using '#': e.g. `om ci run .#extra-tests` #[arg(default_value = ".")] pub flake_ref: FlakeRef, /// Systems to include in the matrix #[arg(long, value_parser, value_delimiter = ',')] pub systems: Vec, /// Nix command global options #[command(flatten)] pub nixcmd: NixCmd, } impl GHMatrixCommand { /// Run the command pub async fn run(&self, cfg: OmConfig) -> anyhow::Result<()> { let (config, _rest) = cfg.get_sub_config_under::("ci")?; let matrix = github::matrix::GitHubMatrix::from(self.systems.clone(), &config); println!("{}", serde_json::to_string(&matrix)?); Ok(()) } } ================================================ FILE: crates/omnix-ci/src/command/mod.rs ================================================ //! CLI commands for omnix-ci pub mod core; pub mod gh_matrix; pub mod run; pub mod run_remote; ================================================ FILE: crates/omnix-ci/src/command/run.rs ================================================ //! The run command use std::{ collections::HashMap, env, io::Write, path::{Path, PathBuf}, }; use anyhow::{Context, Result}; use clap::Parser; use colored::Colorize; use nix_rs::{ command::NixCmd, config::NixConfig, flake::{functions::addstringcontext, system::System, url::FlakeUrl}, info::NixInfo, store::{path::StorePath, uri::StoreURI}, system_list::{SystemsList, SystemsListFlakeRef}, }; use omnix_common::config::OmConfig; use omnix_health::{traits::Checkable, NixHealth}; use serde::{Deserialize, Serialize}; use crate::{ config::subflakes::SubflakesConfig, flake_ref::FlakeRef, github::actions::in_github_log_group, step::core::StepsResult, }; use super::run_remote; /// Run all CI steps for all or given subflakes /// Command to run all CI steps #[derive(Parser, Debug, Clone)] pub struct RunCommand { /// Run `om ci run` remotely on the given store URI #[clap(long)] pub on: Option, /// The systems list to build for. If empty, build for current system. /// /// Must be a flake reference which, when imported, must return a Nix list /// of systems. You may use one of the lists from /// . /// /// You can also pass the individual system name, if they are supported by omnix. #[arg(long)] pub systems: Option, /// Symlink to build results (as JSON) #[arg( long, short = 'o', default_value = "result", conflicts_with = "no_link", alias = "results", // For backwards compat name = "PATH" )] out_link: Option, /// Do not create a symlink to build results JSON #[arg(long)] no_link: bool, /// Flake URL or github URL /// /// A specific configuration can be specified /// using '#': e.g. `om ci run .#default.extra-tests` #[arg(default_value = ".")] pub flake_ref: FlakeRef, /// Print Github Actions log groups (enabled by default when run in Github Actions) #[clap(long, default_value_t = env::var("GITHUB_ACTION").is_ok())] pub github_output: bool, /// Arguments for all steps #[command(flatten)] pub steps_args: crate::step::core::StepsArgs, /// Nix command global options #[command(flatten)] pub nixcmd: NixCmd, } impl Default for RunCommand { fn default() -> Self { RunCommand::parse_from::<[_; 0], &str>([]) } } impl RunCommand { /// Get the out-link path pub fn get_out_link(&self) -> Option<&Path> { if self.no_link { None } else { self.out_link.as_ref().map(PathBuf::as_ref) } } /// Override the `flake_ref` and `out_link`` for building locally. pub fn local_with(&self, flake_ref: FlakeRef, out_link: Option) -> Self { let mut new = self.clone(); new.on = None; // Disable remote building new.flake_ref = flake_ref; new.no_link = out_link.is_none(); new.out_link = out_link; new } /// Run the build command which decides whether to do ci run on current machine or a remote machine pub async fn run(&self, cfg: OmConfig) -> anyhow::Result<()> { match &self.on { Some(store_uri) => { run_remote::run_on_remote_store(&self.nixcmd, self, &cfg, store_uri).await } None => self.run_local(cfg).await, } } /// Run [RunCommand] on local Nix store. async fn run_local(&self, cfg: OmConfig) -> anyhow::Result<()> { // TODO: We'll refactor this function to use steps // https://github.com/juspay/omnix/issues/216 let nix_info = in_github_log_group("info", self.github_output, || async { tracing::info!("{}", "\n👟 Gathering NixInfo".bold()); NixInfo::get() .await .as_ref() .with_context(|| "Unable to gather nix info") }) .await?; // First, run the necessary health checks in_github_log_group("health", self.github_output, || async { tracing::info!("{}", "\n🫀 Performing health check".bold()); // check_nix_version(&cfg, nix_info).await?; check_nix_version(&cfg, nix_info).await }) .await?; // Then, do the CI steps tracing::info!( "{}", format!("\n🤖 Running CI for {}", self.flake_ref).bold() ); let res = ci_run(&self.nixcmd, self, &cfg, &nix_info.nix_config).await?; let msg = in_github_log_group::, _, _>( "outlink", self.github_output, || async { let m_out_link = self.get_out_link(); let s = serde_json::to_string(&res)?; let mut path = tempfile::Builder::new() .prefix("om-ci-results-") .suffix(".json") .tempfile()?; path.write_all(s.as_bytes())?; let results_path = addstringcontext::addstringcontext(&self.nixcmd, path.path(), m_out_link) .await?; println!("{}", results_path.display()); let msg = format!( "Result available at {:?}{}", results_path.as_path(), m_out_link .map(|p| format!(" and symlinked at {:?}", p)) .unwrap_or_default() ); Ok(msg) }, ) .await?; tracing::info!("{}", msg); Ok(()) } /// Get the systems to build for pub async fn get_systems(&self, cmd: &NixCmd, nix_config: &NixConfig) -> Result> { match &self.systems { None => { // An empty systems list means build for the current system let current_system = &nix_config.system.value; Ok(vec![current_system.clone()]) } Some(systems) => { let systems = SystemsList::from_flake(cmd, systems).await?.0; Ok(systems) } } } /// Convert this type back to the user-facing command line arguments pub fn to_cli_args(&self) -> Vec { let mut args = vec![]; if let Some(uri) = self.on.as_ref() { args.push("--on".to_owned()); args.push(uri.to_string()); } if let Some(systems) = self.systems.as_ref() { args.push("--systems".to_string()); args.push(systems.0 .0.clone()); } if let Some(out_link) = self.out_link.as_ref() { args.push("--out-link".to_string()); args.push(out_link.to_string_lossy().to_string()); } if self.no_link { args.push("--no-link".to_string()); } args.push(self.flake_ref.to_string()); args.extend(self.steps_args.to_cli_args()); args } } /// Check that Nix version is not too old. pub async fn check_nix_version(cfg: &OmConfig, nix_info: &NixInfo) -> anyhow::Result<()> { let omnix_health = NixHealth::from_om_config(cfg)?; let checks = omnix_health .nix_version .check(nix_info, Some(&cfg.flake_url)); let exit_code = NixHealth::print_report_returning_exit_code(&checks, false).await?; if exit_code != 0 { std::process::exit(exit_code); } Ok(()) } /// Run CI for all subflakes pub async fn ci_run( cmd: &NixCmd, run_cmd: &RunCommand, cfg: &OmConfig, nix_config: &NixConfig, ) -> anyhow::Result { let mut res = HashMap::new(); let systems = run_cmd.get_systems(cmd, nix_config).await?; let (config, attrs) = cfg.get_sub_config_under::("ci")?; // User's filter by subflake name let only_subflake = attrs.first(); for (subflake_name, subflake) in &config.0 { let name = subflake_name.italic(); if let Some(s) = only_subflake { if s != subflake_name { tracing::info!("\n🍊 {} {}", name, "skipped (deselected out)".dimmed()); continue; } } let compatible_system = subflake.can_run_on(&systems); if !compatible_system { tracing::info!( "\n🍊 {} {}", name, "skipped (cannot run on this system)".dimmed() ); continue; } let steps_res = in_github_log_group( &format!("subflake={}", name), run_cmd.github_output, || async { tracing::info!("\n🍎 {}", name); subflake .steps .run(cmd, run_cmd, &systems, &cfg.flake_url, subflake) .await }, ) .await?; res.insert(subflake_name.clone(), steps_res); } tracing::info!("\n🥳 Success!"); Ok(RunResult { systems, flake: cfg.flake_url.clone(), result: res, }) } /// Results of the 'ci run' command #[derive(Debug, Serialize, Deserialize, Clone)] pub struct RunResult { /// The systems we are building for systems: Vec, /// The flake being built flake: FlakeUrl, /// CI result for each subflake result: HashMap, } impl RunResult { /// Get all store paths mentioned in this type. pub fn all_out_paths(&self) -> Vec { let mut res = vec![]; for steps_res in self.result.values() { if let Some(build) = steps_res.build_step.as_ref() { res.extend(build.devour_flake_output.out_paths.clone()); } } res } } ================================================ FILE: crates/omnix-ci/src/command/run_remote.rs ================================================ //! Functions for running `ci run` on remote machine. use colored::Colorize; use nix_rs::{ command::{CommandError, NixCmd}, copy::{nix_copy, NixCopyOptions}, flake::{ functions::metadata::{FlakeMetadata, FlakeMetadataInput}, url::FlakeUrl, }, store::{command::NixStoreCmd, path::StorePath, uri::StoreURI}, }; use omnix_common::config::OmConfig; use std::{ ffi::{OsStr, OsString}, os::unix::ffi::OsStringExt, path::{Path, PathBuf}, }; use tokio::process::Command; use super::run::RunCommand; /// Path to Rust source corresponding to this (running) instance of Omnix const OMNIX_SOURCE: &str = env!("OMNIX_SOURCE"); /// Like [RunCommand::run] but run on a remote Nix store. pub async fn run_on_remote_store( nixcmd: &NixCmd, run_cmd: &RunCommand, cfg: &OmConfig, store_uri: &StoreURI, ) -> anyhow::Result<()> { let StoreURI::SSH(ssh_uri, opts) = store_uri; tracing::info!( "{}", format!("\n🛜 Running CI remotely on {} ({:?})", ssh_uri, opts).bold() ); let (flake_closure, local_flake_url) = &cache_flake(nixcmd, cfg, opts.copy_inputs).await?; let omnix_source = PathBuf::from(OMNIX_SOURCE); let paths_to_push = vec![omnix_source, flake_closure.clone()]; // First, copy the flake and omnix source to the remote store, because we will be needing them when running over ssh. nix_copy_to_remote(nixcmd, store_uri, &paths_to_push).await?; // If out-link is requested, we need to copy the results back to local store - so that when we create the out-link *locally* the paths in it refer to valid paths in the local store. Thus, --out-link can be used to trick Omnix into copying all built paths back. if let Some(out_link) = run_cmd.get_out_link() { // A temporary location on ssh remote to hold the result let tmpdir = parse_path_line( &run_ssh_with_output( &ssh_uri.to_string(), &nixpkgs_cmd("coreutils", &["mktemp", "-d", "-t", "om.json.XXXXXX"]), ) .await?, ); let om_json_path = tmpdir.join("om.json"); // Then, SSH and run the same `om ci run` CLI but without the `--on` argument but with `--out-link` pointing to the temporary location. run_ssh( &ssh_uri.to_string(), &om_cli_with( run_cmd.local_with(local_flake_url.clone().into(), Some(om_json_path.clone())), ), ) .await?; // Get the out-link store path. let om_result_path: StorePath = StorePath::new(parse_path_line( &run_ssh_with_output( &ssh_uri.to_string(), &nixpkgs_cmd( "coreutils", &["readlink", om_json_path.to_string_lossy().as_ref()], ), ) .await?, )); // Copy the results back to local store (the out-link). tracing::info!("{}", "📦 Copying results back to local store".bold()); nix_copy_from_remote(nixcmd, store_uri, &[&om_result_path]).await?; // Write the local out-link let nix_store = NixStoreCmd {}; nix_store .nix_store_add_root(out_link, &[&om_result_path]) .await?; tracing::info!( "Results available at {:?} symlinked at {:?}", om_result_path.as_path(), out_link ); } else { // Then, SSH and run the same `om ci run` CLI but without the `--on` argument. run_ssh( &ssh_uri.to_string(), &om_cli_with(run_cmd.local_with(local_flake_url.clone().into(), None)), ) .await?; } Ok(()) } async fn nix_copy_to_remote( nixcmd: &NixCmd, store_uri: &StoreURI, paths: I, ) -> Result<(), CommandError> where I: IntoIterator, P: AsRef + AsRef, { nix_copy( nixcmd, NixCopyOptions { to: Some(store_uri.to_owned()), no_check_sigs: true, ..Default::default() }, paths, ) .await } async fn nix_copy_from_remote( nixcmd: &NixCmd, store_uri: &StoreURI, paths: I, ) -> Result<(), CommandError> where I: IntoIterator, P: AsRef + AsRef, { nix_copy( nixcmd, NixCopyOptions { from: Some(store_uri.to_owned()), no_check_sigs: true, ..Default::default() }, paths, ) .await } fn parse_path_line(bytes: &[u8]) -> PathBuf { let trimmed_bytes = bytes.trim_ascii_end(); PathBuf::from(OsString::from_vec(trimmed_bytes.to_vec())) } /// Construct CLI arguments for running a program from nixpkgs using given arguments fn nixpkgs_cmd(package: &str, cmd: &[&str]) -> Vec { let mut args = vec![ "nix".to_owned(), "shell".to_owned(), format!("nixpkgs#{}", package), ]; args.push("-c".to_owned()); args.extend(cmd.iter().map(|s| s.to_string())); args } /// Return the locally cached [FlakeUrl] for the given flake url that points to same selected [ConfigRef]. async fn cache_flake( nixcmd: &NixCmd, cfg: &OmConfig, include_inputs: bool, ) -> anyhow::Result<(PathBuf, FlakeUrl)> { let (closure, metadata) = FlakeMetadata::from_nix( nixcmd, FlakeMetadataInput { flake: cfg.flake_url.clone(), include_inputs, }, ) .await?; let attr = cfg.reference.join("."); let mut local_flake_url = Into::::into(metadata.flake.clone()); if !attr.is_empty() { local_flake_url = local_flake_url.with_attr(&attr); } Ok((closure, local_flake_url)) } /// Construct a `nix run ...` based CLI that runs Omnix using given arguments. /// /// Omnix itself will be compiled from source ([OMNIX_SOURCE]) if necessary. Thus, this invocation is totally independent and can be run on remote machines, as long as the paths exista on the nix store. fn om_cli_with(run_cmd: RunCommand) -> Vec { let mut args: Vec = vec![]; let omnix_flake = format!("{}#default", OMNIX_SOURCE); args.extend( [ "nix", "--accept-flake-config", "run", &omnix_flake, "--", "ci", "run", ] .map(&str::to_owned), ); args.extend(run_cmd.to_cli_args()); args } /// Run SSH command with given arguments. async fn run_ssh(host: &str, args: &[String]) -> anyhow::Result<()> { let mut cmd = Command::new("ssh"); cmd.args([host, &shell_words::join(args)]); nix_rs::command::trace_cmd_with("🐌", &cmd); let status = cmd.status().await?; if !status.success() { return Err(anyhow::anyhow!("SSH command failed: {}", status)); } Ok(()) } /// Run SSH command with given arguments and return the stdout. async fn run_ssh_with_output(host: &str, args: I) -> anyhow::Result> where I: IntoIterator, S: AsRef, { let mut cmd = Command::new("ssh"); cmd.args([host, &shell_words::join(args)]); nix_rs::command::trace_cmd_with("🐌", &cmd); let output = cmd.output().await?; if output.status.success() { Ok(output.stdout) } else { Err(anyhow::anyhow!( "SSH command failed: {}", String::from_utf8_lossy(&output.stderr) )) } } ================================================ FILE: crates/omnix-ci/src/config/core.rs ================================================ //! The top-level configuration of omnix-ci, as defined in flake.nix #[cfg(test)] mod tests { use nix_rs::{command::NixCmd, flake::url::FlakeUrl}; use omnix_common::config::OmConfig; use crate::config::subflakes::SubflakesConfig; #[tokio::test] async fn test_config_loading() { // Testing this flake: // https://github.com/srid/haskell-flake/blob/c60351652c71ebeb5dd237f7da874412a7a96970/flake.nix#L30-L95 let url = &FlakeUrl( "github:srid/haskell-flake/c60351652c71ebeb5dd237f7da874412a7a96970#default.dev" .to_string(), ); let cfg = OmConfig::get(NixCmd::get().await, url).await.unwrap(); let (config, attrs) = cfg.get_sub_config_under::("ci").unwrap(); assert_eq!(attrs, &["dev"]); // assert_eq!(cfg.selected_subconfig, Some("dev".to_string())); assert_eq!(config.0.len(), 9); } } ================================================ FILE: crates/omnix-ci/src/config/mod.rs ================================================ //! omnix-ci config defined in `flake.nix` pub mod core; pub mod subflake; pub mod subflakes; ================================================ FILE: crates/omnix-ci/src/config/subflake.rs ================================================ //! Subflake configuration use std::collections::BTreeMap; use nix_rs::flake::{system::System, url::FlakeUrl}; use serde::Deserialize; use crate::step::core::Steps; /// Represents a sub-flake look-alike. /// /// "Look-alike" because its inputs may be partial, thus requiring explicit /// --override-inputs when evaluating the flake. #[derive(Debug, Deserialize, Clone)] pub struct SubflakeConfig { /// Whether to skip building this subflake #[serde(default)] pub skip: bool, /// Subdirectory in which the flake lives pub dir: String, /// Inputs to override (via --override-input) // NB: we use BTreeMap instead of HashMap here so that we always iterate // inputs in a determinitstic (i.e. asciibetical) order #[serde(rename = "overrideInputs", default)] pub override_inputs: BTreeMap, /// An optional whitelist of systems to build on (others are ignored) pub systems: Option>, /// List of CI steps to run #[serde(default)] pub steps: Steps, } impl Default for SubflakeConfig { /// The default `SubflakeConfig` is the root flake. fn default() -> Self { SubflakeConfig { skip: false, dir: ".".to_string(), override_inputs: BTreeMap::default(), systems: None, steps: Steps::default(), } } } impl SubflakeConfig { /// Whether the CI for this subflake can be run on any of the given systems pub fn can_run_on(&self, systems: &[System]) -> bool { match self.systems.as_ref() { Some(systems_whitelist) => systems_whitelist.iter().any(|s| systems.contains(s)), None => true, } } } ================================================ FILE: crates/omnix-ci/src/config/subflakes.rs ================================================ //! Subflakes configuration group. use std::collections::BTreeMap; use serde::Deserialize; use super::subflake::SubflakeConfig; /// CI configuration for a subflake #[derive(Debug, Deserialize, Clone)] pub struct SubflakesConfig( // NB: we use BTreeMap instead of HashMap here so that we always iterate // configs in a determinitstic (i.e. asciibetical) order pub BTreeMap, ); impl Default for SubflakesConfig { /// Default value contains a single entry for the root flake. fn default() -> Self { let mut subflakes = BTreeMap::new(); subflakes.insert("ROOT".to_string(), SubflakeConfig::default()); SubflakesConfig(subflakes) } } ================================================ FILE: crates/omnix-ci/src/flake_ref.rs ================================================ //! A reference to some flake living somewhere use std::{ fmt::{Display, Formatter}, str::FromStr, }; use anyhow::Result; use nix_rs::flake::url::FlakeUrl; use crate::github::pull_request::{PullRequest, PullRequestRef}; /// A reference to some flake living somewhere /// /// This type captures the superset of what flake URLs allow. #[derive(Debug, Clone, PartialEq, Eq)] pub enum FlakeRef { /// A github PR GithubPR(PullRequestRef), /// A flake URL supported by Nix commands Flake(FlakeUrl), } impl FromStr for FlakeRef { type Err = String; fn from_str(s: &str) -> std::result::Result { let flake_ref = match PullRequestRef::from_web_url(s) { Some(pr) => FlakeRef::GithubPR(pr), None => FlakeRef::Flake(FlakeUrl(s.to_string())), }; Ok(flake_ref) } } impl From for FlakeRef { fn from(url: FlakeUrl) -> Self { FlakeRef::Flake(url) } } impl Display for FlakeRef { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { FlakeRef::GithubPR(pr) => write!(f, "{}", pr), FlakeRef::Flake(url) => write!(f, "{}", url), } } } impl FlakeRef { /// Convert the value to a flake URL that Nix command will recognize. pub async fn to_flake_url(&self) -> Result { match self { FlakeRef::GithubPR(pr) => { let pr = PullRequest::get(pr).await?; Ok(pr.flake_url()) } FlakeRef::Flake(url) => Ok(url.clone()), } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_github_pr() { assert_eq!( FlakeRef::from_str("https://github.com/srid/nixci/pull/19").unwrap(), FlakeRef::GithubPR(PullRequestRef { owner: "srid".to_string(), repo: "nixci".to_string(), pr: 19 }) ); } #[test] fn test_current_dir() { assert_eq!( FlakeRef::from_str(".").unwrap(), FlakeRef::Flake(FlakeUrl(".".to_string())) ); } #[test] fn test_flake_url() { assert_eq!( FlakeRef::from_str("github:srid/nixci").unwrap(), FlakeRef::Flake(FlakeUrl("github:srid/nixci".to_string())) ); } } ================================================ FILE: crates/omnix-ci/src/github/actions.rs ================================================ //! Working with GitHub Actions use std::future::Future; /// Group log lines in GitHub Actions /// /// https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#grouping-log-lines pub async fn in_github_log_group(name: &str, enable: bool, f: F) -> T where F: FnOnce() -> Fut, Fut: Future, { if enable { eprintln!("::group::{}", name); } let result = f().await; if enable { eprintln!("::endgroup::"); } result } ================================================ FILE: crates/omnix-ci/src/github/matrix.rs ================================================ //! Github Actions matrix use nix_rs::flake::system::System; use serde::{Deserialize, Serialize}; use crate::config::subflakes::SubflakesConfig; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] /// A row in the Github Actions matrix configuration pub struct GitHubMatrixRow { /// System to build on pub system: System, /// Subflake to build pub subflake: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] /// Github Actions matrix configuration pub struct GitHubMatrix { /// The includes pub include: Vec, } impl GitHubMatrix { /// Create a [GitHubMatrix] for the given subflakes and systems pub fn from(systems: Vec, subflakes: &SubflakesConfig) -> Self { let include: Vec = systems .iter() .flat_map(|system| { subflakes .0 .iter() .filter(|&(_k, v)| v.can_run_on(std::slice::from_ref(system))) .map(|(k, _v)| GitHubMatrixRow { system: system.clone(), subflake: k.clone(), }) }) .collect(); GitHubMatrix { include } } } ================================================ FILE: crates/omnix-ci/src/github/mod.rs ================================================ //! GitHub related types and functions. pub mod actions; pub mod matrix; pub mod pull_request; ================================================ FILE: crates/omnix-ci/src/github/pull_request.rs ================================================ //! Github Pull Request API use std::fmt::Display; /// Enough types to get branch info from Pull Request URL use anyhow::{bail, Context}; use nix_rs::flake::url::FlakeUrl; use reqwest::header::USER_AGENT; use serde::Deserialize; use try_guard::guard; use url::{Host, Url}; /// A reference to a Github Pull Request #[derive(Debug, Clone, PartialEq, Eq)] pub struct PullRequestRef { pub(crate) owner: String, pub(crate) repo: String, pub(crate) pr: u64, } impl Display for PullRequestRef { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "https://github.com/{}/{}/pull/{}", self.owner, self.repo, self.pr ) } } impl PullRequestRef { fn api_url(&self) -> String { format!( "https://api.github.com/repos/{}/{}/pulls/{}", self.owner, self.repo, self.pr ) } /// Parse a Github PR URL into its owner, repo, and PR number pub fn from_web_url(url: &str) -> Option { let url = Url::parse(url).ok()?; guard!(url.scheme() == "https" && url.host() == Some(Host::Domain("github.com"))); let paths = url.path_segments().map(|c| c.collect::>())?; match paths[..] { [user, repo, "pull", pr_] => { let pr = pr_.parse::().ok()?; Some(PullRequestRef { owner: user.to_string(), repo: repo.to_string(), pr, }) } _ => None, } } } /// Github Pull Request API Response #[derive(Debug, Deserialize)] pub struct PullRequest { /// PR URL pub url: String, /// [Head] info pub head: Head, } /// Pull Request head info #[derive(Debug, Deserialize)] pub struct Head { #[serde(rename = "ref")] /// Head ref pub ref_: String, /// Head [Repo] pub repo: Repo, } /// Pull Request repo info #[derive(Debug, Deserialize)] pub struct Repo { /// `/` pub full_name: String, } impl PullRequest { /// Fetch the given PR using Github's API pub async fn get(ref_: &PullRequestRef) -> anyhow::Result { let v = api_get::(ref_.api_url()).await?; Ok(v) } /// The flake URL referencing the branch of this PR pub fn flake_url(&self) -> FlakeUrl { // We cannot use `github:user/repo` syntax, because it doesn't support // special characters in branch name. For that, we need to use the full // git+https URL with url encoded `ref` query parameter. FlakeUrl(format!( "git+https://github.com/{}?ref={}", self.head.repo.full_name, urlencoding::encode(&self.head.ref_) )) } } /// Get an API response, parsing the response into the given type async fn api_get(url: String) -> anyhow::Result where T: serde::de::DeserializeOwned, { let client = reqwest::Client::new(); let resp = client .get(&url) // Github API requires a user agent .header(USER_AGENT, "github.com/juspay/omnix") .send() .await .with_context(|| format!("cannot create request: {}", &url))?; if resp.status().is_success() { let v = resp .json::() .await .with_context(|| format!("cannot parse response: {}", &url))?; Ok(v) } else { bail!("cannot make request: {}", resp.status()) } } ================================================ FILE: crates/omnix-ci/src/lib.rs ================================================ //! omnix-ci: CI for Nix projects #![warn(missing_docs)] pub mod command; pub mod config; pub mod flake_ref; pub mod github; pub mod nix; pub mod step; ================================================ FILE: crates/omnix-ci/src/nix/devour_flake.rs ================================================ //! Rust support for invoking use lazy_static::lazy_static; use nix_rs::{ flake::{functions::core::FlakeFn, url::FlakeUrl}, store::path::StorePath, }; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, path::Path}; /// Devour all outputs of a flake producing their store paths pub struct DevourFlake; lazy_static! { /// devour flake URL static ref DEVOUR_FLAKE: FlakeUrl = { let path = env!("DEVOUR_FLAKE"); Into::::into(Path::new(path)).with_attr("json") }; } impl FlakeFn for DevourFlake { type Input = DevourFlakeInput; type Output = DevourFlakeOutput; fn flake() -> &'static FlakeUrl { &DEVOUR_FLAKE } fn init(out: &mut DevourFlakeOutput) { // Remove duplicates, which is possible in user's flake // e.g., when doing `packages.foo = self'.packages.default` out.out_paths.sort(); out.out_paths.dedup(); } } /// Input arguments to devour-flake #[derive(Serialize)] pub struct DevourFlakeInput { /// The flake whose outputs will be built pub flake: FlakeUrl, /// The systems it will build for. An empty list means all allowed systems. pub systems: Option, } /// Output of `devour-flake` #[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct DevourFlakeOutput { /// The built store paths #[serde(rename = "outPaths")] pub out_paths: Vec, /// Output paths indexed by name (or pname) of the path if any #[serde(rename = "byName")] pub by_name: HashMap, } ================================================ FILE: crates/omnix-ci/src/nix/lock.rs ================================================ //! Functions for working with `nix flake lock`. use anyhow::{Ok, Result}; use nix_rs::{ command::NixCmd, flake::{self, command::FlakeOptions, url::FlakeUrl}, }; /// Make sure that the `flake.lock` file is in sync. pub async fn nix_flake_lock_check(nixcmd: &NixCmd, url: &FlakeUrl) -> Result<()> { flake::command::lock( nixcmd, &FlakeOptions::default(), &["--no-update-lock-file"], url, ) .await?; Ok(()) } ================================================ FILE: crates/omnix-ci/src/nix/mod.rs ================================================ //! Nix-specific types and functions pub mod devour_flake; pub mod lock; ================================================ FILE: crates/omnix-ci/src/step/build.rs ================================================ //! The build step use clap::Parser; use colored::Colorize; use nix_rs::{ command::NixCmd, flake::{functions::core::FlakeFn, url::FlakeUrl}, store::{command::NixStoreCmd, path::StorePath}, }; use serde::{Deserialize, Serialize}; use crate::{ command::run::RunCommand, config::subflake::SubflakeConfig, nix::devour_flake::{DevourFlake, DevourFlakeInput, DevourFlakeOutput}, }; /// Represents a build step in the CI pipeline /// /// It builds all flake outputs. /// /// TODO: Should we use [`serde-bool`](https://docs.rs/serde-bool/latest/serde_bool/) to obviate that `Option` types in fields? #[derive(Debug, Clone, Deserialize)] pub struct BuildStep { /// Whether to enable this step pub enable: bool, /// Whether to pass `--impure` to `nix build` #[serde(default)] pub impure: Option, } impl Default for BuildStep { fn default() -> Self { BuildStep { enable: true, impure: None, } } } impl BuildStep { /// Run this step pub async fn run( &self, nixcmd: &NixCmd, run_cmd: &RunCommand, url: &FlakeUrl, subflake: &SubflakeConfig, ) -> anyhow::Result { // Run devour-flake to do the actual build. tracing::info!( "{}", format!("⚒️ Building subflake: {}", subflake.dir).bold() ); let nix_args = subflake_extra_args(subflake); let output = DevourFlake::call( nixcmd, self.impure.unwrap_or(false), None, None, nix_args, DevourFlakeInput { flake: url.sub_flake_url(subflake.dir.clone()), systems: run_cmd.systems.clone().map(|l| l.0), }, ) .await? .1; let mut res = BuildStepResult { devour_flake_output: output, all_deps: None, }; if run_cmd.steps_args.build_step_args.include_all_dependencies { // Handle --include-all-dependencies let all_paths = NixStoreCmd .fetch_all_deps(&res.devour_flake_output.out_paths) .await?; res.all_deps = Some(all_paths); } Ok(res) } } /// Extra args to pass to devour-flake fn subflake_extra_args(subflake: &SubflakeConfig) -> Vec { let mut args = vec![]; for (k, v) in &subflake.override_inputs { args.extend([ "--override-input".to_string(), k.to_string(), v.0.to_string(), ]) } args } /// CLI arguments for [BuildStep] #[derive(Parser, Debug, Clone)] pub struct BuildStepArgs { /// Include build and runtime dependencies along with out paths in the result JSON /// /// By default, `om ci run` includes only the out paths. This option is /// useful to explicitly push all dependencies to a cache. #[clap(long, short = 'd')] pub include_all_dependencies: bool, } impl BuildStepArgs { /// Convert this type back to the user-facing command line arguments pub fn to_cli_args(&self) -> Vec { let mut args = vec![]; if self.include_all_dependencies { args.push("--include-all-dependencies".to_owned()); } args } } /// The result of the build step #[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct BuildStepResult { /// Output of devour-flake #[serde(flatten)] pub devour_flake_output: DevourFlakeOutput, /// All dependencies of the out paths, if available #[serde(skip_serializing_if = "Option::is_none", rename = "allDeps")] pub all_deps: Option>, } ================================================ FILE: crates/omnix-ci/src/step/core.rs ================================================ //! All CI steps available use clap::Parser; use nix_rs::{ command::NixCmd, flake::{system::System, url::FlakeUrl}, }; use serde::{Deserialize, Serialize}; use super::{ build::{BuildStep, BuildStepArgs, BuildStepResult}, custom::CustomSteps, flake_check::FlakeCheckStep, lockfile::LockfileStep, }; use crate::command::run::RunCommand; use crate::config::subflake::SubflakeConfig; /// CI steps to run /// /// Contains some builtin steps, as well as custom steps (defined by user) #[derive(Debug, Default, Clone, Deserialize)] pub struct Steps { /// [LockfileStep] #[serde(default, rename = "lockfile")] pub lockfile_step: LockfileStep, /// [BuildStep] #[serde(default, rename = "build")] pub build_step: BuildStep, /// [FlakeCheckStep] #[serde(default, rename = "flake-check")] pub flake_check_step: FlakeCheckStep, /// Custom steps #[serde(default, rename = "custom")] pub custom_steps: CustomSteps, } /// CLI arguments associated with [Steps] #[derive(Parser, Debug, Clone)] pub struct StepsArgs { /// [BuildStepArgs] #[command(flatten)] pub build_step_args: BuildStepArgs, } /// Results of [Steps] #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct StepsResult { /// [BuildStepResult] #[serde(rename = "build")] pub build_step: Option, } impl Steps { /// Run all CI steps pub async fn run( &self, cmd: &NixCmd, run_cmd: &RunCommand, systems: &[System], url: &FlakeUrl, subflake: &SubflakeConfig, ) -> anyhow::Result { let mut res = StepsResult::default(); if self.lockfile_step.enable { self.lockfile_step.run(cmd, url, subflake).await?; } if self.build_step.enable { let build_res = self.build_step.run(cmd, run_cmd, url, subflake).await?; res.build_step = Some(build_res); } if self.flake_check_step.enable { self.flake_check_step.run(cmd, url, subflake).await?; } self.custom_steps.run(cmd, systems, url, subflake).await?; Ok(res) } } impl StepsArgs { /// Convert this type back to the user-facing command line arguments pub fn to_cli_args(&self) -> Vec { self.build_step_args.to_cli_args() } } ================================================ FILE: crates/omnix-ci/src/step/custom.rs ================================================ //! Custom steps in the CI pipeline use colored::Colorize; use nonempty::NonEmpty; use serde::Deserialize; use std::{collections::BTreeMap, future::Future, path::PathBuf}; use nix_rs::{ command::NixCmd, flake::{ self, system::System, url::{attr::FlakeAttr, FlakeUrl}, }, }; use crate::config::subflake::SubflakeConfig; /// Represents a custom step in the CI pipeline /// /// All these commands are run in the same directory as the subflake #[derive(Debug, Clone, Deserialize)] #[serde(tag = "type")] pub enum CustomStep { /// A flake app to run #[serde(rename = "app")] FlakeApp { /// Name of the app #[serde(default)] name: FlakeAttr, /// Arguments to pass to the app #[serde(default)] args: Vec, /// Whitelist of systems to run on systems: Option>, }, /// An arbitrary command to run in the devshell #[serde(rename = "devshell")] FlakeDevShellCommand { /// Name of the devShell #[serde(default)] name: FlakeAttr, /// The command to run inside of devshell command: NonEmpty, /// Whitelist of systems to run on systems: Option>, }, } impl CustomStep { /// Run this step pub async fn run( &self, nixcmd: &NixCmd, url: &FlakeUrl, subflake: &SubflakeConfig, ) -> anyhow::Result<()> { with_writeable_flake_dir(nixcmd, url, |flake_path| async move { self.run_on_local_path(nixcmd, flake_path, subflake).await }) .await } /// Like [run] but runs on a flake that is known to be at a local path async fn run_on_local_path( &self, nixcmd: &NixCmd, flake_path: PathBuf, subflake: &SubflakeConfig, ) -> anyhow::Result<()> { let path = flake_path.join(&subflake.dir); tracing::info!("Running custom step under: {:}", &path.display()); let pwd_flake = FlakeUrl::from(PathBuf::from(".")); let flake_opts = flake::command::FlakeOptions { override_inputs: subflake.override_inputs.clone(), current_dir: Some(path.clone()), no_write_lock_file: false, }; match self { CustomStep::FlakeApp { name, args, .. } => { flake::command::run( nixcmd, &flake_opts, &pwd_flake.with_attr(&name.get_name()), args.clone(), ) .await?; } CustomStep::FlakeDevShellCommand { name, command, .. } => { flake::command::develop( nixcmd, &flake_opts, &pwd_flake.with_attr(&name.get_name()), command.clone(), ) .await?; } } Ok(()) } fn can_run_on(&self, systems: &[System]) -> bool { match self.get_systems() { Some(systems_whitelist) => systems_whitelist.iter().any(|s| systems.contains(s)), None => true, } } fn get_systems(&self) -> &Option> { match self { CustomStep::FlakeApp { systems, .. } => systems, CustomStep::FlakeDevShellCommand { systems, .. } => systems, } } } /// A collection of custom steps #[derive(Debug, Clone, Default, Deserialize)] pub struct CustomSteps(BTreeMap); impl CustomSteps { /// Run all custom steps pub async fn run( &self, nixcmd: &NixCmd, systems: &[System], url: &FlakeUrl, subflake: &SubflakeConfig, ) -> anyhow::Result<()> { for (name, step) in &self.0 { if step.can_run_on(systems) { tracing::info!("{}", format!("🏗 Running custom step: {}", name).bold()); step.run(nixcmd, url, subflake).await?; } else { tracing::info!( "{}", format!( "🏗 Skipping custom step {} because it's not whitelisted for the current system: {:?}", name, systems.iter().map(|s| s.to_string()).collect::>() ) .yellow() ); } } Ok(()) } } /// Call the given function with a (write-able) local path equivalent to the given URL /// /// The flake is retrieved locally, and stored in a temp directory is created if necessary. /// /// Two reasons for copying to a temp (and writeable) directory: /// 1. `nix run` does not work reliably on store paths (`/nix/store/**`) /// 2. `nix develop -c ...` often requires mutable flake directories async fn with_writeable_flake_dir( nixcmd: &NixCmd, url: &FlakeUrl, f: F, ) -> anyhow::Result<()> where F: FnOnce(PathBuf) -> Fut, Fut: Future>, { // First, ensure that flake is locally available. let local_path = match url.as_local_path() { Some(local_path) => local_path.to_path_buf(), None => url.as_local_path_or_fetch(nixcmd).await?, }; // Then, ensure that it is writeable by the user let read_only = local_path.metadata()?.permissions().readonly(); let path = if read_only { // Two reasons for copying to a temp location: // 1. `nix run` does not work reliably on store paths // 2. `nix develop -c ...` often require mutable flake directories let target_path = tempfile::Builder::new() .prefix("om-ci-") .tempdir()? .path() .join("flake"); omnix_common::fs::copy_dir_all(&local_path, &target_path).await?; target_path } else { local_path }; // Finally, call the function with the path f(path).await } ================================================ FILE: crates/omnix-ci/src/step/flake_check.rs ================================================ //! The cachix step use colored::Colorize; use nix_rs::{ command::NixCmd, flake::{self, command::FlakeOptions, url::FlakeUrl}, }; use serde::Deserialize; use crate::config::subflake::SubflakeConfig; /// Run `nix flake check` /// /// Note: `nix build ...` does not evaluate all the checks that `nix flake check` does. So, enabling this steps allows `om ci` to run those evaluation checks. #[derive(Debug, Clone, Default, Deserialize)] pub struct FlakeCheckStep { /// Whether to enable this step /// /// Disabled by default, since only a handful of flakes need this (for others, it will unnecessarily slow down the build) pub enable: bool, } impl FlakeCheckStep { /// Run this step pub async fn run( &self, nixcmd: &NixCmd, url: &FlakeUrl, subflake: &SubflakeConfig, ) -> anyhow::Result<()> { tracing::info!( "{}", format!("🩺 Running flake check on: {}", subflake.dir).bold() ); let sub_flake_url = url.sub_flake_url(subflake.dir.clone()); let opts = FlakeOptions { override_inputs: subflake.override_inputs.clone(), ..Default::default() }; flake::command::check(nixcmd, &opts, &sub_flake_url).await?; Ok(()) } } ================================================ FILE: crates/omnix-ci/src/step/lockfile.rs ================================================ //! The lockfile step use colored::Colorize; use nix_rs::{command::NixCmd, flake::url::FlakeUrl}; use serde::Deserialize; use crate::{config::subflake::SubflakeConfig, nix}; /// Check that `flake.lock` is not out of date. #[derive(Debug, Clone, Deserialize)] pub struct LockfileStep { /// Whether to enable this step pub enable: bool, } impl Default for LockfileStep { fn default() -> Self { LockfileStep { enable: true } } } impl LockfileStep { /// Run this step pub async fn run( &self, nixcmd: &NixCmd, url: &FlakeUrl, subflake: &SubflakeConfig, ) -> anyhow::Result<()> { if subflake.override_inputs.is_empty() { tracing::info!( "{}", format!("🫀 Checking that {}/flake.lock is up-to-date", subflake.dir).bold() ); let sub_flake_url = url.sub_flake_url(subflake.dir.clone()); nix::lock::nix_flake_lock_check(nixcmd, &sub_flake_url).await?; } Ok(()) } } ================================================ FILE: crates/omnix-ci/src/step/mod.rs ================================================ //! CI is broken down into various 'steps'. pub mod build; pub mod core; pub mod custom; pub mod flake_check; pub mod lockfile; ================================================ FILE: crates/omnix-cli/Cargo.toml ================================================ [package] name = "omnix-cli" version = "1.3.2" edition = "2021" default-run = "om" # NOTE: The 'description' here will be printed in `om` CLI banner (thanks to `clap` crate) description = "omnix: a developer-friendly companion for Nix " homepage = "https://omnix.page" repository = "https://github.com/juspay/omnix" license = "AGPL-3.0-only" [lib] crate-type = ["cdylib", "rlib"] [[bin]] name = "om" path = "src/main.rs" [dependencies] anyhow = { workspace = true } clap = { workspace = true } clap-verbosity-flag = { workspace = true } colored = { workspace = true } human-panic = { workspace = true } omnix-ci = { workspace = true } omnix-health = { workspace = true } nix_rs = { workspace = true } omnix-common = { workspace = true } omnix-init = { workspace = true } omnix-develop = { workspace = true } tabled = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } clap_complete = { workspace = true } clap_complete_nushell = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } [dev-dependencies] anyhow = { workspace = true } assert_cmd = "2" assert_fs = "1" assert_matches = "1.5" ctor = "0.2" predicates = "3" regex = "1.9" ================================================ FILE: crates/omnix-cli/crate.nix ================================================ { pkgs , lib , ... }: let inherit (pkgs) stdenv pkgsStatic; in { autoWire = [ ]; crane = { args = { nativeBuildInputs = [ # Packages from `pkgsStatic` require cross-compilation support for the target platform, # which is not yet available for `x86_64-apple-darwin` in nixpkgs. Upon trying to evaluate # a static package for `x86_64-apple-darwin`, you may see an error like: # # > error: don't yet have a `targetPackages.darwin.LibsystemCross for x86_64-apple-darwin` (if (stdenv.isDarwin && stdenv.isAarch64) then pkgsStatic.libiconv else pkgs.libiconv) pkgs.pkg-config ]; buildInputs = lib.optionals pkgs.stdenv.isLinux [ pkgsStatic.openssl ]; # Disable tests due to sandboxing issues; we run them on CI # instead. doCheck = false; meta = { description = "Command-line interface for Omnix"; mainProgram = "om"; }; CARGO_BUILD_RUSTFLAGS = "-C target-feature=+crt-static"; hardeningDisable = [ "fortify" ]; # https://github.com/NixOS/nixpkgs/issues/18995#issuecomment-249748307 } // lib.optionalAttrs (stdenv.isLinux && stdenv.isAarch64) { CARGO_BUILD_TARGET = "aarch64-unknown-linux-musl"; } // lib.optionalAttrs (stdenv.isLinux && stdenv.isx86_64) { CARGO_BUILD_TARGET = "x86_64-unknown-linux-musl"; }; }; } ================================================ FILE: crates/omnix-cli/src/args.rs ================================================ use clap::Parser; use clap_verbosity_flag::{InfoLevel, Verbosity}; use crate::command::core::Command; /// Omnix CLI entrypoint #[derive(Parser, Debug)] #[command(version, about)] pub struct Args { #[command(flatten)] pub verbosity: Verbosity, #[clap(subcommand)] pub command: Command, } ================================================ FILE: crates/omnix-cli/src/command/ci.rs ================================================ use clap::Parser; use omnix_ci::command::core::Command; /// Build all outputs of the flake #[derive(Parser, Debug)] pub struct CICommand { #[clap(subcommand)] command: Option, } impl CICommand { /// Run this sub-command pub async fn run(&self) -> anyhow::Result<()> { self.command().run().await?; Ok(()) } /// Get the command to run /// /// If the user has not provided one, return the build command by default. fn command(&self) -> Command { self.command.clone().unwrap_or_default() } } ================================================ FILE: crates/omnix-cli/src/command/completion.rs ================================================ use clap::CommandFactory; use clap::Parser; use clap_complete::generate; /// Generates shell completion scripts #[derive(Parser, Debug)] pub struct CompletionCommand { #[arg(value_enum)] shell: Shell2, } impl CompletionCommand { pub fn run(&self) -> anyhow::Result<()> { generate_completion(self.shell); Ok(()) } } /// Like `clap::Shell`, but with an additional variant for Nushell #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, clap::ValueEnum)] #[non_exhaustive] enum Shell2 { Bash, Elvish, Fish, PowerShell, Zsh, Nushell, } impl Shell2 { fn nushell_or(self) -> Result { match self { Shell2::Nushell => Err(clap_complete_nushell::Nushell), Shell2::Bash => Ok(clap_complete::Shell::Bash), Shell2::Elvish => Ok(clap_complete::Shell::Elvish), Shell2::Fish => Ok(clap_complete::Shell::Fish), Shell2::PowerShell => Ok(clap_complete::Shell::PowerShell), Shell2::Zsh => Ok(clap_complete::Shell::Zsh), } } } fn generate_completion(shell: Shell2) { let cmd = &mut crate::args::Args::command(); let out = &mut std::io::stdout(); match shell.nushell_or() { Ok(shell) => generate(shell, cmd, "om", out), Err(shell) => generate(shell, cmd, "om", out), } } ================================================ FILE: crates/omnix-cli/src/command/core.rs ================================================ use clap::Subcommand; #[derive(Subcommand, Debug)] pub enum Command { Show(super::show::ShowCommand), Init(super::init::InitCommand), #[clap(alias = "hack")] Develop(super::develop::DevelopCommand), CI(super::ci::CICommand), Health(super::health::HealthCommand), Completion(super::completion::CompletionCommand), } impl Command { pub async fn run(&self) -> anyhow::Result<()> { if !matches!(self, Command::Completion(_)) && !omnix_common::check::nix_installed() { tracing::error!("Nix is not installed: https://nixos.asia/en/install"); std::process::exit(1); } match self { Command::Show(cmd) => cmd.run().await, Command::Init(cmd) => cmd.run().await, Command::Develop(cmd) => cmd.run().await, Command::CI(cmd) => cmd.run().await, Command::Health(cmd) => cmd.run().await, Command::Completion(cmd) => cmd.run(), } } } ================================================ FILE: crates/omnix-cli/src/command/develop.rs ================================================ use clap::Parser; use nix_rs::{command::NixCmd, flake::url::FlakeUrl}; use omnix_common::config::OmConfig; /// Prepare to develop on a flake project #[derive(Parser, Debug)] pub struct DevelopCommand { /// Directory of the project #[arg(name = "DIR", default_value = ".")] flake_shell: FlakeUrl, /// The stage to run in. If not provided, runs all stages. #[arg(long, value_enum)] stage: Option, /// Nix command global options #[command(flatten)] pub nixcmd: NixCmd, } /// The stage to run in #[derive(clap::ValueEnum, Debug, Clone)] enum Stage { /// Stage before Nix shell is invoked. PreShell, /// Stage after Nix shell is successfully invoked. PostShell, } impl DevelopCommand { pub async fn run(&self) -> anyhow::Result<()> { let flake = self.flake_shell.without_attr(); let om_config = OmConfig::get(&self.nixcmd, &flake).await?; tracing::info!("⌨️ Preparing to develop project: {:}", &flake); let prj = omnix_develop::core::Project::new(flake, om_config).await?; match self.stage { Some(Stage::PreShell) => omnix_develop::core::develop_on_pre_shell(&prj).await?, Some(Stage::PostShell) => omnix_develop::core::develop_on_post_shell(&prj).await?, None => omnix_develop::core::develop_on(&prj).await?, } Ok(()) } } ================================================ FILE: crates/omnix-cli/src/command/health.rs ================================================ use clap::Parser; use nix_rs::{command::NixCmd, flake::url::FlakeUrl}; use omnix_health::{run_all_checks_with, NixHealth}; /// Display the health of your Nix dev environment #[derive(Parser, Debug)] pub struct HealthCommand { /// Use `om.health` configuration from the given flake #[arg(name = "FLAKE")] pub flake_url: Option, /// Dump the config schema of the health checks (useful when adding them to /// a flake.nix) #[arg(long = "dump-schema")] pub dump_schema: bool, /// Print output in JSON #[arg(long)] json: bool, /// Nix command global options #[command(flatten)] pub nixcmd: NixCmd, } impl HealthCommand { pub async fn run(&self) -> anyhow::Result<()> { if self.dump_schema { println!("{}", NixHealth::schema()?); } else { let checks = run_all_checks_with(&self.nixcmd, self.flake_url.clone(), self.json).await?; let exit_code = NixHealth::print_report_returning_exit_code(&checks, self.json).await?; if exit_code != 0 { std::process::exit(exit_code); } } Ok(()) } } ================================================ FILE: crates/omnix-cli/src/command/init.rs ================================================ use std::{collections::HashMap, path::PathBuf, str::FromStr}; use clap::Parser; use nix_rs::{command::NixCmd, config::NixConfig, flake::url::FlakeUrl}; use serde_json::Value; /// Initialize a new flake project #[derive(Parser, Debug)] pub struct InitCommand { /// Where to create the template #[arg( name = "OUTPUT_DIR", short = 'o', long = "output", required_unless_present = "test" )] path: Option, /// The flake from which to initialize the template to use /// /// Defaults to builtin registry of flake templates. #[arg(name = "FLAKE_URL")] flake: Option, /// Parameter values to use for the template by default. #[arg(long = "params")] params: Option, /// Whether to disable all prompting, making the command non-interactive #[arg(long = "non-interactive")] non_interactive: bool, /// Run template tests, instead of initializing the template #[arg( long = "test", requires = "FLAKE_URL", conflicts_with = "non_interactive", conflicts_with = "params", conflicts_with = "OUTPUT_DIR" )] test: bool, /// Nix command global options #[command(flatten)] pub nixcmd: NixCmd, } impl InitCommand { pub async fn run(&self) -> anyhow::Result<()> { if self.test { let cfg = NixConfig::get().await.as_ref()?; omnix_init::core::run_tests( &self.nixcmd, &cfg.system.value, &self.registry_choose().await?, ) .await?; } else { let path = self.path.as_ref().unwrap(); // unwrap is okay, because of `required_unless_present` if path.exists() { // Make sure that the directory does not already exist. We don't risk mutating accidentally incorrect location! anyhow::bail!("Output directory already exists: {}", path.display()); } let params = self .params .as_ref() .map_or_else(HashMap::new, |hm| hm.0.clone()); omnix_init::core::run( &self.nixcmd, path, &self.registry_choose().await?, ¶ms, self.non_interactive, ) .await?; } Ok(()) } async fn registry_choose(&self) -> anyhow::Result { match self.flake { Some(ref flake) => Ok(flake.clone()), None => omnix_init::core::select_from_registry(&self.nixcmd).await, } } } /// A map of parameter values #[derive(Clone, Debug, Default)] struct Params(HashMap); /// Convenience for passing JSON in command line impl FromStr for Params { type Err = serde_json::Error; fn from_str(s: &str) -> Result { let map: HashMap = serde_json::from_str(s)?; Ok(Params(map)) } } ================================================ FILE: crates/omnix-cli/src/command/mod.rs ================================================ pub mod ci; pub mod completion; pub mod core; pub mod develop; pub mod health; pub mod init; pub mod show; ================================================ FILE: crates/omnix-cli/src/command/show.rs ================================================ use std::io::IsTerminal; use anyhow::Context; use clap::Parser; use colored::Colorize; use nix_rs::{ command::NixCmd, config::NixConfig, flake::{outputs::FlakeOutputs, url::FlakeUrl, Flake}, }; use tabled::{ settings::{location::ByColumnName, Color, Modify, Style}, Table, Tabled, }; /// Inspect the outputs of a flake #[derive(Parser, Debug)] pub struct ShowCommand { /// The flake to show outputs for #[arg(name = "FLAKE")] pub flake_url: FlakeUrl, /// Nix command global options #[command(flatten)] pub nixcmd: NixCmd, } /// Tabular representation of a set of flake outputs (eg: `packages.*`) pub struct FlakeOutputTable { /// Rows of the table pub rows: Vec, /// Title of the table pub title: String, /// Command to run the outputs in the `name` column pub command: Option, } impl FlakeOutputTable { /// Convert the table to a [Table] struct fn to_tabled(&self) -> Table { let mut table = Table::new(&self.rows); table.with(Style::rounded()); if std::io::stdout().is_terminal() { table.with(Modify::new(ByColumnName::new("name")).with(Color::BOLD)); }; table } /// Print the table to stdout pub fn print(&self) { if self.rows.is_empty() { return; } print!("{}", self.title.blue().bold()); if let Some(command) = &self.command { println!(" ({})", command.green().bold()); } else { // To ensure the table name and the table are on separate lines println!(); } println!("{}", self.to_tabled()); println!(); } } /// Row in a [FlakeOutputTable] #[derive(Tabled)] pub struct Row { /// Name of the output pub name: String, /// Description of the output pub description: String, } impl Row { /// Convert a [FlakeOutputs] to a vector of [Row]s pub fn vec_from_flake_output(output: &FlakeOutputs) -> Vec { output .get_attrset_of_val() .into_iter() .map(|(name, val)| Row { name, description: val .short_description .filter(|s| !s.is_empty()) .unwrap_or(String::from("N/A")) .to_owned(), }) .collect() } } impl ShowCommand { pub async fn run(&self) -> anyhow::Result<()> { let nix_config = NixConfig::get().await.as_ref()?; let system = &nix_config.system.value; let flake = Flake::from_nix(&self.nixcmd, nix_config, self.flake_url.clone()) .await .with_context(|| "Unable to fetch flake")?; let print_flake_output_table = |title: &str, keys: &[&str], command: Option| { FlakeOutputTable { rows: flake .output .get_by_path(keys) .map_or(vec![], Row::vec_from_flake_output), title: title.to_string(), command, } .print(); }; print_flake_output_table( "📦 Packages", &["packages", system.as_ref()], Some(format!("nix build {}#", self.flake_url)), ); print_flake_output_table( "🐚 Devshells", &["devShells", system.as_ref()], Some(format!("nix develop {}#", self.flake_url)), ); print_flake_output_table( "🚀 Apps", &["apps", system.as_ref()], Some(format!("nix run {}#", self.flake_url)), ); print_flake_output_table( "🔍 Checks", &["checks", system.as_ref()], Some("nix flake check".to_string()), ); print_flake_output_table( "🐧 NixOS Configurations", &["nixosConfigurations"], Some(format!( "nixos-rebuild switch --flake {}#", self.flake_url )), ); print_flake_output_table( "🍏 Darwin Configurations", &["darwinConfigurations"], Some(format!( "darwin-rebuild switch --flake {}#", self.flake_url )), ); print_flake_output_table("🔧 NixOS Modules", &["nixosModules"], None); print_flake_output_table( "🐳 Docker Images", &["dockerImages"], Some(format!("nix build {}#dockerImages.", self.flake_url)), ); print_flake_output_table("🎨 Overlays", &["overlays"], None); print_flake_output_table( "📝 Templates", &["templates"], Some(format!("nix flake init -t {}#", self.flake_url)), ); print_flake_output_table("📜 Schemas", &["schemas"], None); Ok(()) } } ================================================ FILE: crates/omnix-cli/src/lib.rs ================================================ pub mod args; pub mod command; ================================================ FILE: crates/omnix-cli/src/main.rs ================================================ use clap::Parser; #[tokio::main] async fn main() -> anyhow::Result<()> { // To avoid clippy warning // error: use of deprecated type alias `std::panic::PanicInfo`: use `PanicHookInfo` instead #[allow(deprecated)] { human_panic::setup_panic!(); } let args = omnix_cli::args::Args::parse(); let verbose = args.verbosity.log_level() > Some(clap_verbosity_flag::Level::Info); omnix_common::logging::setup_logging(&args.verbosity, !verbose); tracing::debug!("Args: {:?}", args); args.command.run().await } ================================================ FILE: crates/omnix-cli/tests/command/ci.rs ================================================ use std::path::{Path, PathBuf}; use anyhow::bail; use nix_rs::store::path::StorePath; use regex::Regex; use serde::de::DeserializeOwned; use serde_json::Value; use super::core::om; /// Run `om ci run` passing given arguments, returning its stdout (parsed). async fn om_ci_run(args: &[&str]) -> anyhow::Result { let mut cmd = om()?; cmd.arg("ci").arg("run").args(args); let output = cmd.output()?; if !output.status.success() { bail!( "Failed to run `om ci run`:\n{}", String::from_utf8_lossy(&output.stderr).to_string(), ); } let stdout = String::from_utf8_lossy(&output.stdout); let out = StorePath::new(PathBuf::from(stdout.trim())); Ok(out) } #[tokio::test] /// Run `om ci build` and check if the stdout consists of only /nix/store/* paths async fn build_flake_output() -> anyhow::Result<()> { let out = om_ci_run(&["github:srid/haskell-multi-nix/c85563721c388629fa9e538a1d97274861bc8321"]) .await?; assert!( out.as_path().starts_with("/nix/store/"), "Unexpected line in stdout: {}", out ); Ok(()) } #[tokio::test] /// A simple test, without config async fn test_haskell_multi_nix() -> anyhow::Result<()> { let out = om_ci_run(&["github:srid/haskell-multi-nix/c85563721c388629fa9e538a1d97274861bc8321"]) .await?; let v: Value = serde_json::from_reader(std::fs::File::open(&out)?)?; let paths: Vec = lookup_path(&v, &["result", "ROOT", "build", "outPaths"]).unwrap(); let expected = vec![ "/nix/store/3x2kpymc1qmd05da20wnmdyam38jkl7s-ghc-shell-for-packages-0", "/nix/store/dzhf0i3wi69568m5nvyckck8bbs9yrfd-foo-0.1.0.0", "/nix/store/hsj8mwn9vzlyaxzmwyf111scisnjhlkb-bar-0.1.0.0", "/nix/store/hsj8mwn9vzlyaxzmwyf111scisnjhlkb-bar-0.1.0.0/bin/bar", ] .into_iter() .map(|s| PathBuf::from(s.to_string())) .collect::>(); assert_same_drvs(paths, expected); Ok(()) } #[tokio::test] async fn test_haskell_multi_nix_all_dependencies() -> anyhow::Result<()> { let out = om_ci_run(&[ "--include-all-dependencies", "github:srid/haskell-multi-nix/c85563721c388629fa9e538a1d97274861bc8321", ]) .await?; let v: Value = serde_json::from_reader(std::fs::File::open(&out)?)?; let paths: Vec = lookup_path(&v, &["result", "ROOT", "build", "allDeps"]).unwrap(); // Since the number of dependencies is huge, we just check for the presence of system-independent // source of the `foo` sub-package in `haskell-multi-nix`. let expected = PathBuf::from("/nix/store/bpybsny4gd5jnw0lvk5khpq7md6nwg5f-source-foo"); assert!(paths.contains(&expected)); Ok(()) } #[tokio::test] /// Whether `--override-input` passes CI successfully async fn test_haskell_multi_nix_override_input() -> anyhow::Result<()> { let _out = om_ci_run(&[ "github:srid/haskell-multi-nix/c85563721c388629fa9e538a1d97274861bc8321", "--", "--override-input", "haskell-flake", // haskell-flake 0.4 release "github:srid/haskell-flake/c8622c8a259e18e0a1919462ce885380108a723c", ]) .await?; Ok(()) } #[tokio::test] /// A test, with config async fn test_services_flake() -> anyhow::Result<()> { let out = om_ci_run(&[ "github:juspay/services-flake/23cf162387af041035072ee4a9de20f8408907cb#default.simple-example", ]) .await?; let v: Value = serde_json::from_reader(std::fs::File::open(&out)?)?; let paths: Vec = lookup_path(&v, &["result", "simple-example", "build", "outPaths"]).unwrap(); let expected = vec![ "/nix/store/ib83flb2pqjb416qrjbs4pqhifa3hhs4-default-test", "/nix/store/l9c8y2xx2iffk8l1ipp4mkval8wl8paa-default", "/nix/store/pj2l11lc4kai6av32hgfsrsvmga7vkhf-nix-shell", ] .into_iter() .map(|s| PathBuf::from(s.to_string())) .collect::>(); assert_same_drvs(paths, expected); Ok(()) } pub fn assert_same_drvs(drvs1: Vec, drvs2: Vec) { assert_eq!(drvs1.len(), drvs2.len()); let mut drv1 = drvs1 .into_iter() .map(|d| without_hash(&d)) .collect::>(); let mut drv2 = drvs2 .into_iter() .map(|d| without_hash(&d)) .collect::>(); drv1.sort(); drv2.sort(); assert_eq!(drv1, drv2); } pub fn without_hash(out_path: &Path) -> String { let re = Regex::new(r".+\-(.+)").unwrap(); let captures = re.captures(out_path.to_str().unwrap()).unwrap(); captures.get(1).unwrap().as_str().to_string() } /// Lookup a path in the [`serde_json::Value`] fn lookup_path(v: &Value, path: &[&str]) -> Option where T: DeserializeOwned, { match path { [] => None, [key] => v .get(key) .and_then(|v| serde_json::from_value(v.clone()).ok()), [key, rest @ ..] => v.get(key).and_then(|v| lookup_path(v, rest)), } } ================================================ FILE: crates/omnix-cli/tests/command/core.rs ================================================ use assert_cmd::Command; /// `om --help` works #[test] fn om_help() -> anyhow::Result<()> { om()?.arg("--help").assert().success(); Ok(()) } /// Return the [Command] pointing to the `om` cargo bin pub(crate) fn om() -> anyhow::Result { Ok(Command::cargo_bin("om")?) } ================================================ FILE: crates/omnix-cli/tests/command/health.rs ================================================ use predicates::{prelude::*, str::contains}; use super::core::om; /// `om health` runs, and succeeds. #[test] fn om_health() -> anyhow::Result<()> { om()? .arg("health") .assert() .success() .stderr(contains("All checks passed").or(contains("Required checks passed"))); Ok(()) } ================================================ FILE: crates/omnix-cli/tests/command/init.rs ================================================ use nix_rs::{command::NixCmd, config::NixConfig}; /// `om init` runs and successfully initializes a template #[tokio::test] async fn om_init() -> anyhow::Result<()> { let registry = omnix_init::registry::get(NixCmd::get().await) .await .as_ref()?; let cfg = NixConfig::get().await.as_ref()?; let current_system = &cfg.system.value; for url in registry.0.values() { // TODO: Refactor(DRY) with src/core.rs:run_tests // TODO: Make this test (and other tests) use tracing! println!("🕍 Testing template: {}", url); let templates = omnix_init::config::load_templates(NixCmd::get().await, url).await?; for template in templates { let tests = &template.template.tests; for (name, test) in tests { if test.can_run_on(current_system) { println!( "🧪 [{}#{}] Running test: {}", url, template.template_name, name ); test.run_test( &url.with_attr(&format!("{}.{}", template.template_name, name)), &template, ) .await?; } else { println!( "⚠️ Skipping test: {} (cannot run on {})", name, current_system ); } } } } Ok(()) } ================================================ FILE: crates/omnix-cli/tests/command/mod.rs ================================================ mod ci; mod core; mod health; mod init; mod show; ================================================ FILE: crates/omnix-cli/tests/command/show.rs ================================================ use predicates::{prelude::*, str::contains}; use super::core::om; /// `om show` runs, and succeeds for a local flake. #[test] fn om_show_local() -> anyhow::Result<()> { om()?.arg("show").arg("../..").assert().success().stdout( contains("Packages") .and(contains("Apps")) .and(contains("Devshells")) .and(contains("Checks")), ); Ok(()) } /// `om show` runs, and succeeds for a remote flake. #[test] fn om_show_remote() -> anyhow::Result<()> { om()? .arg("show") .arg("github:srid/haskell-multi-nix/c85563721c388629fa9e538a1d97274861bc8321") .assert() .success() .stdout(contains("bar").and(contains( "github:srid/haskell-multi-nix/c85563721c388629fa9e538a1d97274861bc8321", ))); Ok(()) } /// `om show` displays `nixosConfigurations` /// Note: This is used to test `evalOnAllSystems` (see: https://github.com/juspay/omnix/pull/277#discussion_r1760164052). #[test] fn om_show_nixos_configurations() -> anyhow::Result<()> { om()? .arg("show") .arg("github:srid/nixos-config/fe9c16cc6a60bbc17646c15c8ce3c5380239ab92") .assert() .success() .stdout(contains("NixOS Configurations").and(contains("immediacy"))); Ok(()) } ================================================ FILE: crates/omnix-cli/tests/flake.nix ================================================ # A dummy flake to cache test dependencies in Nix store. { inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; flake-parts.url = "github:hercules-ci/flake-parts"; systems.url = "github:nix-systems/default"; # NOTE: These inputs should kept in sync with those used in the Rust source (cli.rs) haskell-multi-nix.url = "github:srid/haskell-multi-nix/c85563721c388629fa9e538a1d97274861bc8321"; services-flake.url = "github:juspay/services-flake/23cf162387af041035072ee4a9de20f8408907cb"; nixos-config.url = "github:srid/nixos-config/fe9c16cc6a60bbc17646c15c8ce3c5380239ab92"; # FIXME: Sadly, these will still result in rate-limiting errors, due to the 60/hour limit. # See https://github.com/NixOS/nix/issues/5409 # system_list.rs tests nix-systems-empty.url = "github:nix-systems/empty"; # Used in `om init` tests haskell-flake.url = "github:srid/haskell-flake"; haskell-template.url = "github:srid/haskell-template"; rust-nix-template.url = "github:srid/rust-nix-template"; nixos-unified-template.url = "github:juspay/nixos-unified-template"; }; outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } { systems = import inputs.systems; perSystem = { system, ... }: { packages = { haskell-multi-nix = inputs.haskell-multi-nix.packages.${system}.default; }; }; }; } ================================================ FILE: crates/omnix-cli/tests/test.rs ================================================ extern crate assert_matches; mod command; ================================================ FILE: crates/omnix-common/Cargo.toml ================================================ [package] name = "omnix-common" version = "1.3.0" edition = "2021" repository = "https://github.com/juspay/omnix" license = "AGPL-3.0-only" description = "Common functionality for omnix frontends" [lib] crate-type = ["cdylib", "rlib"] [dependencies] anyhow = { workspace = true } async-walkdir = { workspace = true } clap = { workspace = true } clap-verbosity-flag = { workspace = true } futures-lite = { workspace = true } nix_rs = { workspace = true } pulldown-cmark-mdcat = { workspace = true } pulldown-cmark = { workspace = true } lazy_static = { workspace = true } serde = { workspace = true } syntect = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } which = { workspace = true } serde_json = { workspace = true } serde_yaml = { workspace = true } ================================================ FILE: crates/omnix-common/crate.nix ================================================ { autoWire = [ ]; } ================================================ FILE: crates/omnix-common/src/check.rs ================================================ //! Prerequisite checks for the Omnix project. use std::path::PathBuf; use which::{which, Error}; /// Check if Nix is installed. pub fn nix_installed() -> bool { which_strict("nix").is_some() } /// Check if a binary is available in the system's PATH and return its path. /// Returns None if the binary is not found, panics on unexpected errors. pub fn which_strict(binary: &str) -> Option { match which(binary) { Ok(path) => Some(path), Err(Error::CannotFindBinaryPath) => None, Err(e) => panic!( "Unexpected error while searching for binary '{}': {:?}", binary, e ), } } ================================================ FILE: crates/omnix-common/src/config.rs ================================================ //! Manage omnix configuration in flake.nix use std::collections::BTreeMap; use nix_rs::{ command::NixCmd, flake::{command::FlakeOptions, eval::nix_eval_maybe, url::FlakeUrl}, }; use serde::{de::DeserializeOwned, Deserialize}; #[cfg(test)] use std::str::FromStr; /// [OmConfigTree] with additional metadata about the flake URL and reference. /// /// `reference` here is the part of the flake URL after `#` #[derive(Debug)] pub struct OmConfig { /// The flake URL used to load this configuration pub flake_url: FlakeUrl, /// The (nested) key reference into the flake config. pub reference: Vec, /// The config tree pub config: OmConfigTree, } impl OmConfig { /// Fetch the `om` configuration from `om.yaml` if present, falling back to `om` config in flake output pub async fn get(nixcmd: &NixCmd, flake_url: &FlakeUrl) -> Result { match Self::from_yaml(nixcmd, flake_url).await? { None => Self::from_flake(nixcmd, flake_url).await, Some(config) => Ok(config), } } /// Read the configuration from `om.yaml` in flake root async fn from_yaml( nixcmd: &NixCmd, flake_url: &FlakeUrl, ) -> Result, OmConfigError> { let path = if let Some(local_path) = flake_url.without_attr().as_local_path() { local_path.to_path_buf() } else { (flake_url.without_attr()) .as_local_path_or_fetch(nixcmd) .await? } .join("om.yaml"); if !path.exists() { tracing::debug!("{:?} does not exist; evaluating flake", path); return Ok(None); } let yaml_str = std::fs::read_to_string(path)?; let config: OmConfigTree = serde_yaml::from_str(&yaml_str)?; Ok(Some(OmConfig { flake_url: flake_url.without_attr(), reference: flake_url.get_attr().as_list(), config, })) } /// Read the configuration from `om` flake output async fn from_flake(nixcmd: &NixCmd, flake_url: &FlakeUrl) -> Result { Ok(OmConfig { flake_url: flake_url.without_attr(), reference: flake_url.get_attr().as_list(), config: nix_eval_maybe(nixcmd, &FlakeOptions::default(), &flake_url.with_attr("om")) .await? .unwrap_or_default(), }) } /// Get the user referenced (per `referenced`) sub-tree under the given root key. /// /// get_sub_config_under("ci") will return `ci.default` (or Default instance if config is missing) without a reference. Otherwise, it will use the reference to find the correct sub-tree. pub fn get_sub_config_under(&self, root_key: &str) -> Result<(T, &[String]), OmConfigError> where T: Default + DeserializeOwned + Clone, { // Get the config map, returning default if it doesn't exist let config = match self.config.get::(root_key)? { Some(res) => res, None => return Ok((T::default(), &[])), }; let default = "default".to_string(); let (k, rest) = self.reference.split_first().unwrap_or((&default, &[])); let v: &T = config .get(k) .ok_or(OmConfigError::MissingConfigAttribute(k.to_string()))?; Ok((v.clone(), rest)) } } /// Represents the whole configuration for `omnix` parsed from JSON #[derive(Debug, Default, Deserialize)] pub struct OmConfigTree(BTreeMap>); impl OmConfigTree { /// Get all the configs of type `T` for a given sub-config /// /// Return None if key doesn't exist pub fn get(&self, key: &str) -> Result>, serde_json::Error> where T: DeserializeOwned, { match self.0.get(key) { Some(config) => { let result: Result, _> = config .iter() .map(|(k, v)| serde_json::from_value(v.clone()).map(|value| (k.clone(), value))) .collect(); result.map(Some) } None => Ok(None), } } } /// Error type for OmConfig #[derive(thiserror::Error, Debug)] pub enum OmConfigError { /// Missing configuration attribute #[error("Missing configuration attribute: {0}")] MissingConfigAttribute(String), /// A [nix_rs::command::NixCmdError] #[error("Nix command error: {0}")] NixCmdError(#[from] nix_rs::command::NixCmdError), /// Flake function error #[error("Flake function error: {0}")] FlakeFnError(#[from] nix_rs::flake::functions::core::Error), /// Failed to parse JSON #[error("Failed to decode (json error): {0}")] DecodeErrorJson(#[from] serde_json::Error), /// Failed to parse yaml #[error("Failed to parse yaml: {0}")] ParseYaml(#[from] serde_yaml::Error), /// Failed to read yaml #[error("Failed to read yaml: {0}")] ReadYaml(#[from] std::io::Error), } #[tokio::test] async fn test_get_missing_sub_config() { let om_config_empty_reference = OmConfig { flake_url: FlakeUrl::from_str(".").unwrap(), reference: vec![], config: serde_yaml::from_str("").unwrap(), }; let om_config_with_reference = OmConfig { flake_url: FlakeUrl::from_str(".").unwrap(), reference: vec!["foo".to_string()], config: serde_yaml::from_str("").unwrap(), }; let (res_empty_reference, _rest) = om_config_empty_reference .get_sub_config_under::("health") .unwrap(); let (res_with_reference, _rest) = om_config_with_reference .get_sub_config_under::("health") .unwrap(); assert_eq!(res_empty_reference, String::default()); assert_eq!(res_with_reference, String::default()); } #[tokio::test] async fn test_get_omconfig_from_remote_flake_with_attr() { let om_config = OmConfig::get( NixCmd::get().await, &FlakeUrl::from_str( "github:juspay/omnix/0ed2a389d6b4c8eb78caed778e20e872d2a59973#default.omnix", ) .unwrap(), ) .await; assert!(om_config.is_ok()); } ================================================ FILE: crates/omnix-common/src/fs.rs ================================================ //! Filesystem utilities use async_walkdir::{DirEntry, WalkDir}; use futures_lite::stream::StreamExt; use std::{ os::unix::fs::PermissionsExt, path::{Path, PathBuf}, }; use tokio::fs; /// Copy a directory recursively /// /// The target directory will always be user readable & writable. pub async fn copy_dir_all(src: impl AsRef, dst: impl AsRef) -> anyhow::Result<()> { let mut walker = WalkDir::new(&src); while let Some(entry) = walker.next().await { copy_entry(&src, entry?, &dst).await?; } Ok(()) } async fn copy_entry( src: impl AsRef, entry: DirEntry, dst: impl AsRef, ) -> anyhow::Result<()> { let path = &entry.path(); let relative = path.strip_prefix(src)?; let target = dst.as_ref().join(relative); let file_type = entry.file_type().await?; if file_type.is_dir() { // Handle directories fs::create_dir_all(&target).await?; } else { if let Some(parent) = target.parent() { fs::create_dir_all(parent).await?; } if file_type.is_symlink() { // Handle symlinks *as is* (we expect relative symlink targets) without resolution. let link_target = fs::read_link(path).await?; fs::symlink(&link_target, &target).await?; } else { // Handle regular files fs::copy(path, &target).await?; // Because we are copying from the Nix store, the source paths will be read-only. // So, make the target writeable by the owner. make_owner_writeable(&target).await?; } } Ok(()) } async fn make_owner_writeable(path: impl AsRef) -> anyhow::Result<()> { let path = path.as_ref(); let mut perms = fs::metadata(path).await?.permissions(); perms.set_mode(perms.mode() | 0o600); // Read/write for owner fs::set_permissions(path, perms).await?; Ok(()) } /// Recursively find paths under a directory /// /// Returned list of files or directories are relative to the given directory. pub async fn find_paths(dir: impl AsRef + Copy) -> anyhow::Result> { let mut paths = Vec::new(); let mut walker = WalkDir::new(dir); while let Some(entry) = walker.next().await { let entry = entry?; let path = entry.path(); paths.push(path.strip_prefix(dir)?.to_path_buf()); } Ok(paths) } /// Recursively delete the path pub async fn remove_all(path: impl AsRef) -> anyhow::Result<()> { let path = path.as_ref(); if path.is_dir() { fs::remove_dir_all(path).await?; } else { fs::remove_file(path).await?; } Ok(()) } ================================================ FILE: crates/omnix-common/src/lib.rs ================================================ //! Omnix library crate #![warn(missing_docs)] pub mod check; pub mod config; pub mod fs; pub mod logging; pub mod markdown; ================================================ FILE: crates/omnix-common/src/logging.rs ================================================ //! Logging setup for omnix use clap_verbosity_flag::{InfoLevel, Level, Verbosity}; use std::fmt; use tracing::{Event, Subscriber}; use tracing_subscriber::{ filter::{Directive, LevelFilter}, fmt::{format, FmtContext, FormatEvent, FormatFields}, registry::LookupSpan, EnvFilter, }; /// Setup logging for whole application pub fn setup_logging(verbosity: &Verbosity, bare: bool) { let builder = tracing_subscriber::fmt() .with_writer(std::io::stderr) .with_env_filter(log_filter(verbosity)) .compact(); if bare { builder.event_format(BareFormatter).init(); } else { builder.init(); } } /// Return the log filter for CLI flag. fn log_filter(v: &Verbosity) -> EnvFilter { log_directives(v) .iter() .fold(EnvFilter::from_env("OMNIX_LOG"), |filter, directive| { filter.add_directive(directive.clone()) }) } fn log_directives(v: &Verbosity) -> Vec { // Allow warnings+errors from all crates. match v.log_level() { None => vec![LevelFilter::WARN.into()], Some(Level::Warn) => vec![LevelFilter::WARN.into()], Some(Level::Error) => vec![LevelFilter::ERROR.into()], // Default Some(Level::Info) => vec![ LevelFilter::WARN.into(), "om=info".parse().unwrap(), "nix_rs=info".parse().unwrap(), "omnix-health=info".parse().unwrap(), "omnix-ci=info".parse().unwrap(), "omnix-init=info".parse().unwrap(), "omnix-hack=info".parse().unwrap(), ], // -v: log app DEBUG level, as well as http requests Some(Level::Debug) => vec![ LevelFilter::WARN.into(), "om=debug".parse().unwrap(), "nix_rs=debug".parse().unwrap(), "omnix-health=debug".parse().unwrap(), "omnix-ci=debug".parse().unwrap(), "omnix-init=debug".parse().unwrap(), "omnix-hack=debug".parse().unwrap(), ], // -vv: log app TRACE level, as well as http requests Some(Level::Trace) => vec![ LevelFilter::WARN.into(), "om=trace".parse().unwrap(), "nix_rs=trace".parse().unwrap(), "omnix-health=trace".parse().unwrap(), "omnix-ci=trace".parse().unwrap(), "omnix-init=trace".parse().unwrap(), "omnix-hack=trace".parse().unwrap(), ], // -vvv: log DEBUG level of app and libraries // 3 => vec![LevelFilter::DEBUG.into()], // -vvvv: log TRACE level of app and libraries // _ => vec![LevelFilter::TRACE.into()], } } /// A [tracing_subscriber] event formatter that suppresses everything but the /// log message. struct BareFormatter; impl FormatEvent for BareFormatter where S: Subscriber + for<'a> LookupSpan<'a>, N: for<'a> FormatFields<'a> + 'static, { fn format_event( &self, ctx: &FmtContext<'_, S, N>, mut writer: format::Writer<'_>, event: &Event<'_>, ) -> fmt::Result { /* let metadata = event.metadata(); if metadata.level() != &tracing::Level::INFO { write!(&mut writer, "{} {}: ", metadata.level(), metadata.target())?; } */ ctx.field_format().format_fields(writer.by_ref(), event)?; writeln!(writer) } } ================================================ FILE: crates/omnix-common/src/markdown.rs ================================================ //! Markdown rendering using `mdcat` use anyhow::Context; use lazy_static::lazy_static; use pulldown_cmark::{Options, Parser}; use pulldown_cmark_mdcat::{ resources::FileResourceHandler, Environment, Settings, TerminalProgram, TerminalSize, Theme, }; use std::{io::Write, path::Path}; use syntect::parsing::SyntaxSet; lazy_static! { static ref SYNTAX_SET: SyntaxSet = SyntaxSet::load_defaults_newlines(); /// Global settings for rendering markdown pub static ref SETTINGS: Settings<'static> = Settings { terminal_capabilities: TerminalProgram::detect().capabilities(), terminal_size: TerminalSize::from_terminal().unwrap_or_default(), theme: Theme::default(), syntax_set: &SYNTAX_SET, }; } /// Print Markdown using `mdcat` to STDERR pub async fn print_markdown(base_dir: &Path, s: &str) -> anyhow::Result<()> { print_markdown_to(base_dir, &mut std::io::stderr(), s) .await .with_context(|| "Cannot print markdown") } /// Render Markdown into a string to be printed to terminal pub async fn render_markdown(base_dir: &Path, s: &str) -> anyhow::Result { let mut w = Vec::new(); print_markdown_to(base_dir, &mut w, s) .await .with_context(|| "Cannot render markdown")?; let s = String::from_utf8(w)?; // A trim is needed to remove unnecessary newlines at end (which can impact for single-line renders) Ok(s.trim().to_string()) } async fn print_markdown_to(base_dir: &Path, w: &mut W, s: &str) -> anyhow::Result<()> where W: Write, { let env = Environment::for_local_directory(&base_dir)?; let handler = FileResourceHandler::new(200000); let parser = Parser::new_ext( s, Options::ENABLE_TASKLISTS | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_GFM, ); pulldown_cmark_mdcat::push_tty(&SETTINGS, &env, &handler, w, parser)?; Ok(()) } ================================================ FILE: crates/omnix-develop/Cargo.toml ================================================ [package] authors = ["Sridhar Ratnakumar "] edition = "2021" # If you change the name here, you must also do it in flake.nix (and run `cargo generate-lockfile` afterwards) name = "omnix-develop" version = "1.3.2" repository = "https://github.com/juspay/omnix" description = "Implementation for the `om develop` command" license = "Apache-2.0" [lib] crate-type = ["cdylib", "rlib"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = { workspace = true } lazy_static = { workspace = true } nix_rs = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } syntect = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } omnix-common = { workspace = true } omnix-health = { workspace = true } ================================================ FILE: crates/omnix-develop/crate.nix ================================================ { autoWire = [ ]; crane.args = { }; } ================================================ FILE: crates/omnix-develop/src/config.rs ================================================ use serde::Deserialize; use omnix_common::config::OmConfig; use crate::readme::Readme; #[derive(Debug, Deserialize, Clone, Default)] pub struct DevelopConfig { pub readme: Readme, } #[derive(Debug, Deserialize, Clone)] pub struct CacheConfig { /// Cache substituter URL pub url: String, } impl DevelopConfig { pub fn from_om_config(om_config: &OmConfig) -> anyhow::Result { let (config, _rest) = om_config.get_sub_config_under("develop")?; Ok(config) } } ================================================ FILE: crates/omnix-develop/src/core.rs ================================================ use anyhow::Context; use std::{env::current_dir, path::PathBuf}; use nix_rs::{flake::url::FlakeUrl, info::NixInfo}; use omnix_common::{config::OmConfig, markdown::print_markdown}; use omnix_health::{check::caches::CachixCache, traits::Checkable, NixHealth}; use crate::config::DevelopConfig; /// A project that an be developed on locally. pub struct Project { /// The directory of the project. pub dir: Option, /// [FlakeUrl] corresponding to the project. pub flake: FlakeUrl, /// The `om` configuration pub om_config: OmConfig, } impl Project { pub async fn new(flake: FlakeUrl, om_config: OmConfig) -> anyhow::Result { let dir = match flake.as_local_path() { Some(path) => Some(path.canonicalize()?), None => None, }; Ok(Self { dir, flake, om_config, }) } } pub async fn develop_on(prj: &Project) -> anyhow::Result<()> { develop_on_pre_shell(prj).await?; develop_on_post_shell(prj).await?; tracing::warn!(""); tracing::warn!("🚧 !!!!"); tracing::warn!("🚧 Not invoking Nix devShell (not supported yet). Please use `direnv`!"); tracing::warn!("🚧 !!!!"); Ok(()) } pub async fn develop_on_pre_shell(prj: &Project) -> anyhow::Result<()> { // Run relevant `om health` checks let health = NixHealth::from_om_config(&prj.om_config)?; let nix_info = NixInfo::get() .await .as_ref() .with_context(|| "Unable to gather nix info")?; let mut relevant_checks: Vec<&'_ dyn Checkable> = vec![&health.nix_version, &health.rosetta, &health.max_jobs]; if !health.caches.required.is_empty() { relevant_checks.push(&health.trusted_users); }; // Run cache related checks, and try to resolve it automatically using `cachix use` as appropriate if !health.caches.required.is_empty() { let missing = health.caches.get_missing_caches(nix_info); let (missing_cachix, missing_other) = parse_many(&missing, CachixCache::from_url); for cachix_cache in &missing_cachix { tracing::info!("🐦 Running `cachix use` for {}", cachix_cache.0); cachix_cache.cachix_use().await?; } if !missing_other.is_empty() { // We cannot add these caches automatically, so defer to `om health` relevant_checks.push(&health.caches); }; // TODO: Re-calculate NixInfo since our nix.conf has changed (due to `cachix use`) // To better implement this, we need a mutable database of NixInfo, NixConfig, etc. OnceCell is not sufficient }; for check_kind in relevant_checks.into_iter() { for (_, check) in check_kind.check(nix_info, Some(&prj.flake)) { if !check.result.green() { check.tracing_log().await?; if !check.result.green() && check.required { anyhow::bail!("ERROR: Your Nix environment is not properly setup. See suggestions above, or run `om health` for details."); }; }; } } tracing::info!("✅ Nix environment is healthy."); Ok(()) } pub async fn develop_on_post_shell(prj: &Project) -> anyhow::Result<()> { eprintln!(); let pwd = current_dir()?; let dir = prj.dir.as_ref().unwrap_or(&pwd); let cfg = DevelopConfig::from_om_config(&prj.om_config)?; print_markdown(dir, cfg.readme.get_markdown()).await?; Ok(()) } /// Parse all items using the given parse function fn parse_many<'a, T, Q, F>(vec: &'a [T], f: F) -> (Vec, Vec<&'a T>) where F: Fn(&T) -> Option, { let mut successes: Vec = Vec::new(); let mut failures: Vec<&'a T> = Vec::new(); for item in vec { match f(item) { Some(transformed) => successes.push(transformed), None => failures.push(item), } } (successes, failures) } ================================================ FILE: crates/omnix-develop/src/lib.rs ================================================ pub mod config; pub mod core; pub mod readme; ================================================ FILE: crates/omnix-develop/src/readme.rs ================================================ use serde::Deserialize; const DEFAULT: &str = r#"🍾 Welcome to the project *(Want to show custom instructions here? Add them to the `om.develop.default.readme` field in your `flake.nix` or `om.yaml` file)* "#; /// The README to display at the end. #[derive(Debug, Deserialize, Clone)] pub struct Readme(pub String); impl Default for Readme { fn default() -> Self { Self(DEFAULT.to_string()) } } impl Readme { /// Get the Markdown string pub fn get_markdown(&self) -> &str { &self.0 } } ================================================ FILE: crates/omnix-gui/Cargo.toml ================================================ [package] edition = "2021" license = "AGPL-3.0-only" repository = "https://github.com/juspay/omnix" # If you change the name here, you must also do it in flake.nix (and run `cargo generate-lockfile` afterwards) name = "omnix-gui" version = "0.1.0" homepage = "https://github.com/juspay/omnix" build = "build.rs" description = "Graphical interface for Omnix" [dependencies] anyhow = { workspace = true } bytesize = { workspace = true } cfg-if = { workspace = true } clap = { workspace = true } clap-verbosity-flag = { workspace = true } console_error_panic_hook = { workspace = true } console_log = { workspace = true } dioxus = { version = "0.5.0", features = ["desktop"] } dioxus-desktop = "0.5.0" dioxus-router = "0.5.0" dioxus-sdk = { version = "0.5.0", features = ["storage"] } dioxus-signals = "0.5.0" direnv = { workspace = true } fermi = { workspace = true } http = { workspace = true } human-panic = { workspace = true } omnix-health = { workspace = true } nix_rs = { workspace = true } omnix-common = { workspace = true } regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } serde_with = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } [package.metadata.docs.rs] all-features = true ================================================ FILE: crates/omnix-gui/Dioxus.toml ================================================ [application] name = "omnix-gui" default_platform = "desktop" out_dir = "dist" asset_dir = "assets" [web.app] title = "Omnix | 🌍" [web.watcher] # when watcher trigger, regenerate the `index.html` reload_html = true # which files or dirs will be watcher monitoring watch_path = ["src", "assets"] [web.resource] # CSS style file style = ["tailwind.css"] # Javascript code file script = [] [web.resource.dev] # CSS style file style = [] # Javascript code file script = [] # FIXME: Need to `cd assets` before running `dx bundle` due to https://github.com/DioxusLabs/dioxus/issues/1283 [bundle] name = "Omnix" identifier = "in.juspay.omnix" icon = ["images/128x128.png"] # ["32x32.png", "128x128.png", "128x128@2x.png"] version = "1.0.0" # TODO: Must add these files resources = ["**/tailwind.css", "images/**/*.png"] # , "secrets/public_key.txt"] copyright = "Copyright (c) Juspay 2023. All rights reserved." category = "Developer Tool" short_description = "Graphical user interface for Omnix" long_description = """ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. """ osx_frameworks = [] ================================================ FILE: crates/omnix-gui/assets/tailwind.css ================================================ /*! tailwindcss v3.4.10 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Proxima Nova,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]{display:none}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.static{position:static}.m-2{margin:.5rem}.my-1{margin-bottom:.25rem;margin-top:.25rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-4{margin-bottom:1rem;margin-top:1rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.mr-2{margin-right:.5rem}.mt-4{margin-top:1rem}.flex{display:flex}.table{display:table}.contents{display:contents}.hidden{display:none}.h-16{height:4rem}.h-4{height:1rem}.h-6{height:1.5rem}.h-screen{height:100vh}.w-16{width:4rem}.w-6{width:1.5rem}.w-full{width:100%}.max-w-prose{max-width:65ch}.flex-1{flex:1 1 0%}.scale-x-\[-1\]{--tw-scale-x:-1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-auto{cursor:auto}.cursor-pointer{cursor:pointer}.list-disc{list-style-type:disc}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-stretch{align-items:stretch}.justify-start{justify-content:flex-start}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(2rem*var(--tw-space-y-reverse));margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.border-2{border-width:2px}.border-4{border-width:4px}.border-b-2{border-bottom-width:2px}.border-l-2{border-left-width:2px}.border-t-2{border-top-width:2px}.border-base-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity))}.border-base-400{--tw-border-opacity:1;border-color:rgb(156 163 175/var(--tw-border-opacity))}.border-black{--tw-border-opacity:1;border-color:rgb(0 0 0/var(--tw-border-opacity))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity))}.border-purple-500{--tw-border-opacity:1;border-color:rgb(168 85 247/var(--tw-border-opacity))}.bg-base-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity))}.bg-blue-400{--tw-bg-opacity:1;background-color:rgb(96 165 250/var(--tw-bg-opacity))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity))}.bg-primary-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity))}.bg-primary-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity))}.bg-red-400{--tw-bg-opacity:1;background-color:rgb(248 113 113/var(--tw-bg-opacity))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-4{padding-left:1rem;padding-right:1rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.pl-4{padding-left:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-light{font-weight:300}.font-semibold{font-weight:600}.italic{font-style:italic}.text-base-100{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity))}.text-base-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.text-base-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity))}.text-primary-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity))}.text-primary-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity))}.text-primary-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.underline{text-decoration-line:underline}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:scale-125:hover{--tw-scale-x:1.25;--tw-scale-y:1.25;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:border-gray-400:hover{--tw-border-opacity:1;border-color:rgb(156 163 175/var(--tw-border-opacity))}.hover\:bg-primary-100:hover{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity))}.hover\:text-primary-500:hover{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity))}.hover\:underline:hover{text-decoration-line:underline}.hover\:no-underline:hover{text-decoration-line:none}.active\:scale-100:active{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))} ================================================ FILE: crates/omnix-gui/build.rs ================================================ use std::process::Command; const INPUT_CSS_PATH: &str = "./css/input.css"; const PUBLIC_DIR: &str = "./assets/"; fn main() { run_tailwind(); } fn run_tailwind() { let mut command = Command::new("tailwindcss"); command .args([ "-i", INPUT_CSS_PATH, "-o", &(PUBLIC_DIR.to_string() + "tailwind.css"), "--minify", ]) .spawn() .expect("couldn't run tailwind. Please run it manually"); } ================================================ FILE: crates/omnix-gui/crate.nix ================================================ { flake , pkgs , lib , rust-project , ... }: let inherit (flake) inputs; in { autoWire = [ ]; crane = { args = { buildInputs = lib.optionals pkgs.stdenv.isLinux (with pkgs; [ webkitgtk_4_1 xdotool pkg-config ]); nativeBuildInputs = with pkgs;[ pkg-config makeWrapper tailwindcss dioxus-cli # pkgs.nix # cargo tests need nix ]; meta.description = "Graphical user interface for Omnix"; }; }; } ================================================ FILE: crates/omnix-gui/css/input.css ================================================ @tailwind base; @tailwind components; @tailwind utilities; ================================================ FILE: crates/omnix-gui/src/app/flake.rs ================================================ //! UI for /flake segment of the app use std::{collections::BTreeMap, path::PathBuf}; use dioxus::prelude::*; use dioxus_router::components::Link; use nix_rs::flake::{ outputs::{FlakeOutputs, Leaf, Type, Val}, schema::FlakeSchema, url::FlakeUrl, Flake, }; use crate::{ app::widget::FolderDialogButton, app::{state::AppState, widget::Loader, Route}, }; #[component] pub fn Flake() -> Element { let state = AppState::use_state(); let flake = state.flake.read(); rsx! { h1 { class: "text-5xl font-bold", "Flake browser" } { FlakeInput() }, if flake.is_loading_or_refreshing() { Loader {} } { flake.render_with(|v| rsx! { FlakeView { flake: v.clone() } }) } } } #[component] pub fn FlakeInput() -> Element { let state = AppState::use_state(); let busy = state.flake.read().is_loading_or_refreshing(); rsx! { div { class: "p-2 my-1 flex w-full", input { class: "flex-1 w-full p-1 mb-4 font-mono", id: "nix-flake-input", "type": "text", value: state.get_flake_url_string(), disabled: busy, onchange: move |ev| { let url: FlakeUrl = str::parse(&ev.value()).unwrap(); Route::go_to_flake(url); } } div { class: "ml-2 flex flex-col", { FolderDialogButton( move |flake_path: PathBuf| { let url: FlakeUrl = flake_path.into(); Route::go_to_flake(url); } ) } } } } } #[component] pub fn FlakeRaw() -> Element { let state = AppState::use_state(); // use_future(cx, (), |_| async move { state.update_flake().await }); let flake = state.flake.read(); rsx! { div { Link { to: Route::Flake {}, "⬅ Back" } div { class: "px-4 py-2 font-mono text-xs text-left text-gray-500 border-2 border-black", { flake.render_with(|v| rsx! { FlakeOutputsRawView { outs: v.output.clone() } } ) } } } } } #[component] pub fn FlakeView(flake: Flake) -> Element { rsx! { div { class: "flex flex-col my-4", h3 { class: "text-lg font-bold", { flake.url.to_string() } } div { class: "text-sm italic text-gray-600", Link { to: Route::FlakeRaw {}, "View raw output" } } FlakeSchemaView { schema: flake.schema } } } } #[component] pub fn SectionHeading(title: &'static str, extra: Option) -> Element { rsx! { h3 { class: "p-2 mt-4 mb-2 font-bold bg-gray-300 border-b-2 border-l-2 border-black text-l", "{title}" match extra { Some(v) => rsx! { span { class: "text-xs text-gray-500 ml-1", "(", "{v}", ")" } }, None => rsx! { "" } } } } } #[component] pub fn FlakeSchemaView(schema: FlakeSchema) -> Element { let system = schema.system.clone(); rsx! { div { h2 { class: "my-2", div { class: "text-xl font-bold text-primary-600", "{system.human_readable()}" } span { class: "font-mono text-xs text-gray-500", "(" "{system }" ")" } } div { class: "text-left", BtreeMapView { title: "Packages", tree: schema.packages } BtreeMapView { title: "Legacy Packages", tree: schema.legacy_packages } BtreeMapView { title: "Dev Shells", tree: schema.devshells } BtreeMapView { title: "Checks", tree: schema.checks } BtreeMapView { title: "Apps", tree: schema.apps } BtreeMapView { title: "NixOS configurations", tree: schema.nixos_configurations } BtreeMapView { title: "Darwin configurations", tree: schema.darwin_configurations } BtreeMapView { title: "NixOS modules", tree: schema.nixos_modules } SectionHeading { title: "Formatter" } match schema.formatter.as_ref() { Some(l) => { let v = l.as_val().cloned().unwrap_or_default(); let k = v.derivation_name.as_deref().unwrap_or("formatter"); rsx! { FlakeValView { k: k, v: v.clone() } } }, None => rsx! { "" } }, SectionHeading { title: "Other" } match &schema.other { Some(v) => rsx! { FlakeOutputsRawView { outs: FlakeOutputs::Attrset(v.clone()) } }, None => rsx! { "" } } } } } } #[component] pub fn BtreeMapView(title: &'static str, tree: BTreeMap) -> Element { rsx! { div { SectionHeading { title, extra: tree.len().to_string() } BtreeMapBodyView { tree } } } } #[component] pub fn BtreeMapBodyView(tree: BTreeMap) -> Element { rsx! { div { class: "flex flex-wrap justify-start", for (k , l) in tree.iter() { FlakeValView { k: k.clone(), v: l.as_val().cloned().unwrap_or_default() } } } } } #[component] pub fn FlakeValView(k: String, v: Val) -> Element { rsx! { div { title: "{v.type_}", class: "flex flex-col p-2 my-2 mr-2 space-y-2 bg-white border-4 border-gray-300 rounded hover:border-gray-400", div { class: "flex flex-row justify-start space-x-2 font-bold text-primary-500", div { { v.type_.to_icon() } } div { "{k}" } } match &v.derivation_name { Some(name_val) => rsx! { div { class: "font-mono text-xs text-gray-500", "{name_val}" } }, None => rsx! { "" } // No-op for None }, match &v.short_description { Some(desc_val) => rsx! { div { class: "font-light", "{desc_val}" } }, None => rsx! { "" } // No-op for None } } } } /// This component renders recursively. This view is used to see the raw flake /// output only; it is not useful for general UX. /// /// WARNING: This may cause performance problems if the tree is large. #[component] pub fn FlakeOutputsRawView(outs: FlakeOutputs) -> Element { #[component] fn ValView(val: Val) -> Element { rsx! { span { b { { val.derivation_name.clone() } } " (" TypeView { type_: val.type_ } ") " em { { val.short_description.clone() } } } } } #[component] pub fn TypeView(type_: Type) -> Element { rsx! { span { match type_ { Type::NixosModule => "nixosModule ❄️", Type::NixosConfiguration => "nixosConfiguration 🧩", Type::DarwinConfiguration => "darwinConfiguration 🍏", Type::Package => "package 📦", Type::DevShell => "devShell 🐚", Type::Check => "check 🧪", Type::App => "app 📱", Type::Template => "template 🏗️", Type::Unknown => "unknown ❓", } } } } match outs { FlakeOutputs::Leaf(l) => rsx! { ValView { val: l.as_val().cloned().unwrap_or_default() } }, FlakeOutputs::Attrset(v) => rsx! { ul { class: "list-disc", for (k , v) in v.iter() { li { class: "ml-4", span { class: "px-2 py-1 font-bold text-primary-500", "{k}" } FlakeOutputsRawView { outs: v.clone() } } } } }, } } ================================================ FILE: crates/omnix-gui/src/app/health.rs ================================================ //! Nix health check UI use dioxus::prelude::*; use omnix_health::traits::{Check, CheckResult}; use crate::{app::state::AppState, app::widget::Loader}; /// Nix health checks pub fn Health() -> Element { let state = AppState::use_state(); let health_checks = state.health_checks.read(); let title = "Nix Health"; rsx! { h1 { class: "text-5xl font-bold", title } if health_checks.is_loading_or_refreshing() { Loader {} } { health_checks.render_with(|checks| rsx! { div { class: "flex flex-col items-stretch justify-start space-y-8 text-left", for check in checks { ViewCheck { check: check.clone() } } } }) } } } #[component] fn ViewCheck(check: Check) -> Element { rsx! { div { class: "contents", details { open: check.result != CheckResult::Green, class: "my-2 bg-white border-2 rounded-lg cursor-pointer hover:bg-primary-100 border-base-300", summary { class: "p-4 text-xl font-bold", CheckResultSummaryView { green: check.result.green() } " " { check.title.clone() } } div { class: "p-4", div { class: "p-2 my-2 font-mono text-sm bg-black text-base-100", { check.info.clone() } } div { class: "flex flex-col justify-start space-y-4", match check.result.clone() { CheckResult::Green => rsx! { "" }, CheckResult::Red { msg, suggestion } => rsx! { h3 { class: "my-2 font-bold text-l" } div { class: "p-2 bg-red-400 rounded bg-border", { msg } } h3 { class: "my-2 font-bold text-l" } div { class: "p-2 bg-blue-400 rounded bg-border", { suggestion } } } } } } } } } } #[component] pub fn CheckResultSummaryView(green: bool) -> Element { if green { rsx! { span { class: "text-green-500", "✓" } } } else { rsx! { span { class: "text-red-500", "✗" } } } } ================================================ FILE: crates/omnix-gui/src/app/info.rs ================================================ //! Nix info UI use std::fmt::Display; use dioxus::prelude::*; use nix_rs::{config::NixConfig, env::NixEnv, info::NixInfo, version::NixVersion}; use crate::{app::state::AppState, app::widget::Loader}; /// Nix information #[component] pub fn Info() -> Element { let title = "Nix Info"; let state = AppState::use_state(); let nix_info = state.nix_info.read(); rsx! { h1 { class: "text-5xl font-bold", title } if nix_info.is_loading_or_refreshing() { Loader {} } div { class: "flex items-center justify-center", { nix_info.render_with(|v| rsx! { NixInfoView { info: v.clone() } }) } } } } #[component] fn NixInfoView(info: NixInfo) -> Element { rsx! { div { class: "flex flex-col max-w-prose p-4 space-y-8 bg-white border-2 rounded border-base-400", div { b { "Nix Version" } div { class: "p-1 my-1 rounded bg-primary-50", NixVersionView { version: info.nix_version } } } div { b { "Nix Config" } NixConfigView { config: info.nix_config.clone() } } div { b { "Nix Env" } NixEnvView { env: info.nix_env.clone() } } } } } #[component] fn NixVersionView(version: NixVersion) -> Element { rsx! { a { href: nix_rs::refs::RELEASE_HISTORY, class: "font-mono hover:underline", target: "_blank", "{version}" } } } #[component] fn NixConfigView(config: NixConfig) -> Element { rsx! { div { class: "py-1 my-1 rounded bg-primary-50", table { class: "text-right", tbody { TableRow { name: "Local System", title: config.system.description, "{config.system.value}" } TableRow { name: "Max Jobs", title: config.max_jobs.description, "{config.max_jobs.value}" } TableRow { name: "Cores per build", title: config.cores.description, "{config.cores.value}" } TableRow { name: "Nix Caches", title: config.substituters.description, ConfigValList { items: config.substituters.value } } } } } } } #[component] fn ConfigValList(items: Vec) -> Element { rsx! { div { class: "flex flex-col space-y-4", for item in items { li { class: "list-disc", "{item}" } } } } } #[component] fn NixEnvView(env: NixEnv) -> Element { rsx! { div { class: "py-1 my-1 rounded bg-primary-50", table { class: "text-right", tbody { TableRow { name: "Current User", title: "Logged-in user", code { "{env.current_user}" } } TableRow { name: "OS", title: "Operating System", code { "{env.os}" } } TableRow { name: "Total disk space", title: "Total disk space on the current machine", code { "{env.total_disk_space}" } } TableRow { name: "Total RAM", title: "Total memory on the current machine", code { "{env.total_memory}" } } } } } } } #[component] fn TableRow(name: &'static str, title: String, children: Element) -> Element { rsx! { tr { title: "{title}", td { class: "px-4 py-2 font-semibold text-base-700", "{name}" } td { class: "px-4 py-2 text-left", code { { children } } } } } } ================================================ FILE: crates/omnix-gui/src/app/mod.rs ================================================ //! Frontend UI entry point // Workaround for https://github.com/rust-lang/rust-analyzer/issues/15344 #![allow(non_snake_case)] mod flake; mod health; mod info; mod state; mod widget; use dioxus::prelude::*; use dioxus_router::prelude::*; use nix_rs::flake::url::FlakeUrl; use crate::app::{ flake::{Flake, FlakeRaw}, health::Health, info::Info, state::AppState, widget::{Loader, RefreshButton}, }; #[derive(Routable, PartialEq, Debug, Clone)] #[rustfmt::skip] enum Route { #[layout(Wrapper)] #[route("/")] Dashboard {}, #[route("/flake")] Flake {}, #[route("/flake/raw")] FlakeRaw {}, #[route("/health")] Health {}, #[route("/info")] Info {}, } impl Route { fn go_to_flake(url: FlakeUrl) { AppState::use_state().set_flake_url(url); use_navigator().replace(Route::Flake {}); } fn go_to_dashboard() { AppState::use_state().reset_flake_data(); use_navigator().replace(Route::Dashboard {}); } } /// Main frontend application container pub fn App() -> Element { AppState::provide_state(); rsx! { body { class: "bg-base-100 overflow-hidden", Router:: {} } } } fn Wrapper() -> Element { rsx! { div { class: "flex flex-col text-center justify-between w-full h-screen", TopBar {} div { class: "m-2 py-2 overflow-auto", Outlet:: {} } Footer {} } } } #[component] fn TopBar() -> Element { let is_dashboard = use_route::() == Route::Dashboard {}; let state = AppState::use_state(); let health_checks = state.health_checks.read(); let nix_info = state.nix_info.read(); rsx! { div { class: "flex justify-between items-center w-full p-2 bg-primary-100 shadow", div { class: "flex space-x-2", a { onclick: move |_| { if !is_dashboard { Route::go_to_dashboard(); } }, class: if is_dashboard { "cursor-auto" } else { "cursor-pointer" }, "🏠" } } div { class: "flex space-x-2", ViewRefreshButton {} Link { to: Route::Health {}, span { title: "Nix Health Status", match (*health_checks).current_value() { Some(Ok(checks)) => rsx! { if checks.iter().all(|check| check.result.green()) { "✅" } else { "❌" } }, Some(Err(err)) => rsx! { "{err}" }, None => rsx! { Loader {} }, } } } Link { to: Route::Info {}, span { "Nix " match (*nix_info).current_value() { Some(Ok(info)) => rsx! { "{info.nix_version} on {info.nix_env.os}" }, Some(Err(err)) => rsx! { "{err}" }, None => rsx! { Loader {} }, } } } } } } } /// Intended to refresh the data behind the current route. #[component] fn ViewRefreshButton() -> Element { let state = AppState::use_state(); let (busy, mut refresh_signal) = match use_route() { Route::Flake {} => Some(( state.flake.read().is_loading_or_refreshing(), state.flake_refresh, )), Route::Health {} => Some(( state.health_checks.read().is_loading_or_refreshing(), state.health_checks_refresh, )), Route::Info {} => Some(( state.nix_info.read().is_loading_or_refreshing(), state.nix_info_refresh, )), _ => None, }?; rsx! { { RefreshButton ( busy, move |_| { refresh_signal.write().request_refresh(); } ) } } } #[component] fn Footer() -> Element { rsx! { footer { class: "flex flex-row justify-center w-full bg-primary-100 p-2", a { href: "https://github.com/juspay/omnix", img { src: "images/128x128.png", class: "h-4" } } } } } // Home page fn Dashboard() -> Element { tracing::debug!("Rendering Dashboard page"); let state = AppState::use_state(); rsx! { div { class: "pl-4", h2 { class: "text-2xl", "Enter a flake URL:" } { flake::FlakeInput () }, h2 { class: "text-2xl", "Or, try one of these:" } div { class: "flex flex-col", for flake_url in state.flake_cache.read().recent_flakes() { a { onclick: move |_| { Route::go_to_flake(flake_url.clone()); }, class: "cursor-pointer text-primary-600 underline hover:no-underline", "{flake_url.clone()}" } } } } } } ================================================ FILE: crates/omnix-gui/src/app/state/datum.rs ================================================ use std::{fmt::Display, future::Future}; use dioxus::prelude::*; use dioxus_signals::{CopyValue, Signal}; use tokio::task::AbortHandle; /// Represent loading/refreshing state of UI data #[derive(Debug, Clone, Copy, PartialEq)] pub struct Datum { /// The current value of the datum value: Option, /// If the datum is currently being loaded or refresh, this contains the /// [AbortHandle] to abort that loading/refreshing process. task: CopyValue>, } impl Default for Datum { fn default() -> Self { Self { value: None, task: CopyValue::default(), } } } impl Datum { pub fn is_loading_or_refreshing(&self) -> bool { self.task.read().is_some() } /// Get the inner value if available pub fn current_value(&self) -> Option<&T> { self.value.as_ref() } pub async fn set_value(signal: &mut Signal>, value: T) where T: Send + Clone + 'static, { Datum::refresh_with(signal, async { value }).await; } /// Refresh the datum [Signal] using the given function /// /// If a previous refresh is still running, it will be cancelled. pub async fn refresh_with(signal: &mut Signal>, f: F) -> Option where F: Future + Send + 'static, T: Send + Clone + 'static, { // Cancel existing fetcher if any. signal.with_mut(move |x| { if let Some(abort_handle) = x.task.take() { tracing::warn!( "🍒 Cancelling previous refresh task for {}", std::any::type_name::() ); abort_handle.abort(); } }); // NOTE: We must spawn a tasks (using tokio::spawn), otherwise this // will run in main desktop thread, and will hang at some point. let join_handle = tokio::spawn(f); // Store the [AbortHandle] for cancelling latter. let abort_handle = join_handle.abort_handle(); signal.with_mut(move |x| { *x.task.write() = Some(abort_handle); }); // Wait for result and update the signal state. match join_handle.await { Ok(val) => { signal.with_mut(|x| { tracing::debug!("🍒 Setting {} datum value", std::any::type_name::()); x.value = Some(val.clone()); *x.task.write() = None; }); Some(val) } Err(err) => { if !err.is_cancelled() { tracing::error!("🍒 Datum refresh failed: {err}"); signal.with_mut(move |x| { *x.task.write() = None; }); } // x.task will be set to None by the caller who cancelled us, so // we need not do anything here. None } } } } impl Datum> { /// Render the result datum with the given component /// /// The error message will be rendered appropriately. If the datum is /// unavailable, nothing will be rendered (loading state is rendered /// differently) pub fn render_with(&self, component: F) -> Element where F: FnOnce(&T) -> Element, { match self.current_value()? { Ok(value) => component(value), Err(err) => rsx! { div { class: "p-4 my-1 text-left text-sm font-mono text-white bg-red-500 rounded", "Error: {err}" } }, } } } ================================================ FILE: crates/omnix-gui/src/app/state/db.rs ================================================ //! A database of [Flake] intended to be cached in dioxus [Signal] and persisted to disk. //! //! This is purposefully dumb right now, but we might revisit this in future based on actual performance. use serde::{Deserialize, Serialize}; use std::{collections::HashMap, time::SystemTime}; use dioxus_sdk::storage::new_storage; use dioxus_sdk::storage::LocalStorage; use dioxus_signals::Signal; use crate::app::state::FlakeUrl; use nix_rs::flake::Flake; /// A database of [Flake] intended to be cached in dioxus [Signal] and persisted to disk. /// /// Contains the "last fetched" time and the [Flake] itself. #[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct FlakeCache(HashMap>); impl FlakeCache { /// Create a new [Signal] for [FlakeCache] from [LocalStorage]. pub fn new_signal() -> Signal { new_storage::("flake_cache".to_string(), || { tracing::warn!("📦 No flake cache found"); let init = FlakeUrl::suggestions() .into_iter() .map(|url| (url, None)) .collect(); FlakeCache(init) }) } /// Look up a [Flake] by [FlakeUrl] in the cache. pub fn get(&self, k: &FlakeUrl) -> Option { let (t, flake) = self.0.get(k).and_then(|v| v.as_ref().cloned())?; tracing::info!("Cache hit for {} (updated: {:?})", k, t); Some(flake) } /// Update the cache with a new [Flake]. pub fn update(&mut self, k: FlakeUrl, flake: Flake) { tracing::info!("Caching flake [{}]", &k); self.0.insert(k, Some((SystemTime::now(), flake))); } /// Recently updated flakes, along with any unavailable flakes in cache. pub fn recent_flakes(&self) -> Vec { let mut pairs: Vec<_> = self .0 .iter() .map(|(k, v)| (k, v.as_ref().map(|(t, _)| t))) .collect(); // Sort by the timestamp in descending order. pairs.sort_unstable_by(|a, b| b.1.cmp(&a.1)); pairs.into_iter().map(|(k, _)| k.clone()).collect() } } ================================================ FILE: crates/omnix-gui/src/app/state/error.rs ================================================ use std::fmt::Display; /// Catch all error to use in UI components #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct SystemError { pub message: String, } impl Display for SystemError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.message) } } impl From for SystemError { fn from(message: String) -> Self { Self { message } } } ================================================ FILE: crates/omnix-gui/src/app/state/mod.rs ================================================ //! Application state mod datum; mod db; mod error; mod refresh; use dioxus::prelude::*; use dioxus_signals::{Readable, Signal, Writable}; use nix_rs::{ config::NixConfig, flake::{url::FlakeUrl, Flake}, info::NixInfo, version::NixVersion, }; use omnix_health::NixHealth; use self::{datum::Datum, error::SystemError, refresh::Refresh}; /// Our dioxus application state is a struct of [Signal]s that store app state. /// /// They use [Datum] which is a glorified [Option] to distinguish between initial /// loading and subsequent refreshing. #[derive(Default, Clone, Copy, Debug, PartialEq)] pub struct AppState { /// [NixInfo] as detected on the user's system pub nix_info: Signal>>, pub nix_info_refresh: Signal, /// User's Nix health [omnix_health::traits::Check]s pub health_checks: Signal, SystemError>>>, pub health_checks_refresh: Signal, /// User selected [FlakeUrl] pub flake_url: Signal>, /// Trigger to refresh [AppState::flake] pub flake_refresh: Signal, /// [Flake] for [AppState::flake_url] pub flake: Signal>>, /// Cached [Flake] values indexed by [FlakeUrl] /// /// Most recently updated flakes appear first. pub flake_cache: Signal, } impl AppState { fn new() -> Self { tracing::info!("🔨 Creating new AppState"); // TODO: Should we use new_synced_storage, instead? To allow multiple app windows? let flake_cache = db::FlakeCache::new_signal(); AppState { flake_cache, ..AppState::default() } } /// Get the [AppState] from context pub fn use_state() -> Self { use_context::() } pub fn provide_state() { tracing::debug!("🏗️ Providing AppState"); let mut state = use_context_provider(Self::new); // FIXME: Can we avoid calling build_network multiple times? state.build_network(); } /// Return the [String] representation of the current [AppState::flake_url] value. If there is none, return empty string. pub fn get_flake_url_string(&self) -> String { self.flake_url .read() .clone() .map_or("".to_string(), |url| url.to_string()) } pub fn set_flake_url(&mut self, url: FlakeUrl) { tracing::info!("setting flake url to {}", &url); self.flake_url.set(Some(url)); } /// Empty flake related data (`flake_url` and `flake`) pub fn reset_flake_data(&mut self) { tracing::info!("empty flake data"); self.flake.set(Datum::default()); self.flake_url.set(None); } } impl AppState { /// Build the Signal network /// /// If a signal's value is dependent on another signal's value, you must /// define that relationship here. fn build_network(&mut self) { tracing::debug!("🕸️ Building AppState network"); // Build `state.flake` signal dependent signals change { // ... when [AppState::flake_url] changes. let flake_url = self.flake_url; let flake_cache = self.flake_cache; let mut flake_refresh = self.flake_refresh; let mut flake = self.flake; let _ = use_resource(move || async move { if let Some(flake_url) = flake_url.read().clone() { let maybe_flake = flake_cache.read().get(&flake_url); if let Some(cached_flake) = maybe_flake { Datum::set_value(&mut flake, Ok(cached_flake)).await; } else { flake_refresh.write().request_refresh(); } } }); // ... when refresh button is clicked. let flake_refresh = self.flake_refresh; let flake_url = self.flake_url; let mut flake = self.flake; let mut flake_cache = self.flake_cache; let _ = use_resource(move || async move { let nixcmd = nix_rs::command::NixCmd::get().await; let flake_url = flake_url.read().clone(); let refresh = *flake_refresh.read(); if let Some(flake_url) = flake_url { let flake_url_2 = flake_url.clone(); tracing::info!("Updating flake [{}] refresh={} ...", &flake_url, refresh); let res = Datum::refresh_with(&mut flake, async move { let nix_version = NixVersion::from_nix(nixcmd) .await .map_err(|e| Into::::into(e.to_string()))?; let nix_config = NixConfig::from_nix(nixcmd, &nix_version) .await .map_err(|e| Into::::into(e.to_string()))?; Flake::from_nix(nixcmd, &nix_config, flake_url_2) .await .map_err(|e| Into::::into(e.to_string())) }) .await; if let Some(Ok(flake)) = res { flake_cache.with_mut(|cache| { cache.update(flake_url, flake); }); } } }); } // Build `state.health_checks` { let nix_info = self.nix_info; let health_checks_refresh = self.health_checks_refresh; let mut health_checks = self.health_checks; let _ = use_resource(move || async move { let nix_info = nix_info.read().clone(); let refresh = *health_checks_refresh.read(); if let Some(nix_info) = nix_info.current_value().map(|x| { x.as_ref() .map_err(|e| Into::::into(e.to_string())) .cloned() }) { tracing::info!("Updating nix health [{}] ...", refresh); Datum::refresh_with(&mut health_checks, async move { let health_checks = NixHealth::default().run_checks(&nix_info?, None); Ok(health_checks) }) .await; } }); } // Build `state.nix_info` { let mut nix_info = self.nix_info; let nix_info_refresh = self.nix_info_refresh; let _ = use_resource(move || async move { let refresh = *nix_info_refresh.read(); tracing::info!("Updating nix info [{}] ...", refresh); Datum::refresh_with(&mut nix_info, async { let ver = NixVersion::get() .await .as_ref() .map_err(|e| Into::::into(e.to_string()))?; let cfg = NixConfig::get() .await .as_ref() .map_err(|e| Into::::into(e.to_string()))?; NixInfo::new(*ver, cfg.clone()) .await .map_err(|e| SystemError { message: format!("Error getting nix info: {:?}", e), }) }) .await; }); } } } ================================================ FILE: crates/omnix-gui/src/app/state/refresh.rs ================================================ use std::fmt::Display; /// Represents an user request to update some thing (a dioxus Signal) #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct Refresh { idx: usize, } impl Display for Refresh { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.idx) } } impl Refresh { pub fn request_refresh(&mut self) { tracing::info!("🔄 Requesting refresh of a signal"); self.idx += 1; } } ================================================ FILE: crates/omnix-gui/src/app/widget.rs ================================================ //! Various widgets use std::path::PathBuf; use dioxus::prelude::*; /// A refresh button with a busy indicator pub fn RefreshButton)>(busy: bool, mut handler: F) -> Element { rsx! { button { disabled: busy, onclick: move |evt| { if !busy { handler(evt) } }, title: "Refresh current data being viewed", LoaderIcon { loading: busy } } } } /// A button that opens a file explorer dialog. /// /// Note: You can only select a single folder. /// /// NOTE(for future): When migrating to Dioxus using Tauri 2.0, switch to using /// // #[component] pub fn FolderDialogButton(mut handler: F) -> Element { // FIXME: The id should be unique if this widget is used multiple times on // the same page. let id = "folder-dialog-input"; rsx! { input { r#type: "file", multiple: false, directory: true, accept: "", oninput: move |evt: Event| { if let Some(path) = get_selected_path(evt) { handler(path) } }, id, class: "hidden" } label { class: "py-1 px-1 cursor-pointer hover:scale-125 active:scale-100", r#for: id, title: "Choose a local folder", "📁" } } } /// Get the user selected path from a file dialog event /// /// If the user has not selected any (eg: cancels the dialog), this returns /// None. Otherwise, it returns the first entry in the selected list. fn get_selected_path(evt: Event) -> Option { match evt.files().as_ref() { None => { tracing::error!("unable to get files from event"); None } Some(file_engine) => { let path = file_engine.files().first().cloned()?; Some(PathBuf::from(path)) } } } #[component] pub fn Loader() -> Element { rsx! { div { class: "flex justify-center items-center", div { class: "animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-purple-500" } } } } #[component] pub fn LoaderIcon(loading: bool) -> Element { let cls = if loading { "animate-spin text-base-800" } else { "text-primary-700 hover:text-primary-500" }; rsx! { div { class: cls, svg { class: "h-6 w-6 scale-x-[-1]", xmlns: "http://www.w3.org/2000/svg", view_box: "0 0 24 24", fill: "none", stroke: "currentColor", path { d: "M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15", stroke_linecap: "round", stroke_linejoin: "round", stroke_width: "2" } } } } } /// A div that can get scrollbar for long content /// /// Since our body container is `overflow-hidden`, we need to wrap content that /// can get long in this component. #[component] #[allow(dead_code)] // https://github.com/juspay/omnix/issues/132 pub fn Scrollable(children: Element) -> Element { rsx! { div { class: "overflow-auto", { children } } } } ================================================ FILE: crates/omnix-gui/src/cli.rs ================================================ //! Command-line interface use clap::Parser; use clap_verbosity_flag::{InfoLevel, Verbosity}; #[derive(Parser, Debug)] pub struct Args { #[command(flatten)] pub verbosity: Verbosity, } ================================================ FILE: crates/omnix-gui/src/main.rs ================================================ #![feature(let_chains)] use dioxus::prelude::*; use dioxus_desktop::{LogicalSize, WindowBuilder}; mod app; mod cli; fn main() { use clap::Parser; let args = crate::cli::Args::parse(); omnix_common::logging::setup_logging(&args.verbosity, false); // Set data directory for persisting [Signal]s. On macOS, this is ~/Library/Application Support/omnix-gui. dioxus_sdk::storage::set_dir!(); let config = dioxus_desktop::Config::new() .with_custom_head(r#" "#.to_string()) .with_window( WindowBuilder::new() .with_title("Omnix") .with_inner_size(LogicalSize::new(800, 700)), ); LaunchBuilder::desktop().with_cfg(config).launch(app::App) } ================================================ FILE: crates/omnix-gui/tailwind.config.js ================================================ const colors = require('tailwindcss/colors') const defaultTheme = require('tailwindcss/defaultTheme') module.exports = { content: [ "./src/**/*.rs", "./dist/**/*.html" ], theme: { fontFamily: { sans: ['Proxima Nova', ...defaultTheme.fontFamily.sans], mono: [...defaultTheme.fontFamily.mono], serif: [...defaultTheme.fontFamily.serif] }, extend: { // Our application colour palette is defined here. colors: { 'base': colors.gray, 'primary': colors.blue, 'secondary': colors.yellow, 'error': colors.red } } }, plugins: [], } ================================================ FILE: crates/omnix-health/CHANGELOG.md ================================================ # Changelog ## Unreleased - Crate renamed to `omnix-health` - Remove unused `logging` module - Display Nix installer used - `nix-version.min-required` -> `nix-version.supported` ## 1.0.0 - Remove executable (use `omnix` instead) * **new**: * Expose `run_checks_with` * **minor**: * Remove redundant `NixEnv` detection - **fixes**: - Error out when the user passes `.#foo` as flake URL argument, with `foo` missing in the flake.nix. Previously, this fell back to `.#default` configuration. ## [0.4.0](https://github.com/juspay/nix-health/compare/0.3.0...0.4.0) (2024-07-10) ### Features * **lib:** Expose `print_returning_exit_code` (#25) ([b9c70a9](https://github.com/juspay/nix-health/commit/b9c70a9506823bdcc1d54c14b7c56d299b3a5c6a)), closes [#25](https://github.com/juspay/nix-health/issues/25) * build linux static executable ([78b95e8](https://github.com/juspay/nix-health/commit/78b95e8528282ef3f88b2ed29c0f5fc0cebbaa07)) * Add flake-module to run nix-health in devShell shellHook ([2f8d8dc](https://github.com/juspay/nix-health/commit/2f8d8dc30121923192c78a8f5152c5c89fdf1809)) ### Fixes * build failure on intel mac ([91e9bcf](https://github.com/juspay/nix-health/commit/91e9bcfd60d672074951d534d7b51f609dda1e94)) ## 0.3.0 (2024-07-10) ### Fixes * **nix-health:** use `direnv status --json` & create `direnv` crate (#123) ([f7762d7](https://github.com/juspay/nix-health/commit/f7762d7fec28f3091289fb03b3ad171cfb923f87)), closes [#123](https://github.com/juspay/nix-health/issues/123) ================================================ FILE: crates/omnix-health/Cargo.toml ================================================ [package] name = "omnix-health" version = "1.3.2" license = "Apache-2.0" repository = "https://github.com/juspay/omnix" description = "Check the health of your Nix setup" edition = "2021" [lib] crate-type = ["cdylib", "rlib"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] serde = { workspace = true } cfg-if = { workspace = true } clap = { workspace = true } omnix-common = { workspace = true } regex = { workspace = true } thiserror = { workspace = true } serde_json = { workspace = true } serde_with = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } url = { workspace = true } nix_rs = { workspace = true } direnv = { workspace = true } human-panic = { workspace = true } anyhow = { workspace = true } colored = { workspace = true } which = { workspace = true } bytesize = { workspace = true } semver = { workspace = true } serde_repr = { workspace = true } ================================================ FILE: crates/omnix-health/README.md ================================================ # `omnix-health` [![Crates.io](https://img.shields.io/crates/v/omnix-health.svg)](https://crates.io/crates/omnix-health) The Rust crate responsible for [`om health`](https://omnix.page/om/health.html). ================================================ FILE: crates/omnix-health/crate.nix ================================================ { autoWire = [ ]; crane = { args = { nativeBuildInputs = [ # nix # Tests need nix cli ]; }; }; } ================================================ FILE: crates/omnix-health/failing/.envrc ================================================ # Just a dummy .envrc for testing flake.nix checks invalid ================================================ FILE: crates/omnix-health/failing/flake.nix ================================================ # Just a flake.nix to configure omnix-health to fail all possible checks # # Used for testing purposes; run as: # cargo run -p omnix-cli health ./crates/omnix-health/failing { outputs = _: { om.health.default = { caches.required = [ "https://unknown.cachix.org" "https://example.com" ]; nix-version.min-required = "9.99.99"; system = { min_ram = "512GB"; min_disk_space = "64TB"; }; }; }; } ================================================ FILE: crates/omnix-health/module/flake-module.nix ================================================ { self, lib, flake-parts-lib, ... }: let inherit (flake-parts-lib) mkPerSystemOption; in { options = { perSystem = mkPerSystemOption ({ config, pkgs, ... }: { options.nix-health.outputs.devShell = lib.mkOption { type = lib.types.package; description = '' Add a shellHook for running nix-health on the flake. ''; default = pkgs.mkShell { shellHook = '' # Must use a subshell so that 'trap' handles only nix-health # crashes. ( trap "${lib.getExe pkgs.toilet} NIX UNHEALTHY --filter gay -f smmono9" ERR ${lib.getExe pkgs.nix-health} --quiet . ) ''; }; }; }); }; } ================================================ FILE: crates/omnix-health/module/flake.nix ================================================ { outputs = _: { flakeModule = ./flake-module.nix; }; } ================================================ FILE: crates/omnix-health/src/check/caches.rs ================================================ use nix_rs::info; use serde::{Deserialize, Serialize}; use url::Url; use crate::traits::*; /// Check that [nix_rs::config::NixConfig::substituters] is set to a good value. #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] #[serde(rename_all = "kebab-case")] #[serde(default)] pub struct Caches { pub required: Vec, } impl Default for Caches { fn default() -> Self { Caches { required: vec![Url::parse("https://cache.nixos.org").unwrap()], } } } impl Checkable for Caches { fn check( &self, nix_info: &info::NixInfo, _: Option<&nix_rs::flake::url::FlakeUrl>, ) -> Vec<(&'static str, Check)> { let missing_caches = self.get_missing_caches(nix_info); let result = if missing_caches.is_empty() { CheckResult::Green } else { CheckResult::Red { msg: format!( "You are missing some required caches: {}", missing_caches .iter() .map(|url| url.to_string()) .collect::>() .join(" ") ), suggestion: format!( "Caches can be added in your {} (see https://nixos.wiki/wiki/Binary_Cache#Using_a_binary_cache). Cachix caches can also be added using `nix run nixpkgs#cachix use `.", nix_info.nix_env.os.nix_config_label() ) } }; let check = Check { title: "Nix Caches in use".to_string(), info: format!( "substituters = {}", nix_info .nix_config .substituters .value .iter() .map(|url| url.to_string()) .collect::>() .join(" ") ), result, required: true, }; vec![("caches", check)] } } impl Caches { /// Get subset of required caches not already in use pub fn get_missing_caches(&self, nix_info: &info::NixInfo) -> Vec { let val = &nix_info.nix_config.substituters.value; self.required .iter() .filter(|required_cache| !val.contains(required_cache)) .cloned() .collect() } } pub struct CachixCache(pub String); impl CachixCache { /// Parse the https URL into a CachixCache pub fn from_url(url: &Url) -> Option { // Parse https://foo.cachix.org into CachixCache("foo") // If domain is not cachix.org, return None. let host = url.host_str()?; if host.ends_with(".cachix.org") { Some(CachixCache(host.split('.').next()?.to_string())) } else { None } } /// Run `cachix use` for this cache pub async fn cachix_use(&self) -> anyhow::Result<()> { let mut cmd = tokio::process::Command::new(env!("CACHIX_BIN")); cmd.arg("use").arg(&self.0); let status = cmd.spawn()?.wait().await?; if !status.success() { anyhow::bail!("Failed to run `cachix use {}`", self.0); } Ok(()) } } ================================================ FILE: crates/omnix-health/src/check/direnv.rs ================================================ use nix_rs::{flake::url::FlakeUrl, info}; use serde::{Deserialize, Serialize}; use crate::traits::{Check, CheckResult, Checkable}; /// Check if direnv is installed #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(default, rename_all = "kebab-case")] pub struct Direnv { pub(crate) enable: bool, /// Whether to produce [Check::required] checks pub(crate) required: bool, } impl Default for Direnv { fn default() -> Self { Self { enable: true, required: false, } } } impl Checkable for Direnv { fn check( &self, _nix_info: &info::NixInfo, flake_url: Option<&FlakeUrl>, ) -> Vec<(&'static str, Check)> { let mut checks = vec![]; if !self.enable { return checks; } let direnv_install_result = direnv::DirenvInstall::detect(); checks.push(( "direnv-install-check", install_check(&direnv_install_result, self.required), )); match direnv_install_result.as_ref() { Err(_) => return checks, Ok(direnv_install) => { // If direnv is installed, check for version and then allowed_check // This check is currently only relevant if the flake is local and an `.envrc` exists. match flake_url.as_ref().and_then(|url| url.as_local_path()) { None => {} Some(local_path) => { if local_path.join(".envrc").exists() { checks.push(( "direnv-allowed-check", allowed_check(direnv_install, local_path, self.required), )); } } } } } checks } } /// [Check] that direnv was installed. fn install_check( direnv_install_result: &Result, required: bool, ) -> Check { let setup_url = "https://github.com/juspay/nixos-unified-template"; Check { title: "Direnv installation".to_string(), info: format!( "direnv location = {:?}", direnv_install_result.as_ref().ok().map(|s| &s.bin_path) ), result: match direnv_install_result { Ok(direnv_install) if is_path_in_nix_store(&direnv_install.canonical_path) => { CheckResult::Green } Ok(direnv_install) => CheckResult::Red { msg: format!( "direnv is installed outside of Nix ({:?})", &direnv_install.canonical_path ), suggestion: format!( "Install direnv via Nix, it will also manage shell integration. See <{}>", setup_url ), }, Err(e) => CheckResult::Red { msg: format!("Unable to locate direnv ({})", e), suggestion: format!("Install direnv & nix-direnv. See <{}>", setup_url), }, }, required, } } /// Check that the path is in the Nix store (usually /nix/store) pub fn is_path_in_nix_store(path: &std::path::Path) -> bool { path.starts_with("/nix/store") } /// [Check] that direnv was allowed on the local flake fn allowed_check( direnv_install: &direnv::DirenvInstall, local_flake: &std::path::Path, required: bool, ) -> Check { let suggestion = format!( "Run `direnv allow` under `{}` (this activates the Nix devshell automatically)", local_flake.display() ); let direnv_allowed = direnv_install .status(local_flake) .map(|status| status.state.is_allowed()); Check { title: "Direnv allowed".to_string(), info: format!("Local flake: {:?} (has .envrc)", local_flake), result: match direnv_allowed { Ok(true) => CheckResult::Green, Ok(false) => CheckResult::Red { msg: "direnv was not allowed on this project".to_string(), suggestion, }, Err(e) => CheckResult::Red { msg: format!("Unable to check direnv status: {}", e), suggestion, }, }, required, } } ================================================ FILE: crates/omnix-health/src/check/flake_enabled.rs ================================================ use nix_rs::info; use serde::{Deserialize, Serialize}; use crate::traits::*; /// Check that [nix_rs::config::NixConfig::experimental_features] is set to a good value. #[derive(Debug, Default, Serialize, Deserialize, Clone)] #[serde(default)] pub struct FlakeEnabled {} impl Checkable for FlakeEnabled { fn check( &self, nix_info: &info::NixInfo, _: Option<&nix_rs::flake::url::FlakeUrl>, ) -> Vec<(&'static str, Check)> { let val = &nix_info.nix_config.experimental_features.value; let check = Check { title: "Flakes Enabled".to_string(), info: format!("experimental-features = {}", val.join(" ")), result: if val.contains(&"flakes".to_string()) && val.contains(&"nix-command".to_string()) { CheckResult::Green } else { CheckResult::Red { msg: "Nix flakes are not enabled".into(), suggestion: "See https://nixos.wiki/wiki/Flakes#Enable_flakes".into(), } }, required: true, }; vec![("flake-enabled", check)] } } ================================================ FILE: crates/omnix-health/src/check/homebrew.rs ================================================ use nix_rs::{flake::url::FlakeUrl, info::NixInfo}; use omnix_common::check::which_strict; use serde::{Deserialize, Serialize}; use crate::traits::{Check, CheckResult, Checkable}; /// Check if Homebrew is installed #[derive(Default, Debug, Serialize, Deserialize, Clone)] #[serde(default, rename_all = "kebab-case")] pub struct Homebrew { pub(crate) enable: bool, pub(crate) required: bool, } impl Checkable for Homebrew { fn check( &self, nix_info: &NixInfo, _flake_url: Option<&FlakeUrl>, ) -> Vec<(&'static str, Check)> { let mut checks = vec![]; if self.enable && matches!(nix_info.nix_env.os, nix_rs::env::OS::MacOS { .. }) { checks.push(( "homebrew-check", installation_check(&HomebrewInstall::detect(), self.required), )); } checks } } /// Information about user's homebrew installation. #[derive(Debug)] pub struct HomebrewInstall { /// The path to the Homebrew binary. pub bin_path: std::path::PathBuf, } impl HomebrewInstall { /// Detect homebrew installation. pub fn detect() -> Option { which_strict("brew").map(|bin_path| HomebrewInstall { bin_path }) } } /// Create a [Check] for Homebrew installation fn installation_check(homebrew_result: &Option, required: bool) -> Check { let nix_setup_url = "https://github.com/juspay/nixos-unified-template"; Check { title: "Homebrew installation".to_string(), info: format!( "Homebrew binary: {}", homebrew_result .as_ref() .map(|h| format!("Found at {:?}", h.bin_path)) .unwrap_or_else(|| "Not found".to_string()) ), result: match homebrew_result { Some(homebrew) => CheckResult::Red { msg: format!( "Homebrew is installed at {:?}. Consider using Nix for better reproducibility", homebrew.bin_path ), suggestion: format!( "Managing packages with Nix, rather than Homebrew, provides better reproducibility and integration. See <{}>\n\n{}", nix_setup_url, HOMEBREW_REMOVAL_INSTRUCTIONS ), }, None => CheckResult::Green, }, required, } } /// A string containing step-by-step removal commands and migration advice. const HOMEBREW_REMOVAL_INSTRUCTIONS: &str = r#"To completely remove Homebrew from your system: - **Uninstall Homebrew and all packages:** /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/uninstall.sh)" For a safer migration, consider using 'brew list' to inventory your packages before removal, then install equivalents via Nix."#; ================================================ FILE: crates/omnix-health/src/check/max_jobs.rs ================================================ use nix_rs::info; use serde::{Deserialize, Serialize}; use crate::traits::*; /// Check that [nix_rs::config::NixConfig::max_jobs] is set to a good value. #[derive(Debug, Default, Serialize, Deserialize, Clone)] #[serde(default)] pub struct MaxJobs {} impl Checkable for MaxJobs { fn check( &self, nix_info: &info::NixInfo, _: Option<&nix_rs::flake::url::FlakeUrl>, ) -> Vec<(&'static str, Check)> { let max_jobs = nix_info.nix_config.max_jobs.value; let check = Check { title: "Max Jobs".to_string(), info: format!("max-jobs = {}", max_jobs), result: if max_jobs > 1 { CheckResult::Green } else { CheckResult::Red { msg: "You are using only 1 CPU core for nix builds".into(), suggestion: format!( "Set `max-jobs = auto` in {}", nix_info.nix_env.os.nix_config_label() ), } }, required: true, }; vec![("max-jobs", check)] } } ================================================ FILE: crates/omnix-health/src/check/mod.rs ================================================ //! Individual Nix checks pub mod caches; pub mod direnv; pub mod flake_enabled; pub mod homebrew; pub mod max_jobs; pub mod nix_version; pub mod rosetta; pub mod shell; pub mod trusted_users; ================================================ FILE: crates/omnix-health/src/check/nix_version.rs ================================================ use std::str::FromStr; use nix_rs::version_spec::NixVersionReq; use nix_rs::info; use serde::{Deserialize, Serialize}; use crate::traits::*; /// Check that [nix_rs::version::NixVersion] is set to a good value. #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] #[serde(default, rename_all = "kebab-case")] pub struct NixVersionCheck { pub supported: NixVersionReq, } impl Default for NixVersionCheck { fn default() -> Self { NixVersionCheck { supported: NixVersionReq::from_str(">=2.16.0").unwrap(), } } } impl Checkable for NixVersionCheck { fn check( &self, nix_info: &info::NixInfo, _: Option<&nix_rs::flake::url::FlakeUrl>, ) -> Vec<(&'static str, Check)> { let val = &nix_info.nix_version; let is_supported = self.supported.specs.iter().all(|spec| spec.matches(val)); let supported_version_check = Check { title: "Nix Version is supported".to_string(), info: format!("nix version = {}", val), result: if is_supported { CheckResult::Green } else { CheckResult::Red { msg: format!( "Your Nix version ({}) doesn't satisfy the supported bounds: {}", val, self.supported ), suggestion: "To use a specific version of Nix, see " .into(), } }, required: true, }; vec![("supported-nix-versions", supported_version_check)] } } ================================================ FILE: crates/omnix-health/src/check/rosetta.rs ================================================ use nix_rs::{env::OS, info}; use serde::{Deserialize, Serialize}; use crate::traits::{Check, CheckResult, Checkable}; /// Check if Nix is being run under rosetta emulation /// /// Enabled only on ARM macs. #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(default)] pub struct Rosetta { enable: bool, required: bool, } impl Default for Rosetta { fn default() -> Self { Self { enable: true, required: true, } } } impl Checkable for Rosetta { fn check( &self, nix_info: &info::NixInfo, _: Option<&nix_rs::flake::url::FlakeUrl>, ) -> Vec<(&'static str, Check)> { let mut checks = vec![]; if let (true, Some(emulation)) = (self.enable, get_apple_emulation(&nix_info.nix_env.os)) { let check = Check { title: "Rosetta Not Active".to_string(), info: format!("apple emulation = {:?}", emulation), result: if emulation { CheckResult::Red { msg: "Rosetta emulation will slow down Nix builds".to_string(), // NOTE: This check assumes that `omnix` was installed via `nix`, thus assuming `nix` is also translated using Rosetta. // Hence, the suggestion to re-install nix. suggestion: "Disable Rosetta for your terminal (Right-click on your terminal icon in `Finder`, choose `Get Info` and un-check `Open using Rosetta`). Uninstall nix: . And re-install for `aarch64-darwin`: ".to_string(), } } else { CheckResult::Green }, required: self.required, }; checks.push(("rosetta", check)); }; checks } } /// Return [true] if the current binary is translated using Rosetta. Return None if not an ARM mac. fn get_apple_emulation(system: &OS) -> Option { match system { OS::MacOS { nix_darwin: _, arch: _, proc_translated: is_proc_translated, } => Some(*is_proc_translated), _ => None, } } ================================================ FILE: crates/omnix-health/src/check/shell.rs ================================================ use nix_rs::env::OS; use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, hash::Hash, path::{Path, PathBuf}, }; use crate::traits::{Check, CheckResult, Checkable}; #[derive(Debug, Serialize, Deserialize, Clone, Hash, Eq, PartialEq)] pub struct ShellCheck { pub(crate) enable: bool, /// Whether to produce [Check::required] checks pub(crate) required: bool, } impl Default for ShellCheck { fn default() -> Self { Self { enable: true, required: false, } } } impl Checkable for ShellCheck { fn check( &self, nix_info: &nix_rs::info::NixInfo, _flake: Option<&nix_rs::flake::url::FlakeUrl>, ) -> Vec<(&'static str, Check)> { if !self.enable { return vec![]; } let os = &nix_info.nix_env.os; let user_shell_env = match CurrentUserShellEnv::new(os) { Ok(shell) => shell, Err(err) => { tracing::error!("Skipping shell dotfile check! {:?}", err); if self.required { panic!("Unable to determine user's shell environment (see above)"); } else { tracing::warn!("Skipping shell dotfile check! (see above)"); return vec![]; } } }; // Iterate over each dotfile and check if it is managed by Nix let mut managed: HashMap = HashMap::new(); let mut unmanaged: HashMap = HashMap::new(); for (name, path) in user_shell_env.dotfiles { if super::direnv::is_path_in_nix_store(&path) { managed.insert(name, path.clone()); } else { unmanaged.insert(name, path.clone()); } } let title = "Shell dotfiles".to_string(); let info = format!( "Shell={:?}; HOME={:?}; Managed: {:?}; Unmanaged: {:?}", user_shell_env.shell, user_shell_env.home, managed, unmanaged ); let result = if !managed.is_empty() && unmanaged.is_empty() { // If *all* dotfiles are managed, then we are good CheckResult::Green } else { CheckResult::Red { msg: format!("Default Shell: {:?} is not managed by Nix", user_shell_env.shell), suggestion: "You can use `home-manager` to manage shell configuration. See ".to_string(), } }; let check = Check { title, info, result, required: self.required, }; vec![("shell", check)] } } /// The shell environment of the current user struct CurrentUserShellEnv { /// The user's home directory home: PathBuf, /// Current shell shell: Shell, /// *Absolute* paths to the dotfiles dotfiles: HashMap, } impl CurrentUserShellEnv { /// Get the current user's shell environment fn new(os: &OS) -> Result { let home = PathBuf::from(std::env::var("HOME")?); let shell = Shell::current_shell()?; let dotfiles = shell.get_dotfiles(os, &home)?; let v = CurrentUserShellEnv { home, shell, dotfiles, }; Ok(v) } } #[derive(thiserror::Error, Debug)] enum ShellError { #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("Environment variable error: {0}")] Var(#[from] std::env::VarError), #[error("Bad $SHELL value")] BadShellPath, #[error("Unsupported shell. Please file an issue at ")] UnsupportedShell, } /// An Unix shell #[derive(Debug, Serialize, Deserialize, Clone, Hash, Eq, PartialEq)] #[serde(rename_all = "lowercase")] enum Shell { Zsh, Bash, Nushell, } impl Shell { /// Returns the user's current [Shell] fn current_shell() -> Result { let shell_path = PathBuf::from(std::env::var("SHELL")?); Self::from_path(shell_path) } /// Lookup [Shell] from the given executable path /// For example if path is `/bin/zsh`, it would return `Zsh` fn from_path(exe_path: PathBuf) -> Result { let shell_name = exe_path .file_name() .ok_or(ShellError::BadShellPath)? .to_string_lossy(); match shell_name.as_ref() { "zsh" => Ok(Shell::Zsh), "bash" => Ok(Shell::Bash), "nu" => Ok(Shell::Nushell), _ => Err(ShellError::UnsupportedShell), } } /// Get shell dotfiles fn dotfile_names(&self, os: &OS) -> Vec { match &self { Shell::Zsh => vec![ ".zshrc".into(), ".zshenv".into(), ".zprofile".into(), ".zlogin".into(), ".zlogout".into(), ], Shell::Bash => vec![".bashrc".into(), ".bash_profile".into(), ".profile".into()], Shell::Nushell => { let base = match os { // https://www.nushell.sh/book/configuration.html#configuration-overview OS::MacOS { .. } => "Library/Application Support/nushell", _ => ".config/nushell", }; ["env.nu", "config.nu", "login.nu"] .iter() .map(|f| format!("{}/{}", base, f)) .collect() } } } /// Get the currently existing dotfiles under $HOME /// /// Returned paths will be absolute (i.e., symlinks are resolved). fn get_dotfiles(&self, os: &OS, home_dir: &Path) -> std::io::Result> { let mut paths = HashMap::new(); for dotfile in self.dotfile_names(os) { match std::fs::canonicalize(home_dir.join(&dotfile)) { Ok(path) => { paths.insert(dotfile, path); } Err(err) if err.kind() == std::io::ErrorKind::NotFound => { // If file not found, skip } Err(err) => return Err(err), } } Ok(paths) } } ================================================ FILE: crates/omnix-health/src/check/trusted_users.rs ================================================ use std::collections::HashSet; use nix_rs::config::TrustedUserValue; use serde::{Deserialize, Serialize}; use crate::traits::*; /// Check that [nix_rs::config::NixConfig::trusted_users] is set to a good value. #[derive(Debug, Default, Serialize, Deserialize, Clone)] #[serde(default)] pub struct TrustedUsers { /// This check is disabled by default due to security concerns /// See https://github.com/juspay/omnix/issues/409 pub(crate) enable: bool, } impl Checkable for TrustedUsers { fn check( &self, nix_info: &nix_rs::info::NixInfo, _: Option<&nix_rs::flake::url::FlakeUrl>, ) -> Vec<(&'static str, Check)> { if !self.enable { return vec![]; } let result = if is_current_user_trusted(nix_info) { CheckResult::Green } else { let current_user = &nix_info.nix_env.current_user; let msg = format!("User '{}' not present in trusted_users", current_user); let suggestion = match nix_info.nix_env.os.nix_system_config_label() { Some(conf_label) => format!( r#"Add `nix.trustedUsers = [ "root" "{}" ];` to your {}"#, current_user, conf_label, ), None => format!( r#"Set `trusted-users = root {}` in /etc/nix/nix.conf and then restart the Nix daemon using `sudo pkill nix-daemon`"#, current_user ), }; CheckResult::Red { msg, suggestion } }; let check = Check { title: "Trusted Users".to_string(), info: format!( "trusted-users = {}", TrustedUserValue::display_original(&nix_info.nix_config.trusted_users.value) ), result, required: true, }; vec![("trusted-users", check)] } } /// Check that [nix_rs::config::NixConfig::trusted_users] is set to a good /// value such that the current user is trusted. fn is_current_user_trusted(nix_info: &nix_rs::info::NixInfo) -> bool { let current_user = &nix_info.nix_env.current_user; let current_user_groups: HashSet<&String> = nix_info.nix_env.current_user_groups.iter().collect(); nix_info .nix_config .trusted_users .value .iter() .any(|x| match x { TrustedUserValue::Group(x) => current_user_groups.contains(&x), TrustedUserValue::User(x) => x == current_user, TrustedUserValue::All => true, }) } ================================================ FILE: crates/omnix-health/src/json.rs ================================================ //! JSON output schema for health checks use crate::traits::Check; use anyhow::Context; use bytesize::ByteSize; use nix_rs::{ env::{NixInstaller, OS}, flake::system::System, info::NixInfo, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// Entire JSON health check output #[derive(Debug, Serialize, Deserialize, Clone)] pub struct HealthOutput { /// Map of check names to their results pub checks: HashMap, /// System environment information pub info: HealthEnvInfo, } impl HealthOutput { pub async fn get(checks: Vec<(&'static str, Check)>) -> anyhow::Result { Ok(Self { checks: checks .into_iter() .map(|(k, v)| (k.to_string(), v)) .collect(), info: HealthEnvInfo::get().await?, }) } } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct HealthEnvInfo { nix_installer: NixInstaller, system: System, os: OS, total_memory: ByteSize, total_disk_space: ByteSize, } impl HealthEnvInfo { /// Get system environment information /// /// Returns error if [NixInfo] cannot be retrieved pub async fn get() -> anyhow::Result { let nix_info = NixInfo::get() .await .as_ref() .context("Unable to gather nix info")?; Ok(Self { nix_installer: nix_info.nix_env.installer.clone(), system: nix_info.nix_config.system.value.clone(), os: nix_info.nix_env.os.clone(), total_memory: nix_info.nix_env.total_memory, total_disk_space: nix_info.nix_env.total_disk_space, }) } } ================================================ FILE: crates/omnix-health/src/lib.rs ================================================ //! Health checks for the user's Nix install pub mod check; pub mod json; pub mod report; pub mod traits; use anyhow::Context; use check::shell::ShellCheck; use colored::Colorize; use check::direnv::Direnv; use check::homebrew::Homebrew; use json::HealthOutput; use nix_rs::command::NixCmd; use nix_rs::env::OS; use nix_rs::flake::url::FlakeUrl; use nix_rs::info::NixInfo; use omnix_common::config::{OmConfig, OmConfigError}; use omnix_common::markdown::render_markdown; use serde::{Deserialize, Serialize}; use tracing::instrument; use traits::Check; use self::check::{ caches::Caches, flake_enabled::FlakeEnabled, max_jobs::MaxJobs, nix_version::NixVersionCheck, rosetta::Rosetta, trusted_users::TrustedUsers, }; /// Nix Health check of user's install /// /// Each check field is expected to implement [traits::Checkable]. #[derive(Debug, Default, Serialize, Deserialize, Clone)] #[serde(default, rename_all = "kebab-case")] pub struct NixHealth { pub flake_enabled: FlakeEnabled, pub nix_version: NixVersionCheck, pub rosetta: Rosetta, pub max_jobs: MaxJobs, pub trusted_users: TrustedUsers, pub caches: Caches, pub direnv: Direnv, pub homebrew: Homebrew, pub shell: ShellCheck, } /// Convert [NixHealth] into a generic [Vec] of checks impl<'a> IntoIterator for &'a NixHealth { type Item = &'a dyn traits::Checkable; type IntoIter = std::vec::IntoIter; /// Return an iterator to iterate on the fields of [NixHealth] fn into_iter(self) -> Self::IntoIter { let items: Vec = vec![ &self.flake_enabled, &self.nix_version, &self.rosetta, &self.max_jobs, &self.trusted_users, &self.caches, &self.direnv, &self.homebrew, &self.shell, ]; items.into_iter() } } impl NixHealth { /// Create [NixHealth] using configuration from the given flake /// /// Fallback to using the default health check config if the flake doesn't /// override it. pub fn from_om_config(om_config: &OmConfig) -> Result { let (cfg, _rest) = om_config.get_sub_config_under::("health")?; Ok(cfg.clone()) } /// Run all checks and collect the results #[instrument(skip_all)] pub fn run_all_checks( &self, nix_info: &NixInfo, flake_url: Option, ) -> Vec<(&'static str, Check)> { self.into_iter() .flat_map(|c| c.check(nix_info, flake_url.as_ref())) .collect() } pub async fn print_report_returning_exit_code( checks: &Vec<(&'static str, Check)>, json_only: bool, ) -> anyhow::Result { let mut res = AllChecksResult::new(); for (_, check) in checks { if !json_only { check.tracing_log().await?; } if !check.result.green() { res.register_failure(check.required); }; } let code = res.report(); if json_only { let json = HealthOutput::get(checks.to_vec()).await?; println!("{}", serde_json::to_string(&json)?); } Ok(code) } pub fn schema() -> Result { serde_json::to_string_pretty(&NixHealth::default()) } } /// Run all health checks, optionally using the given flake's configuration pub async fn run_all_checks_with( nixcmd: &NixCmd, flake_url: Option, json_only: bool, ) -> anyhow::Result> { let nix_info = NixInfo::get() .await .as_ref() .with_context(|| "Unable to gather nix info")?; let health: NixHealth = match flake_url.as_ref() { Some(flake_url) => { let om_config = OmConfig::get(nixcmd, flake_url).await?; NixHealth::from_om_config(&om_config) } None => Ok(NixHealth::default()), }?; tracing::info!( "🩺️ Checking the health of your Nix setup (flake: '{}')", match flake_url.as_ref() { Some(url) => url.to_string(), None => "N/A".to_string(), } ); if !json_only { print_info_banner(flake_url.as_ref(), nix_info).await?; } let checks = health.run_all_checks(nix_info, flake_url); Ok(checks) } async fn print_info_banner(flake_url: Option<&FlakeUrl>, nix_info: &NixInfo) -> anyhow::Result<()> { let pwd = std::env::current_dir()?; let mut table = String::from("| Property | Value |\n|----------|-------|\n"); table.push_str(&format!( "| Flake | {} |\n", match flake_url { Some(url) => url.to_string(), None => "N/A".to_string(), } )); table.push_str(&format!( "| System | {} |\n", nix_info.nix_config.system.value )); table.push_str(&format!("| OS | {} |\n", nix_info.nix_env.os)); if nix_info.nix_env.os != OS::NixOS { table.push_str(&format!( "| Nix installer | {} |\n", nix_info.nix_env.installer )); } table.push_str(&format!("| RAM | {:?} |\n", nix_info.nix_env.total_memory)); table.push_str(&format!( "| Disk Space | {:?} |", nix_info.nix_env.total_disk_space )); tracing::info!("{}", render_markdown(&pwd, &table).await?); Ok(()) } /// A convenient type to aggregate check failures, and summary report at end. enum AllChecksResult { Pass, PassSomeFail, Fail, } impl AllChecksResult { fn new() -> Self { AllChecksResult::Pass } fn register_failure(&mut self, required: bool) { if required { *self = AllChecksResult::Fail; } else if matches!(self, AllChecksResult::Pass) { *self = AllChecksResult::PassSomeFail; } } /// Print a summary report of the checks and return the exit code fn report(self) -> i32 { match self { AllChecksResult::Pass => { tracing::info!("{}", "✅ All checks passed".green().bold()); 0 } AllChecksResult::PassSomeFail => { tracing::warn!( "{}, {}", "✅ Required checks passed".green().bold(), "but some non-required checks failed".yellow().bold() ); 0 } AllChecksResult::Fail => { tracing::error!("{}", "❌ Some required checks failed".red().bold()); 1 } } } } #[cfg(test)] mod tests { use std::str::FromStr; use nix_rs::version_spec::NixVersionReq; use crate::check::{caches::Caches, nix_version::NixVersionCheck}; #[test] fn test_json_deserialize_empty() { let json = r#"{}"#; let v: super::NixHealth = serde_json::from_str(json).unwrap(); assert_eq!(v.nix_version, NixVersionCheck::default()); assert_eq!(v.caches, Caches::default()); println!("{:?}", v); } #[test] fn test_json_deserialize_nix_version() { let json = r#"{ "nix-version": { "supported": ">=2.17.0" } }"#; let v: super::NixHealth = serde_json::from_str(json).unwrap(); assert_eq!( v.nix_version.supported, NixVersionReq::from_str(">=2.17.0").unwrap() ); assert_eq!(v.caches, Caches::default()); } #[test] fn test_json_deserialize_caches() { let json = r#"{ "caches": { "required": ["https://foo.cachix.org"] } }"#; let v: super::NixHealth = serde_json::from_str(json).unwrap(); assert_eq!( v.caches.required, vec![url::Url::parse("https://foo.cachix.org").unwrap()] ); assert_eq!(v.nix_version, NixVersionCheck::default()); } } ================================================ FILE: crates/omnix-health/src/report.rs ================================================ use serde::{Deserialize, Serialize}; /// Health report /// /// If you just want the binary indicator, use `Report` (see /// [NoDetails]). If you want the report with details regarding the problem, use /// `Report` (see [WithDetails]). #[derive(Debug, PartialEq, PartialOrd, Eq, Ord, Serialize, Deserialize, Clone)] pub enum Report { /// Green means everything is fine Green, /// Red means something is wrong. `T` holds information about what's wrong. Red(T), } #[derive(Debug, PartialEq, PartialOrd, Eq, Ord, Serialize, Deserialize, Clone)] pub struct NoDetails; impl Report { pub fn is_green(&self) -> bool { match self { Report::Green => true, Report::Red(_) => false, } } pub fn is_red(&self) -> bool { !self.is_green() } } /// Details regarding a failed report #[derive(Debug, PartialEq, PartialOrd, Eq, Ord, Serialize, Deserialize, Clone)] pub struct WithDetails { /// A short message describing the problem pub msg: String, /// A suggestion for how to fix the problem pub suggestion: String, } // TODO: Should this be Markdown? impl Report { /// Return the report without the details pub fn without_details(&self) -> Report { match self { Report::Green => Report::Green, Report::Red(_) => Report::Red(NoDetails), } } /// Return the problem details if there is one. pub fn get_red_details(&self) -> Option { match self { Report::Green => None, Report::Red(details) => Some(details.clone()), } } } ================================================ FILE: crates/omnix-health/src/traits.rs ================================================ use colored::Colorize; use serde::{Deserialize, Serialize}; /// Types that can do specific "health check" for Nix pub trait Checkable { /// Run and create the health check /// /// NOTE: Some checks may perform impure actions (IO, etc.). Returning an /// empty vector indicates that the check is skipped on this environment. fn check( &self, nix_info: &nix_rs::info::NixInfo, // The flake against which the check is being run // // If None, the check is run against the current environment, with no // specific configuration from a flake. flake: Option<&nix_rs::flake::url::FlakeUrl>, ) -> Vec<(&'static str, Check)>; } /// A health check #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] pub struct Check { /// A user-facing title of this check /// /// This value is expected to be unique across all checks. pub title: String, /// The user-facing information used to conduct this check /// TODO: Use Markdown pub info: String, /// The result of running this check pub result: CheckResult, /// Whether this check is mandatory /// /// Failures are considered non-critical if this is false. pub required: bool, } impl Check { /// Log the results using tracing crate pub async fn tracing_log(&self) -> anyhow::Result<()> { let pwd = std::env::current_dir()?; use omnix_common::markdown::render_markdown; match &self.result { CheckResult::Green => { tracing::info!("✅ {}", self.title.green().bold()); tracing::info!("{}", render_markdown(&pwd, &self.info).await?.dimmed()); } CheckResult::Red { msg, suggestion } => { let solution = render_markdown( &pwd, &format!("**Problem**: {}\\\n**Fix**: {}\n", msg, suggestion), ) .await?; if self.required { tracing::error!( "❌ {}", render_markdown(&pwd, &self.title).await?.red().bold() ); tracing::error!("{}", render_markdown(&pwd, &self.info).await?.dimmed()); tracing::error!("{}", solution); } else { tracing::warn!( "🟧 {}", render_markdown(&pwd, &self.title).await?.yellow().bold() ); tracing::warn!("{}", render_markdown(&pwd, &self.info).await?.dimmed()); tracing::warn!("{}", solution); } } } Ok(()) } } /// The result of a health [Check] #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] pub enum CheckResult { /// The check passed Green, /// The check failed Red { /// TODO: Use markdown msg: String, /// TODO: Use markdown suggestion: String, }, } impl CheckResult { /// When the check is green (ie., healthy) pub fn green(&self) -> bool { matches!(self, Self::Green) } } ================================================ FILE: crates/omnix-init/Cargo.toml ================================================ [package] authors = ["Sridhar Ratnakumar "] edition = "2021" # If you change the name here, you must also do it in flake.nix (and run `cargo generate-lockfile` afterwards) name = "omnix-init" version = "1.3.2" repository = "https://github.com/juspay/omnix" description = "Enriched Nix flake templates" license = "Apache-2.0" [lib] crate-type = ["cdylib", "rlib"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = { workspace = true } assert_fs = "1" assert_matches = "1.5" colored = { workspace = true } console = { workspace = true } globset = { workspace = true } inquire = { workspace = true } itertools = { workspace = true } lazy_static = { workspace = true } nix_rs = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } omnix-common = { workspace = true } ================================================ FILE: crates/omnix-init/crate.nix ================================================ { autoWire = [ ]; crane.args = { }; } ================================================ FILE: crates/omnix-init/registry/flake.nix ================================================ # `om init` registry's canonical source # # Get JSON using: # nix eval --json .#registry | jq { inputs = { haskell-flake.url = "github:srid/haskell-flake"; haskell-flake.flake = false; haskell-template.url = "github:srid/haskell-template"; haskell-template.flake = false; rust-nix-template.url = "github:srid/rust-nix-template"; rust-nix-template.flake = false; nixos-unified-template.url = "github:juspay/nixos-unified-template"; nixos-unified-template.flake = false; }; outputs = inputs: { registry = builtins.mapAttrs (k: v: v.outPath) (builtins.removeAttrs inputs [ "self" ]); }; } ================================================ FILE: crates/omnix-init/src/action.rs ================================================ use anyhow::Context; use globset::{Glob, GlobSetBuilder}; use itertools::Itertools; use serde::Deserialize; use std::cmp::Ordering; use std::fmt::{self, Display, Formatter}; use std::path::Path; use tokio::fs; /// The action to perform on a template #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(untagged)] pub enum Action { /// Replace 'placeholder' with 'value' if it exists Replace { /// The text to replace. placeholder: String, /// The text to replace it with. #[serde(default)] value: Option, }, /// Delete given paths if 'value' is false Retain { /// The glob patterns to retain or delete paths: Vec, /// Whether to retain or delete #[serde(default)] value: Option, }, } impl Display for Action { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match self { Action::Replace { placeholder, value } => match value { Some(value) => write!(f, "replace [{} => {}]", placeholder, value), None => write!(f, "replace [disabled]"), }, Action::Retain { paths, value } => match value { Some(false) => { let paths = paths.iter().map(|p| p.to_string()).join(", "); write!(f, "prune [{}]", paths) } _ => write!(f, "prune [disabled]"), }, } } } impl Action { /// Whether there is a current value in this action pub fn has_value(&self) -> bool { match self { Action::Replace { value, .. } => value.is_some(), Action::Retain { value, .. } => value.is_some(), } } /// Apply the [Action] to the given directory pub async fn apply(&self, out_dir: &Path) -> anyhow::Result<()> { match &self { Action::Replace { placeholder, value } => { if let Some(value) = value.as_ref() { let files = omnix_common::fs::find_paths(out_dir).await?; // Process files in reverse order, such that we replace in // files *before* their ancestor directories get renamed. for file in files.iter().sorted().rev() { let file_path = &out_dir.join(file); // Replace in content of files if file_path.is_file() { let content = fs::read_to_string(&file_path).await.with_context(|| { format!("Unable to read file: {:?}", &file_path) })?; if content.contains(placeholder) { tracing::info!(" ✍️ {}", file.to_string_lossy()); let content = content.replace(placeholder, value); fs::write(file_path, content).await?; } } // Rename path if necessary if let Some(file_name) = file.file_name().map(|f| f.to_string_lossy()) { if file_name.contains(placeholder) { let new_name = file_name.replace(placeholder, value); let new_path = &file_path.with_file_name(&new_name); if file != new_path { tracing::info!(" ✏️ {} => {}", file.display(), &new_name,); fs::rename(file_path, new_path).await?; } } } } } } Action::Retain { paths, value } => { if *value == Some(false) { // Get files matching let files = omnix_common::fs::find_paths(out_dir).await?; let set = build_glob_set(paths)?; let files_to_delete = files .iter() .filter(|file| set.is_match(file)) .collect::>(); if files_to_delete.is_empty() { anyhow::bail!("No paths matched in {:?}", files); }; // Iterating in reverse-sorted order ensures that children gets deleted before their parent folders. for file in files_to_delete.iter().sorted().rev() { let path = out_dir.join(file); tracing::info!(" ❌ {}", file.display()); omnix_common::fs::remove_all(path).await?; } } } } Ok(()) } } // Combine multiple glob patterns into a single set fn build_glob_set(globs: &[Glob]) -> anyhow::Result { let mut builder = GlobSetBuilder::new(); for g in globs.iter() { builder.add(g.clone()); } Ok(builder.build()?) } // Implement Ord such that 'Retain' appears before 'Replace' in the list // Because, Retain will delete files, which affect the Replace actions. impl Ord for Action { fn cmp(&self, other: &Self) -> Ordering { match (self, other) { (Action::Retain { .. }, Action::Replace { .. }) => Ordering::Less, (Action::Replace { .. }, Action::Retain { .. }) => Ordering::Greater, _ => Ordering::Equal, } } } impl PartialOrd for Action { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } ================================================ FILE: crates/omnix-init/src/config.rs ================================================ use std::fmt::{self, Display, Formatter}; use colored::Colorize; use nix_rs::{command::NixCmd, flake::url::FlakeUrl}; use omnix_common::config::OmConfig; use crate::template::Template; /// A named [Template] associated with a [FlakeUrl] #[derive(Debug, Clone)] pub struct FlakeTemplate<'a> { pub flake: &'a FlakeUrl, pub template_name: String, pub template: Template, } // This instance is used during user prompting. impl Display for FlakeTemplate<'_> { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { write!( f, "{:<15} {} {}", self.template_name, format!("[{}]", self.flake).dimmed(), self.template .template .description .as_ref() .unwrap_or(&"".to_string()) ) } } /// Load templates from the given flake pub async fn load_templates<'a>( nixcmd: &'a NixCmd, url: &'a FlakeUrl, ) -> anyhow::Result>> { let om_config = OmConfig::get(nixcmd, url).await?; let templates = om_config .config .get::