Merge branch 'feat/cli' into 'main'

feat!: add cli with update and list subcommands + gitignore param

See merge request TECHNOFAB/soonix!3
This commit is contained in:
TECHNOFAB 2026-01-28 14:43:27 +01:00
commit 9d7257d48c
6 changed files with 245 additions and 105 deletions

View file

@ -16,7 +16,7 @@
}, },
"postUpgradeTasks": { "postUpgradeTasks": {
"commands": [ "commands": [
"nix-portable nix run .#soonix:update" "nix-portable nix run .#soonix -- update --skip-gitignore"
] ]
} }
} }

View file

@ -22,4 +22,5 @@ in rec {
}; };
mkShellHook = userConfig: (make userConfig).config.shellHook; mkShellHook = userConfig: (make userConfig).config.shellHook;
mkCLI = userConfig: (make userConfig).config.packages.soonix;
} }

View file

@ -4,7 +4,7 @@
lib, lib,
... ...
}: let }: let
inherit (lib) types mkOption concatMapStringsSep; inherit (lib) types mkOption concatMapStringsSep mapAttrsToList;
soonix_lib = import ./. {inherit pkgs;}; soonix_lib = import ./. {inherit pkgs;};
inherit (soonix_lib) engines buildAllFiles; inherit (soonix_lib) engines buildAllFiles;
in { in {
@ -146,15 +146,14 @@ in {
Packages for updating the hooks without a devshell. (readonly) Packages for updating the hooks without a devshell. (readonly)
''; '';
example = { example = {
"soonix:update" = "<derivation>"; "soonix" = "<derivation>";
}; };
}; };
}; };
config = let config = let
hooks = config.hooks; hooks = config.hooks;
hookNames = builtins.attrNames hooks; # allow excluding gitignore since stuff like renovate can't use/commit it anyways
runHooks = concatMapStringsSep "\n" (hookName: let runHooks = concatMapStringsSep "\n" (hookName: let
hook = hooks.${hookName}; hook = hooks.${hookName};
modes = { modes = {
@ -163,8 +162,12 @@ in {
'' ''
if [[ ! -L "${hook.output}" ]] || [[ "$(readlink "${hook.output}")" != "${hook.generatedDerivation}" ]]; then if [[ ! -L "${hook.output}" ]] || [[ "$(readlink "${hook.output}")" != "${hook.generatedDerivation}" ]]; then
_soonix_log "info" "${hookName}" "Creating symlink: ${hook.output} -> ${hook.generatedDerivation}" _soonix_log "info" "${hookName}" "Creating symlink: ${hook.output} -> ${hook.generatedDerivation}"
mkdir -p "$(dirname "${hook.output}")" if [[ "$CHECK_MODE" != "true" ]]; then
ln -sf "${hook.generatedDerivation}" "${hook.output}" mkdir -p "$(dirname "${hook.output}")"
ln -sf "${hook.generatedDerivation}" "${hook.output}"
else
_soonix_log "info" "${hookName}" "Would create symlink: ${hook.output} -> ${hook.generatedDerivation}"
fi
_changed=true _changed=true
else else
_soonix_log "info" "${hookName}" "Symlink up to date: ${hook.output}" _soonix_log "info" "${hookName}" "Symlink up to date: ${hook.output}"
@ -175,10 +178,14 @@ in {
'' ''
if [[ ! -f "${hook.output}" ]] || ! cmp -s "${hook.generatedDerivation}" "${hook.output}"; then if [[ ! -f "${hook.output}" ]] || ! cmp -s "${hook.generatedDerivation}" "${hook.output}"; then
_soonix_log "info" "${hookName}" "Copying file: ${hook.generatedDerivation} -> ${hook.output}" _soonix_log "info" "${hookName}" "Copying file: ${hook.generatedDerivation} -> ${hook.output}"
mkdir -p "$(dirname "${hook.output}")" if [[ "$CHECK_MODE" != "true" ]]; then
# required since they're read only mkdir -p "$(dirname "${hook.output}")"
rm -f "${hook.output}" # required since they're read only
cp "${hook.generatedDerivation}" "${hook.output}" rm -f "${hook.output}"
cp "${hook.generatedDerivation}" "${hook.output}"
else
_soonix_log "info" "${hookName}" "Would copy file: ${hook.generatedDerivation} -> ${hook.output}"
fi
_changed=true _changed=true
else else
_soonix_log "info" "${hookName}" "File up to date: ${hook.output}" _soonix_log "info" "${hookName}" "File up to date: ${hook.output}"
@ -192,104 +199,128 @@ in {
_soonix_add_to_gitignore "${hook.output}" _soonix_add_to_gitignore "${hook.output}"
'' ''
else ""; else "";
isGitignored =
if hook.hook.gitignore
then "true"
else "false";
in in
builtins.addErrorContext "[soonix] while generating script for ${hookName}"
# sh # sh
'' ''
# Process hook: ${hookName} # Process hook: ${hookName}
while IFS= read -r line; do # Skip if SKIP_GITIGNORE is set and this hook is gitignored
case "$line" in if [[ "$SKIP_GITIGNORE" == "true" && "${isGitignored}" == "true" ]]; then
UPDATED) _soonix_updated+=("${hookName}") ;; : # skip
UPTODATE) _soonix_uptodate+=("${hookName}") ;; else
*) echo "$line" ;; while IFS= read -r line; do
esac case "$line" in
done < <( UPDATED) _soonix_updated+=("${hookName}") ;;
set -euo pipefail UPTODATE) _soonix_uptodate+=("${hookName}") ;;
_changed=false *) echo "$line" ;;
esac
done < <(
set -euo pipefail
_changed=false
${modes.${hook.hook.mode} or (throw "Mode ${hook.hook.mode} doesnt exist")} ${modes.${hook.hook.mode} or (throw "Mode ${hook.hook.mode} doesnt exist")}
# Add to gitignore if requested if [[ "$CHECK_MODE" != "true" ]]; then
${optionalGitignore} # Add to gitignore if requested
${optionalGitignore}
# Run extra commands if file changed # Run extra commands if file changed
if [[ "$_changed" == "true" && -n "${hook.hook.extra}" ]]; then if [[ "$_changed" == "true" && -n "${hook.hook.extra}" ]]; then
_soonix_log "info" "${hookName}" "Running extra command: ${hook.hook.extra}" _soonix_log "info" "${hookName}" "Running extra command: ${hook.hook.extra}"
eval "${hook.hook.extra}" eval "${hook.hook.extra}"
fi 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 fi
# Insert the file path before the end comment if [[ "$_changed" == "true" ]]; then
${pkgs.gnused}/bin/sed -i "/# end soonix/i /$file" "$gitignore" echo "UPDATED"
else
echo "UPTODATE"
fi
) || {
_soonix_log "error" "${hookName}" "Failed to process hook"
_soonix_failed+=("${hookName}")
}
fi
'')
(builtins.attrNames hooks);
generateShellHook =
builtins.addErrorContext "[soonix] while generating shell hook"
# sh
''
function _soonix() {
CHECK_MODE=''${CHECK_MODE:-false}
SKIP_GITIGNORE=''${SKIP_GITIGNORE:-false}
_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}
local output=$'\E[msoonix:\E[38;5;8m'
local status=0
if [[ ''${#_soonix_updated[@]} -gt 0 ]]; then
output="$output [updated: ''${_soonix_updated[*]}]" >&2
if [[ "$CHECK_MODE" == "true" ]]; then
status=2
fi
fi
if [[ ''${#_soonix_uptodate[@]} -gt 0 ]]; then
output="$output [unchanged: ''${_soonix_uptodate[*]}]" >&2
fi
if [[ ''${#_soonix_failed[@]} -gt 0 ]]; then
output="$output [failed: ''${_soonix_failed[*]}]" >&2
status=1
fi
printf "%s\E[m\n" "$output" >&2
if [[ $status -ne 0 ]]; then
exit $status
fi fi
} }
_soonix
_soonix_updated=() unset _soonix
_soonix_failed=()
_soonix_uptodate=()
${runHooks}
local output=$'\E[msoonix:\E[38;5;8m'
local status=0
if [[ ''${#_soonix_updated[@]} -gt 0 ]]; then
output="$output [updated: ''${_soonix_updated[*]}]" >&2
fi
if [[ ''${#_soonix_uptodate[@]} -gt 0 ]]; then
output="$output [unchanged: ''${_soonix_uptodate[*]}]" >&2
fi
if [[ ''${#_soonix_failed[@]} -gt 0 ]]; then
output="$output [failed: ''${_soonix_failed[*]}]" >&2
status=1
fi
printf "%s\E[m\n" "$output" >&2
if [[ $status -eq 1 ]]; then
exit 1
fi
''; '';
allFiles = allFiles =
lib.mapAttrsToList (name: hook: { mapAttrsToList (name: hook: {
src = hook.generatedDerivation; src = hook.generatedDerivation;
path = hook.output; path = hook.output;
}) })
@ -297,8 +328,8 @@ in {
in rec { in rec {
# nothing to do if no hooks exist # nothing to do if no hooks exist
shellHook = shellHook =
if (builtins.length hookNames > 0) if (builtins.length (builtins.attrNames config.hooks) > 0)
then generatedShellHook then generateShellHook
else ""; else "";
shellHookFile = pkgs.writeShellScript "shellHook" shellHook; shellHookFile = pkgs.writeShellScript "shellHook" shellHook;
devshellModule = { devshellModule = {
@ -307,11 +338,96 @@ in {
}; };
finalFiles = buildAllFiles allFiles; finalFiles = buildAllFiles allFiles;
# make it simpler to update the hooks without any devshell # make it simpler to update the hooks without any devshell
packages."soonix:update" = pkgs.writeShellScriptBin "soonix:update" '' packages = {
function _soonix() { soonix =
${shellHook} pkgs.writeShellScriptBin "soonix"
} # sh
_soonix ''
''; set -euo pipefail
SKIP_GITIGNORE=false
CHECK_MODE=false
COMMAND=""
show_help() {
cat << EOF
soonix - Declarative file management tool
USAGE:
soonix <COMMAND> [OPTIONS]
COMMANDS:
update Update all managed files
check Check if all files are up to date (dry-run)
list List all managed file targets
help Show this help message
OPTIONS:
--skip-gitignore Skip files that would be added to .gitignore
EXAMPLES:
soonix update # Update all files
soonix update --skip-gitignore # Update only non-gitignored files
soonix list # List all targets
soonix list --skip-gitignore # List only non-gitignored targets
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
update|check|list|help)
COMMAND="$1"
shift
;;
--skip-gitignore)
SKIP_GITIGNORE=true
shift
;;
*)
echo "Error: Unknown argument '$1'" >&2
echo ""
show_help
exit 1
;;
esac
done
if [[ -z "$COMMAND" ]]; then
echo "Error: No command specified" >&2
echo ""
show_help
exit 1
fi
case "$COMMAND" in
help)
show_help
;;
list)
${concatMapStringsSep "\n" (hookName: let
hook = hooks.${hookName};
in ''
if [[ "$SKIP_GITIGNORE" == "true" && "${
if hook.hook.gitignore
then "true"
else "false"
}" == "true" ]]; then
: # skip
else
echo "${hook.output}"
fi
'') (builtins.attrNames hooks)}
;;
update)
${generateShellHook}
;;
check)
CHECK_MODE=true
echo "Checking files..."
${generateShellHook}
;;
esac
'';
};
}; };
} }

View file

@ -41,8 +41,10 @@ in {
} }
{ {
name = "soonix"; name = "soonix";
files = "nix run .#soonix -- list --skip-gitignore";
stage_fixed = true; stage_fixed = true;
run = "nix run .#soonix:update"; # {files} is needed so lefthook git add's them
run = "nix run .#soonix -- update --skip-gitignore; #{files}";
} }
]; ];
}; };

View file

@ -14,7 +14,7 @@ in
data = { data = {
extends = ["config:recommended"]; extends = ["config:recommended"];
postUpgradeTasks.commands = [ postUpgradeTasks.commands = [
"nix-portable nix run .#soonix:update" "nix-portable nix run .#soonix -- update --skip-gitignore"
]; ];
lockFileMaintenance = { lockFileMaintenance = {
enabled = true; enabled = true;

View file

@ -78,6 +78,27 @@ in {
assert_file_contains ${shellHook} "gomplate" assert_file_contains ${shellHook} "gomplate"
''; '';
} }
{
name = "packages";
type = "script";
script = let
conf = (soonix.make {inherit hooks;}).config;
soonixBin = conf.packages.soonix + "/bin/soonix";
in
# sh
''
${ntlib.helpers.path [pkgs.gnugrep]}
${ntlib.helpers.scriptHelpers}
assert -f "${soonixBin}" "should exist"
assert_file_contains "${soonixBin}" "gotmpl"
assert_file_contains "${soonixBin}" "test.json"
assert_file_contains "${soonixBin}" "SKIP_GITIGNORE"
assert_file_contains "${soonixBin}" "CHECK_MODE"
'';
}
]; ];
}; };
} }