chore: initial commit

This commit is contained in:
technofab 2025-08-25 16:45:22 +02:00
commit 25cc087b1d
No known key found for this signature in database
17 changed files with 741 additions and 0 deletions

2
.envrc Normal file
View file

@ -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

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.ren/
result
# soonix
test.yaml
# end soonix

61
README.md Normal file
View file

@ -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)

63
flake.lock generated Normal file
View file

@ -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
}

31
flake.nix Normal file
View file

@ -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"]
];
};
}

25
lib/default.nix Normal file
View file

@ -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;
}

23
lib/devshellModule.nix Normal file
View file

@ -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"];
};
}

6
lib/flake.nix Normal file
View file

@ -0,0 +1,6 @@
{
outputs = i: {
lib = import ./.;
devshellModule = ./devshellModule.nix;
};
}

101
lib/lib.nix Normal file
View file

@ -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;
}

256
lib/module.nix Normal file
View file

@ -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;
};
}

25
nix/repo/devShells.nix Normal file
View file

@ -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;
};
};
};
}

46
nix/repo/flake.lock generated Normal file
View file

@ -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
}

13
nix/repo/flake.nix Normal file
View file

@ -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;};
};
}

10
nix/repo/tests.nix Normal file
View file

@ -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;
};
};
}

1
tests/fixtures/gotmpl_template vendored Normal file
View file

@ -0,0 +1 @@
Hello {{ .hello }}

1
tests/fixtures/jinja_template vendored Normal file
View file

@ -0,0 +1 @@
Hello {{hello}}

71
tests/soonix_test.nix Normal file
View file

@ -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"
'';
}
];
};
}