From 25cc087b1da8dda965d47283c120a726b84ad6cf Mon Sep 17 00:00:00 2001 From: technofab Date: Mon, 25 Aug 2025 16:45:22 +0200 Subject: [PATCH] chore: initial commit --- .envrc | 2 + .gitignore | 6 + README.md | 61 ++++++++ flake.lock | 63 ++++++++ flake.nix | 31 ++++ lib/default.nix | 25 ++++ lib/devshellModule.nix | 23 +++ lib/flake.nix | 6 + lib/lib.nix | 101 +++++++++++++ lib/module.nix | 256 +++++++++++++++++++++++++++++++++ nix/repo/devShells.nix | 25 ++++ nix/repo/flake.lock | 46 ++++++ nix/repo/flake.nix | 13 ++ nix/repo/tests.nix | 10 ++ tests/fixtures/gotmpl_template | 1 + tests/fixtures/jinja_template | 1 + tests/soonix_test.nix | 71 +++++++++ 17 files changed, 741 insertions(+) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 README.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 lib/default.nix create mode 100644 lib/devshellModule.nix create mode 100644 lib/flake.nix create mode 100644 lib/lib.nix create mode 100644 lib/module.nix create mode 100644 nix/repo/devShells.nix create mode 100644 nix/repo/flake.lock create mode 100644 nix/repo/flake.nix create mode 100644 nix/repo/tests.nix create mode 100644 tests/fixtures/gotmpl_template create mode 100644 tests/fixtures/jinja_template create mode 100644 tests/soonix_test.nix diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..565a52a --- /dev/null +++ b/.envrc @@ -0,0 +1,2 @@ +source $(fetchurl https://gitlab.com/rensa-nix/direnv/-/raw/v0.3.0/direnvrc "sha256-u7+KEz684NnIZ+Vh5x5qLrt8rKdnUNexewBoeTcEVHQ=") +use ren //repo/devShells/default diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e930787 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.ren/ +result + +# soonix +test.yaml +# end soonix diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc1aec9 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Soonix + +Soonix helps you declaratively manage configuration files, build scripts, etc. using the Nix module system. +It minimizes configuration clutter and provides shell hooks for automatic file management. + +Heavily based on and inspired by [Nixago](https://github.com/nix-community/nixago), thus the name (ago <-> soon, if that wasn't obvious). + +## Features + +- **Declarative Configuration**: Uses Nix modules for type-safe, declarative file management +- **Multiple Engines**: Support for JSON/YAML/TOML, templates, scripts, and more +- **Shell Hooks**: Automatic file management with status tracking +- **Flexible File Handling**: Choose between symlinks and copies based on your needs +- **GitIgnore Integration**: Automatic management of .gitignore entries + +## Quick Start + +```nix +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + soonix.url = "gitlab:TECHNOFAB/soonix?dir=lib"; + }; + + outputs = { nixpkgs, soonix, ... }: let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + + shellHook = (soonix.lib { inherit pkgs; }).mkShellHook { + hooks = { + eslintrc = { + output = ".eslintrc.json"; + generator = "nix"; + data = { extends = ["eslint:recommended"]; }; + opts = { format = "json"; }; + }; + }; + }; + in { + devShells.${system}.default = pkgs.mkShell { + packages = [ pkgs.jq ]; + inherit shellHook; + }; + }; +} +``` + +If you use [rensa-nix/devshell](https://devshell.rensa.projects.tf), you can also +use the `devshellModule` for easy integration, see the docs for more. + +## Available Engines + +- **`nix`**: Convert Nix data to JSON, YAML, TOML, INI, XML formats +- **`string`**: Output raw string content with optional executable permissions +- **`derivation`**: Use existing Nix derivations as file content +- **`gotmpl`**: Advanced Go template rendering via gomplate +- **`jinja`**: Python Jinja2 template rendering + +## Docs + +[Docs](https://soonix.projects.tf) diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..2faa24f --- /dev/null +++ b/flake.lock @@ -0,0 +1,63 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1755615617, + "narHash": "sha256-HMwfAJBdrr8wXAkbGhtcby1zGFvs+StOp19xNsbqdOg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "20075955deac2583bb12f07151c2df830ef346b4", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1754184128, + "narHash": "sha256-AjhoyBL4eSyXf01Bmc6DiuaMrJRNdWopmdnMY0Pa/M0=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "02e72200e6d56494f4a7c0da8118760736e41b60", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "ren": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "dir": "lib", + "lastModified": 1755264589, + "narHash": "sha256-g8KjU4D/nxpMjCLQNP90VAAWUH89yvONRfChyhhzq4c=", + "owner": "rensa-nix", + "repo": "core", + "rev": "9f20f8c94b09a1c85356f8340ebe0a339b0d32e6", + "type": "gitlab" + }, + "original": { + "dir": "lib", + "owner": "rensa-nix", + "repo": "core", + "type": "gitlab" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "ren": "ren" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..f7d0255 --- /dev/null +++ b/flake.nix @@ -0,0 +1,31 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + ren.url = "gitlab:rensa-nix/core?dir=lib"; + }; + + outputs = { + self, + ren, + ... + } @ inputs: + ren.buildWith + { + inherit inputs; + cellsFrom = ./nix; + transformInputs = system: i: + i + // { + pkgs = import i.nixpkgs {inherit system;}; + }; + cellBlocks = with ren.blocks; [ + (simple "devShells") + (simple "tests") + ]; + } + { + packages = ren.select self [ + ["repo" "tests"] + ]; + }; +} diff --git a/lib/default.nix b/lib/default.nix new file mode 100644 index 0000000..ae29377 --- /dev/null +++ b/lib/default.nix @@ -0,0 +1,25 @@ +{ + pkgs, + lib ? pkgs.lib, + ... +}: let + inherit (lib) evalModules; + + soonix_lib = import ./lib.nix {inherit pkgs lib;}; +in rec { + inherit (soonix_lib) engines buildAllFiles; + + module = ./module.nix; + devshellModule = ./devshellModule.nix; + + make = userConfig: + evalModules { + specialArgs = {inherit pkgs;}; + modules = [ + module + userConfig + ]; + }; + + mkShellHook = userConfig: (make userConfig).config.shellHook; +} diff --git a/lib/devshellModule.nix b/lib/devshellModule.nix new file mode 100644 index 0000000..91a90e9 --- /dev/null +++ b/lib/devshellModule.nix @@ -0,0 +1,23 @@ +{ + pkgs, + lib, + config, + ... +}: let + inherit (lib) mkOption types; + soonixModule = ./module.nix; +in { + options.soonix = mkOption { + type = types.submodule { + # propagate pkgs to the soonix module + _module.args.pkgs = pkgs; + imports = [soonixModule]; + }; + default = {}; + }; + + config.enterShellCommands.soonix = { + text = config.soonix.shellHook; + deps = ["env"]; + }; +} diff --git a/lib/flake.nix b/lib/flake.nix new file mode 100644 index 0000000..68ccde5 --- /dev/null +++ b/lib/flake.nix @@ -0,0 +1,6 @@ +{ + outputs = i: { + lib = import ./.; + devshellModule = ./devshellModule.nix; + }; +} diff --git a/lib/lib.nix b/lib/lib.nix new file mode 100644 index 0000000..5fb4b3b --- /dev/null +++ b/lib/lib.nix @@ -0,0 +1,101 @@ +{ + pkgs, + lib, + ... +}: let + inherit (pkgs) writeText writeTextFile runCommand formats; + + engines = { + nix = { + name, + opts, + data, + }: let + format = opts.format or "json"; + formatOpts = builtins.removeAttrs opts ["format"]; + formatter = formats.${format} formatOpts; + in + formatter.generate name data; + + string = { + name, + opts, + data, + }: let + executable = opts.executable or false; + in + writeTextFile { + inherit name executable; + text = data; + }; + + # only a passthru + derivation = {data, ...}: data; + + gotmpl = { + name, + opts, + data, + }: let + inherit (opts) template; + gomplate = opts.gomplate or pkgs.gomplate; + in + runCommand name { + buildInputs = [gomplate]; + passAsFile = ["dataJson"]; + dataJson = builtins.toJSON data; + } '' + gomplate \ + -c ".=$dataJsonPath?type=application/json" \ + -f "${template}" \ + -o "$out" + ''; + + jinja = { + name, + opts, + data, + }: let + inherit (opts) template; + python = opts.python or pkgs.python3; + dataJson = writeText "template-data.json" (builtins.toJSON data); + renderScript = + writeText "render.py" + # py + '' + import json + import sys + from jinja2 import Template + + with open('${dataJson}', 'r') as f: + data = json.load(f) + + with open('${template}', 'r') as f: + template_str = f.read() + + template = Template(template_str) + print(template.render(**data)) + ''; + in + runCommand name { + buildInputs = [python python.pkgs.jinja2]; + } '' + python ${renderScript} > $out + ''; + }; + + buildAllFiles = files: + runCommand "soonix-files" {} '' + mkdir -p $out + ${lib.concatMapStringsSep "\n" (file: + # sh + '' + target_dir="$out/$(dirname "${file.path}")" + mkdir -p "$target_dir" + ln -sf "${file.src}" "$out/${file.path}" + '') + files} + ''; +in { + inherit engines buildAllFiles; +} diff --git a/lib/module.nix b/lib/module.nix new file mode 100644 index 0000000..63f89a7 --- /dev/null +++ b/lib/module.nix @@ -0,0 +1,256 @@ +{ + pkgs, + config, + lib, + ... +}: let + inherit (lib) types mkOption concatMapStringsSep; + soonix_lib = import ./. {inherit pkgs;}; + inherit (soonix_lib) engines buildAllFiles; +in { + options = { + hooks = mkOption { + type = types.attrsOf (types.submodule ({ + name, + config, + ... + }: { + options = { + name = mkOption { + type = types.str; + internal = true; + default = name; + }; + + output = mkOption { + type = types.str; + description = "The relative path where the generated file should be placed"; + }; + + generator = mkOption { + type = types.enum ["nix" "string" "derivation" "gotmpl" "jinja" "template"]; + description = "Which engine to use for content generation"; + }; + + data = mkOption { + type = types.anything; + description = "The input data for the chosen generator"; + }; + + opts = mkOption { + type = types.attrs; + default = {}; + description = "Generator-specific options"; + }; + + hook = mkOption { + type = types.submodule { + options = { + mode = mkOption { + type = types.enum ["link" "copy"]; + default = "link"; + description = "How the file should be managed (link or copy)"; + }; + + gitignore = mkOption { + type = types.bool; + default = true; + description = "Whether to add the output path to .gitignore"; + }; + + extra = mkOption { + type = types.str; + default = ""; + description = "Additional bash commands to execute after file operation"; + }; + }; + }; + default = {}; + description = "Hook-specific options for file management"; + }; + + generatedDerivation = mkOption { + type = types.package; + internal = true; + readOnly = true; + description = "The generated derivation for this file"; + }; + }; + + config = { + generatedDerivation = + (engines.${config.generator} or (throw "Generator ${config.generator} not found")) + { + inherit (config) opts data name; + }; + }; + })); + default = {}; + description = "Configuration hooks for file generation and management"; + }; + + shellHook = mkOption { + type = types.str; + readOnly = true; + description = "Generated shell hook script for managing all files"; + }; + shellHookFile = mkOption { + type = types.package; + readOnly = true; + description = "Generated shell hook script for managing all files"; + }; + + finalFiles = mkOption { + type = types.package; + readOnly = true; + description = "Aggregated derivation containing all managed files"; + }; + }; + + config = let + hooks = config.hooks; + hookNames = builtins.attrNames hooks; + + runHooks = concatMapStringsSep "\n" (hookName: let + hook = hooks.${hookName}; + modes = { + link = + # sh + '' + if [[ ! -L "${hook.output}" ]] || [[ "$(readlink "${hook.output}")" != "${hook.generatedDerivation}" ]]; then + _soonix_log "info" "${hookName}" "Creating symlink: ${hook.output} -> ${hook.generatedDerivation}" + mkdir -p "$(dirname "${hook.output}")" + ln -sf "${hook.generatedDerivation}" "${hook.output}" + _changed=true + else + _soonix_log "info" "${hookName}" "Symlink up to date: ${hook.output}" + fi + ''; + copy = + # sh + '' + if [[ ! -f "${hook.output}" ]] || ! cmp -s "${hook.generatedDerivation}" "${hook.output}"; then + _soonix_log "info" "${hookName}" "Copying file: ${hook.generatedDerivation} -> ${hook.output}" + mkdir -p "$(dirname "${hook.output}")" + cp "${hook.generatedDerivation}" "${hook.output}" + _changed=true + else + _soonix_log "info" "${hookName}" "File up to date: ${hook.output}" + fi + ''; + }; + + optionalGitignore = + if hook.hook.gitignore + then '' + _soonix_add_to_gitignore "${hook.output}" + '' + else ""; + in + # sh + '' + # Process hook: ${hookName} + while IFS= read -r line; do + case "$line" in + UPDATED) _soonix_updated+=("${hookName}") ;; + UPTODATE) _soonix_uptodate+=("${hookName}") ;; + *) echo "$line" ;; + esac + done < <( + set -euo pipefail + _changed=false + + ${modes.${hook.hook.mode} or (throw "Mode ${hook.hook.mode} doesnt exist")} + + # Add to gitignore if requested + ${optionalGitignore} + + # Run extra commands if file changed + if [[ "$_changed" == "true" && -n "${hook.hook.extra}" ]]; then + _soonix_log "info" "${hookName}" "Running extra command: ${hook.hook.extra}" + eval "${hook.hook.extra}" + fi + + if [[ "$_changed" == "true" ]]; then + echo "UPDATED" + else + echo "UPTODATE" + fi + ) || { + _soonix_log "error" "${hookName}" "Failed to process hook" + _soonix_failed+=("${hookName}") + } + '') + hookNames; + + generatedShellHook = + # sh + '' + _soonix_log() { + local level="$1" + local hook="$2" + local message="$3" + [[ "''${SOONIX_LOG-}" == "true" ]] && echo "$level [$hook]: $message" || true + } + + _soonix_add_to_gitignore() { + local file="$1" + local gitignore=".gitignore" + + if [[ ! -f "$gitignore" ]]; then + touch "$gitignore" + fi + + # Check if file is already in gitignore + if ! grep -Fxq "$file" "$gitignore"; then + # Add sentinel comments if not present + if ! grep -q "# soonix" "$gitignore"; then + echo "" >> "$gitignore" + echo "# soonix" >> "$gitignore" + echo "# end soonix" >> "$gitignore" + fi + + # Insert the file path before the end comment + ${pkgs.gnused}/bin/sed -i "/# end soonix/i $file" "$gitignore" + fi + } + + _soonix_updated=() + _soonix_failed=() + _soonix_uptodate=() + + ${runHooks} + + echo -n "[soonix] " >&2 + if [[ ''${#_soonix_updated[@]} -gt 0 ]]; then + echo -n "[updated: ''${_soonix_updated[*]}] " >&2 + fi + + if [[ ''${#_soonix_uptodate[@]} -gt 0 ]]; then + echo -n "[unchanged: ''${_soonix_uptodate[*]}] " >&2 + fi + + if [[ ''${#_soonix_failed[@]} -gt 0 ]]; then + echo "[failed: ''${_soonix_failed[*]}]" >&2 + exit 1 + else + echo "" + fi + ''; + + allFiles = + lib.mapAttrsToList (name: hook: { + src = hook.generatedDerivation; + path = hook.output; + }) + hooks; + in rec { + # nothing to do if no hooks exist + shellHook = + if (builtins.length hookNames > 0) + then generatedShellHook + else ""; + shellHookFile = pkgs.writeShellScript "shellHook" shellHook; + finalFiles = buildAllFiles allFiles; + }; +} diff --git a/nix/repo/devShells.nix b/nix/repo/devShells.nix new file mode 100644 index 0000000..eb29994 --- /dev/null +++ b/nix/repo/devShells.nix @@ -0,0 +1,25 @@ +{inputs, ...}: let + inherit (inputs) pkgs devshell soonix; +in { + default = devshell.mkShell { + imports = [soonix.devshellModule]; + packages = [ + pkgs.alejandra + pkgs.nil + ]; + + soonix.hooks.test = { + output = "test.yaml"; + generator = "nix"; + data = { + name = "soonix-test"; + version = "1.0.0"; + }; + opts.format = "yaml"; + hook = { + mode = "copy"; + gitignore = true; + }; + }; + }; +} diff --git a/nix/repo/flake.lock b/nix/repo/flake.lock new file mode 100644 index 0000000..38a8c55 --- /dev/null +++ b/nix/repo/flake.lock @@ -0,0 +1,46 @@ +{ + "nodes": { + "devshell-lib": { + "locked": { + "dir": "lib", + "lastModified": 1755673398, + "narHash": "sha256-51MmR+Eo1+bKDd/Ss77wwTqi4yAR2xgmyCSEbKWSpj0=", + "owner": "rensa-nix", + "repo": "devshell", + "rev": "e76bef387e8a4574f9b6d37b1a424e706491af08", + "type": "gitlab" + }, + "original": { + "dir": "lib", + "owner": "rensa-nix", + "repo": "devshell", + "type": "gitlab" + } + }, + "nixtest-lib": { + "locked": { + "dir": "lib", + "lastModified": 1753957623, + "narHash": "sha256-kdImwKx57N0QL8HPUUb5ADwXFgSjaNOk39b/eKlzyTo=", + "owner": "TECHNOFAB", + "repo": "nixtest", + "rev": "22b43c9fe83be73c3f0648bbb54bc3c1cf7f96df", + "type": "gitlab" + }, + "original": { + "dir": "lib", + "owner": "TECHNOFAB", + "repo": "nixtest", + "type": "gitlab" + } + }, + "root": { + "inputs": { + "devshell-lib": "devshell-lib", + "nixtest-lib": "nixtest-lib" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/nix/repo/flake.nix b/nix/repo/flake.nix new file mode 100644 index 0000000..e2b6bf6 --- /dev/null +++ b/nix/repo/flake.nix @@ -0,0 +1,13 @@ +{ + inputs = { + devshell-lib.url = "gitlab:rensa-nix/devshell?dir=lib"; + nixtest-lib.url = "gitlab:TECHNOFAB/nixtest?dir=lib"; + }; + outputs = i: + i + // { + ntlib = i.nixtest-lib.lib {inherit (i.parent) pkgs;}; + devshell = i.devshell-lib.lib {inherit (i.parent) pkgs;}; + soonix = import "${i.parent.self}/lib" {inherit (i.parent) pkgs;}; + }; +} diff --git a/nix/repo/tests.nix b/nix/repo/tests.nix new file mode 100644 index 0000000..b551515 --- /dev/null +++ b/nix/repo/tests.nix @@ -0,0 +1,10 @@ +{inputs, ...}: let + inherit (inputs) pkgs ntlib soonix; +in { + tests = ntlib.mkNixtest { + modules = ntlib.autodiscover {dir = "${inputs.self}/tests";}; + args = { + inherit ntlib soonix pkgs; + }; + }; +} diff --git a/tests/fixtures/gotmpl_template b/tests/fixtures/gotmpl_template new file mode 100644 index 0000000..9033ed5 --- /dev/null +++ b/tests/fixtures/gotmpl_template @@ -0,0 +1 @@ +Hello {{ .hello }} diff --git a/tests/fixtures/jinja_template b/tests/fixtures/jinja_template new file mode 100644 index 0000000..a4d8ee1 --- /dev/null +++ b/tests/fixtures/jinja_template @@ -0,0 +1 @@ +Hello {{hello}} diff --git a/tests/soonix_test.nix b/tests/soonix_test.nix new file mode 100644 index 0000000..347bfe0 --- /dev/null +++ b/tests/soonix_test.nix @@ -0,0 +1,71 @@ +{ + ntlib, + soonix, + ... +}: let + hooks = { + test = { + output = "out/test.json"; + generator = "nix"; + data = { + name = "soonix-test"; + version = "1.0.0"; + }; + opts.format = "json"; + hook = { + mode = "copy"; + gitignore = false; + }; + }; + gomplate = { + output = "gotmpl"; + generator = "gotmpl"; + data.hello = "world"; + opts.template = ./fixtures/gotmpl_template; + }; + jinja = { + output = "jinja"; + generator = "jinja"; + data.hello = "world"; + opts.template = ./fixtures/jinja_template; + }; + }; +in { + suites."Soonix Tests" = { + pos = __curPos; + tests = [ + { + name = "files get generated correctly"; + type = "script"; + script = let + finalFiles = (soonix.make {inherit hooks;}).config.finalFiles; + in + # sh + '' + ${ntlib.helpers.scriptHelpers} + assert "-f ${finalFiles}/out/test.json" "should exist" + assert_file_contains ${finalFiles}/out/test.json "soonix-test" + + assert "-f ${finalFiles}/gotmpl" "should exist" + assert_file_contains ${finalFiles}/gotmpl "Hello world" + + assert "-f ${finalFiles}/jinja" "should exist" + assert_file_contains ${finalFiles}/jinja "Hello world" + ''; + } + { + name = "shell hook"; + type = "script"; + script = let + shellHook = ntlib.helpers.toPrettyFile (soonix.mkShellHook {inherit hooks;}); + in + # sh + '' + ${ntlib.helpers.scriptHelpers} + assert "-f ${shellHook}" "should exist" + assert_file_contains ${shellHook} "gomplate" + ''; + } + ]; + }; +}