diff --git a/README.md b/README.md index a9b5a15..d22ba9f 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ ## Use the template ```bash -nix flake new myapp -t 'git+https://git.dgren.dev/eric/nix-flake-lib?ref=v3.0.0#default' --refresh +nix flake new myapp -t 'git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.0.0#default' --refresh ``` ## Use the library @@ -23,7 +23,7 @@ nix flake new myapp -t 'git+https://git.dgren.dev/eric/nix-flake-lib?ref=v3.0.0# Add this flake input: ```nix -inputs.repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=v3.0.0"; +inputs.repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.0.0"; inputs.repo-lib.inputs.nixpkgs.follows = "nixpkgs"; ``` @@ -49,10 +49,10 @@ outputs = { self, nixpkgs, repo-lib, ... }: perSystem = { pkgs, system, ... }: { tools = [ - (repo-lib.lib.tools.fromPackage { + (repo-lib.lib.tools.fromCommand { name = "Nix"; - package = pkgs.nix; version.args = [ "--version" ]; + command = "nix"; }) ]; @@ -73,7 +73,7 @@ outputs = { self, nixpkgs, repo-lib, ... }: ## Tool banners -Tools are declared once, from packages. They are added to the shell automatically and rendered in the startup banner. +Tools are declared once. Package-backed tools are added to the shell automatically, and both package-backed and command-backed tools are rendered in the startup banner. ```nix (repo-lib.lib.tools.fromPackage { @@ -86,6 +86,16 @@ Tools are declared once, from packages. They are added to the shell automaticall Required tools fail shell startup if their version probe fails. This keeps banner output honest instead of silently hiding misconfiguration. +When a tool should come from the host environment instead of `nixpkgs`, use `fromCommand`: + +```nix +(repo-lib.lib.tools.fromCommand { + name = "Nix"; + command = "nix"; + version.args = [ "--version" ]; +}) +``` + ## Purity model The default path is pure: declare tools and packages in Nix, then let `mkRepo` assemble the shell. diff --git a/flake.nix b/flake.nix index cdf4834..240a5d0 100644 --- a/flake.nix +++ b/flake.nix @@ -36,21 +36,21 @@ replace = { path = "template/flake.nix"; regex = ''^([[:space:]]*repo-lib\.url = ")git\+https://git\.dgren\.dev/eric/nix-flake-lib[^"]*(";)''; - replacement = ''\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=$FULL_TAG\2''; + replacement = ''\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/$FULL_TAG\2''; }; } { replace = { path = "README.md"; regex = ''(nix flake new myapp -t ')git\+https://git\.dgren\.dev/eric/nix-flake-lib[^']*(#default' --refresh)''; - replacement = ''\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=$FULL_TAG\2''; + replacement = ''\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/$FULL_TAG\2''; }; } { replace = { path = "README.md"; regex = ''^([[:space:]]*inputs\.repo-lib\.url = ")git\+https://git\.dgren\.dev/eric/nix-flake-lib[^"]*(";)''; - replacement = ''\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=$FULL_TAG\2''; + replacement = ''\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/$FULL_TAG\2''; }; } ]; @@ -64,10 +64,17 @@ }: { tools = [ - (repoLib.tools.fromPackage { + (repoLib.tools.fromCommand { name = "Nix"; - package = pkgs.nix; - version.args = [ "--version" ]; + command = "nix"; + version = { + args = [ "--version" ]; + group = 1; + }; + banner = { + color = "BLUE"; + icon = ""; + }; }) ]; @@ -90,7 +97,6 @@ gnused coreutils gnugrep - nix perl ]; } @@ -98,7 +104,6 @@ export REPO_LIB_ROOT=${./.} export NIXPKGS_FLAKE_PATH=${nixpkgs} export HOME="$TMPDIR" - export NIX_CONFIG="experimental-features = nix-command flakes" ${pkgs.bash}/bin/bash ${./tests/release.sh} touch "$out" ''; diff --git a/packages/release/release.sh b/packages/release/release.sh index a868f2f..605d141 100644 --- a/packages/release/release.sh +++ b/packages/release/release.sh @@ -6,6 +6,8 @@ ROOT_DIR="$(git rev-parse --show-toplevel)" GITLINT_FILE="$ROOT_DIR/.gitlint" START_HEAD="" CREATED_TAG="" +VERSION_META_LINES=() +VERSION_META_EXPORT_NAMES=() # ── logging ──────────────────────────────────────────────────────────────── @@ -168,6 +170,119 @@ compute_full_version() { export BASE_VERSION CHANNEL PRERELEASE_NUM FULL_VERSION FULL_TAG } +meta_env_name() { + local key="$1" + key="${key//[^[:alnum:]]/_}" + key="$(printf '%s' "$key" | tr '[:lower:]' '[:upper:]')" + printf 'VERSION_META_%s\n' "$key" +} + +clear_version_meta_exports() { + local export_name + for export_name in "${VERSION_META_EXPORT_NAMES[@]:-}"; do + unset "$export_name" + done + VERSION_META_EXPORT_NAMES=() +} + +load_version_metadata() { + VERSION_META_LINES=() + [[ ! -f "$ROOT_DIR/VERSION" ]] && return 0 + + while IFS= read -r line || [[ -n $line ]]; do + VERSION_META_LINES+=("$line") + done < <(tail -n +4 "$ROOT_DIR/VERSION" 2>/dev/null || true) +} + +export_version_metadata() { + clear_version_meta_exports + + local line key value export_name + for line in "${VERSION_META_LINES[@]:-}"; do + [[ $line != *=* ]] && continue + key="${line%%=*}" + value="${line#*=}" + [[ -z $key ]] && continue + export_name="$(meta_env_name "$key")" + printf -v "$export_name" '%s' "$value" + export "${export_name?}=$value" + VERSION_META_EXPORT_NAMES+=("$export_name") + done +} + +write_version_file() { + local channel_to_write="$1" + local n_to_write="$2" + { + printf '%s\n%s\n%s\n' "$BASE_VERSION" "$channel_to_write" "$n_to_write" + local line + for line in "${VERSION_META_LINES[@]:-}"; do + printf '%s\n' "$line" + done + } >"$ROOT_DIR/VERSION" +} + +version_meta_get() { + local key="${1-}" + local line + for line in "${VERSION_META_LINES[@]:-}"; do + if [[ $line == "$key="* ]]; then + printf '%s\n' "${line#*=}" + return 0 + fi + done + return 1 +} + +version_meta_set() { + local key="${1-}" + local value="${2-}" + [[ -z $key ]] && echo "Error: version_meta_set requires a key" >&2 && exit 1 + + local updated=0 + local index + for index in "${!VERSION_META_LINES[@]}"; do + if [[ ${VERSION_META_LINES[index]} == "$key="* ]]; then + VERSION_META_LINES[index]="$key=$value" + updated=1 + break + fi + done + + if [[ $updated -eq 0 ]]; then + VERSION_META_LINES+=("$key=$value") + fi + + export_version_metadata + version_meta_write +} + +version_meta_unset() { + local key="${1-}" + [[ -z $key ]] && echo "Error: version_meta_unset requires a key" >&2 && exit 1 + + local filtered=() + local line + for line in "${VERSION_META_LINES[@]:-}"; do + [[ $line == "$key="* ]] && continue + filtered+=("$line") + done + VERSION_META_LINES=("${filtered[@]}") + + export_version_metadata + version_meta_write +} + +version_meta_write() { + local channel_to_write="$CHANNEL" + local n_to_write="${PRERELEASE_NUM:-1}" + if [[ $channel_to_write == "stable" || -z $channel_to_write ]]; then + channel_to_write="stable" + n_to_write="0" + fi + write_version_file "$channel_to_write" "$n_to_write" +} + # ── gitlint ──────────────────────────────────────────────────────────────── get_gitlint_title_regex() { @@ -205,6 +320,8 @@ run_release_steps() { # and never contaminates the stdout of do_read_version. init_version_file() { if [[ -f "$ROOT_DIR/VERSION" ]]; then + load_version_metadata + export_version_metadata return 0 fi @@ -233,11 +350,16 @@ init_version_file() { n_to_write="0" fi - printf '%s\n%s\n%s\n' "$BASE_VERSION" "$channel_to_write" "$n_to_write" >"$ROOT_DIR/VERSION" + VERSION_META_LINES=() + write_version_file "$channel_to_write" "$n_to_write" + export_version_metadata log "Initialized $ROOT_DIR/VERSION from highest tag: v$highest_tag" } do_read_version() { + load_version_metadata + export_version_metadata + local base_line channel_line n_line base_line="$(sed -n '1p' "$ROOT_DIR/VERSION" | tr -d '\r')" channel_line="$(sed -n '2p' "$ROOT_DIR/VERSION" | tr -d '\r')" @@ -257,7 +379,8 @@ do_write_version() { channel_to_write="stable" n_to_write="0" fi - printf '%s\n%s\n%s\n' "$BASE_VERSION" "$channel_to_write" "$n_to_write" >"$ROOT_DIR/VERSION" + write_version_file "$channel_to_write" "$n_to_write" + export_version_metadata } # ── user-provided hook ───────────────────────────────────────────────────── diff --git a/packages/repo-lib/lib.nix b/packages/repo-lib/lib.nix index 8f27ca8..cfbf4f8 100644 --- a/packages/repo-lib/lib.nix +++ b/packages/repo-lib/lib.nix @@ -44,11 +44,37 @@ let sanitizeName = name: lib.strings.sanitizeDerivationName name; + defaultShellBanner = { + style = "simple"; + icon = "🚀"; + title = "Dev shell ready"; + titleColor = "GREEN"; + subtitle = ""; + subtitleColor = "GRAY"; + borderColor = "BLUE"; + }; + + normalizeShellBanner = + rawBanner: + let + banner = defaultShellBanner // rawBanner; + in + if + !(builtins.elem banner.style [ + "simple" + "pretty" + ]) + then + throw "repo-lib: config.shell.banner.style must be one of simple or pretty" + else + banner; + normalizeStrictTool = pkgs: tool: let version = { args = [ "--version" ]; + match = null; regex = null; group = 0; line = 1; @@ -56,22 +82,26 @@ let // (tool.version or { }); banner = { color = "YELLOW"; + icon = null; + iconColor = null; } // (tool.banner or { }); executable = - if tool ? exe && tool.exe != null then + if tool ? command && tool.command != null then + tool.command + else if tool ? exe && tool.exe != null then "${lib.getExe' tool.package tool.exe}" else "${lib.getExe tool.package}"; in - if !(tool ? package) then - throw "repo-lib: tool '${tool.name or ""}' is missing 'package'" + if !(tool ? command && tool.command != null) && !(tool ? package) then + throw "repo-lib: tool '${tool.name or ""}' is missing 'package' or 'command'" else { kind = "strict"; inherit executable version banner; name = tool.name; - package = tool.package; + package = tool.package or null; required = tool.required or true; }; @@ -87,6 +117,8 @@ let versionCommand = tool.versionCmd or "--version"; banner = { color = tool.color or "YELLOW"; + icon = tool.icon or null; + iconColor = tool.iconColor or null; }; required = tool.required or false; }; @@ -248,7 +280,7 @@ let preCommitShellHook, shellEnvScript, bootstrap, - toolBannerScript, + shellBannerScript, extraShellText, toolLabelWidth, }: @@ -261,7 +293,7 @@ let "@TOOL_LABEL_WIDTH@" "@SHELL_ENV_SCRIPT@" "@BOOTSTRAP@" - "@TOOL_BANNER_SCRIPT@" + "@SHELL_BANNER_SCRIPT@" "@EXTRA_SHELL_TEXT@" ] [ @@ -269,7 +301,7 @@ let (toString toolLabelWidth) shellEnvScript bootstrap - toolBannerScript + shellBannerScript extraShellText ] template; @@ -286,6 +318,7 @@ let env = { }; extraShellText = ""; bootstrap = ""; + banner = defaultShellBanner; }, checkSpecs ? { }, rawHookEntries ? { }, @@ -345,30 +378,95 @@ let ) shellConfig.env ); - toolBannerScript = lib.concatMapStrings ( - tool: - if tool.kind == "strict" then + banner = normalizeShellBanner (shellConfig.banner or { }); + + shellBannerScript = + if banner.style == "pretty" then '' - repo_lib_probe_tool \ - ${lib.escapeShellArg tool.name} \ - ${lib.escapeShellArg tool.banner.color} \ - ${lib.escapeShellArg (if tool.required then "1" else "0")} \ - ${lib.escapeShellArg (toString tool.version.line)} \ - ${lib.escapeShellArg (toString tool.version.group)} \ - ${lib.escapeShellArg (tool.version.regex or "")} \ - ${lib.escapeShellArg tool.executable} \ - ${lib.escapeShellArgs tool.version.args} + repo_lib_print_pretty_header \ + ${lib.escapeShellArg banner.borderColor} \ + ${lib.escapeShellArg banner.titleColor} \ + ${lib.escapeShellArg banner.icon} \ + ${lib.escapeShellArg banner.title} \ + ${lib.escapeShellArg banner.subtitleColor} \ + ${lib.escapeShellArg banner.subtitle} + '' + + lib.concatMapStrings ( + tool: + if tool.kind == "strict" then + '' + repo_lib_print_pretty_tool \ + ${lib.escapeShellArg banner.borderColor} \ + ${lib.escapeShellArg tool.name} \ + ${lib.escapeShellArg tool.banner.color} \ + ${lib.escapeShellArg (if tool.banner.icon == null then "" else tool.banner.icon)} \ + ${lib.escapeShellArg (if tool.banner.iconColor == null then "" else tool.banner.iconColor)} \ + ${lib.escapeShellArg (if tool.required then "1" else "0")} \ + ${lib.escapeShellArg (toString tool.version.line)} \ + ${lib.escapeShellArg (toString tool.version.group)} \ + ${lib.escapeShellArg (if tool.version.regex == null then "" else tool.version.regex)} \ + ${lib.escapeShellArg (if tool.version.match == null then "" else tool.version.match)} \ + ${lib.escapeShellArg tool.executable} \ + ${lib.escapeShellArgs tool.version.args} + '' + else + '' + repo_lib_print_pretty_legacy_tool \ + ${lib.escapeShellArg banner.borderColor} \ + ${lib.escapeShellArg tool.name} \ + ${lib.escapeShellArg tool.banner.color} \ + ${lib.escapeShellArg (if tool.banner.icon == null then "" else tool.banner.icon)} \ + ${lib.escapeShellArg (if tool.banner.iconColor == null then "" else tool.banner.iconColor)} \ + ${lib.escapeShellArg (if tool.required then "1" else "0")} \ + ${lib.escapeShellArg tool.command} \ + ${lib.escapeShellArg tool.versionCommand} + '' + ) tools + + '' + repo_lib_print_pretty_footer \ + ${lib.escapeShellArg banner.borderColor} '' else '' - repo_lib_probe_legacy_tool \ - ${lib.escapeShellArg tool.name} \ - ${lib.escapeShellArg tool.banner.color} \ - ${lib.escapeShellArg (if tool.required then "1" else "0")} \ - ${lib.escapeShellArg tool.command} \ - ${lib.escapeShellArg tool.versionCommand} + repo_lib_print_simple_header \ + ${lib.escapeShellArg banner.titleColor} \ + ${lib.escapeShellArg banner.icon} \ + ${lib.escapeShellArg banner.title} \ + ${lib.escapeShellArg banner.subtitleColor} \ + ${lib.escapeShellArg banner.subtitle} '' - ) tools; + + lib.concatMapStrings ( + tool: + if tool.kind == "strict" then + '' + repo_lib_print_simple_tool \ + ${lib.escapeShellArg tool.name} \ + ${lib.escapeShellArg tool.banner.color} \ + ${lib.escapeShellArg (if tool.banner.icon == null then "" else tool.banner.icon)} \ + ${lib.escapeShellArg (if tool.banner.iconColor == null then "" else tool.banner.iconColor)} \ + ${lib.escapeShellArg (if tool.required then "1" else "0")} \ + ${lib.escapeShellArg (toString tool.version.line)} \ + ${lib.escapeShellArg (toString tool.version.group)} \ + ${lib.escapeShellArg (if tool.version.regex == null then "" else tool.version.regex)} \ + ${lib.escapeShellArg (if tool.version.match == null then "" else tool.version.match)} \ + ${lib.escapeShellArg tool.executable} \ + ${lib.escapeShellArgs tool.version.args} + '' + else + '' + repo_lib_print_simple_legacy_tool \ + ${lib.escapeShellArg tool.name} \ + ${lib.escapeShellArg tool.banner.color} \ + ${lib.escapeShellArg (if tool.banner.icon == null then "" else tool.banner.icon)} \ + ${lib.escapeShellArg (if tool.banner.iconColor == null then "" else tool.banner.iconColor)} \ + ${lib.escapeShellArg (if tool.required then "1" else "0")} \ + ${lib.escapeShellArg tool.command} \ + ${lib.escapeShellArg tool.versionCommand} + '' + ) tools + + '' + printf "\n" + ''; in { inherit pre-commit-check; @@ -378,7 +476,7 @@ let buildInputs = pre-commit-check.enabledPackages; shellHook = buildShellHook { preCommitShellHook = pre-commit-check.shellHook; - inherit toolLabelWidth shellEnvScript toolBannerScript; + inherit toolLabelWidth shellEnvScript shellBannerScript; bootstrap = shellConfig.bootstrap; extraShellText = shellConfig.extraShellText; }; @@ -411,6 +509,24 @@ rec { ; }; + fromCommand = + { + name, + command, + version ? { }, + banner ? { }, + required ? true, + }: + { + inherit + name + command + version + banner + required + ; + }; + simple = name: package: args: fromPackage { @@ -429,6 +545,7 @@ rec { extraShellText = ""; allowImpureBootstrap = false; bootstrap = ""; + banner = { }; }; formatting = { programs = { }; @@ -452,7 +569,13 @@ rec { if merged.shell.bootstrap != "" && !merged.shell.allowImpureBootstrap then throw "repo-lib: config.shell.bootstrap requires config.shell.allowImpureBootstrap = true" else - merged // { inherit release; }; + merged + // { + inherit release; + shell = merged.shell // { + banner = normalizeShellBanner merged.shell.banner; + }; + }; mkDevShell = { @@ -487,6 +610,7 @@ rec { extraShellText = extraShellHook; allowImpureBootstrap = true; bootstrap = preToolHook; + banner = defaultShellBanner; }; in if duplicateToolNames != [ ] then diff --git a/packages/repo-lib/shell-hook.sh b/packages/repo-lib/shell-hook.sh index 157a2f5..696f3be 100644 --- a/packages/repo-lib/shell-hook.sh +++ b/packages/repo-lib/shell-hook.sh @@ -16,107 +16,326 @@ BOLD=$'\033[1m' UNDERLINE=$'\033[4m' RESET=$'\033[0m' -repo_lib_probe_tool() { - local name="$1" - local color_name="$2" - local required="$3" - local line_no="$4" - local group_no="$5" - local regex="$6" - local executable="$7" - shift 7 +REPO_LIB_TOOL_VERSION="" +REPO_LIB_TOOL_ERROR="" + +repo_lib_capture_tool() { + local required="$1" + local line_no="$2" + local group_no="$3" + local regex="$4" + local match_regex="$5" + local executable="$6" + shift 6 - local color="${!color_name:-$YELLOW}" local output="" local selected="" local version="" + REPO_LIB_TOOL_VERSION="" + REPO_LIB_TOOL_ERROR="" + if ! output="$("$executable" "$@" 2>&1)"; then - printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "probe failed" + REPO_LIB_TOOL_ERROR="probe failed" printf "%s\n" "$output" >&2 - if [ "$required" = "1" ]; then - exit 1 - fi - return 0 + return 1 fi - selected="$(printf '%s\n' "$output" | sed -n "${line_no}p")" + if [ -n "$match_regex" ]; then + selected="$(printf '%s\n' "$output" | grep -E -m 1 "$match_regex" || true)" + else + selected="$(printf '%s\n' "$output" | sed -n "${line_no}p")" + fi selected="$(printf '%s' "$selected" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')" if [ -n "$regex" ]; then if [[ "$selected" =~ $regex ]]; then version="${BASH_REMATCH[$group_no]}" else - printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "version parse failed" + REPO_LIB_TOOL_ERROR="version parse failed" printf "%s\n" "$output" >&2 - if [ "$required" = "1" ]; then - exit 1 - fi - return 0 + return 1 fi else version="$selected" fi if [ -z "$version" ]; then - printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "empty version" + REPO_LIB_TOOL_ERROR="empty version" printf "%s\n" "$output" >&2 - if [ "$required" = "1" ]; then - exit 1 - fi - return 0 + return 1 fi - printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET %s%s$RESET\n" "${name}:" "$color" "$version" + REPO_LIB_TOOL_VERSION="$version" + return 0 } -repo_lib_probe_legacy_tool() { - local name="$1" - local color_name="$2" - local required="$3" - local command_name="$4" - local version_command="$5" +repo_lib_capture_legacy_tool() { + local required="$1" + local command_name="$2" + local version_command="$3" - local color="${!color_name:-$YELLOW}" local output="" local version="" + REPO_LIB_TOOL_VERSION="" + REPO_LIB_TOOL_ERROR="" + if ! command -v "$command_name" >/dev/null 2>&1; then - if [ "$required" = "1" ]; then - printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "missing command" - exit 1 - fi - return 0 + REPO_LIB_TOOL_ERROR="missing command" + return 1 fi if ! output="$(sh -c "$command_name $version_command" 2>&1)"; then - printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "probe failed" + REPO_LIB_TOOL_ERROR="probe failed" printf "%s\n" "$output" >&2 - if [ "$required" = "1" ]; then - exit 1 - fi - return 0 + return 1 fi version="$(printf '%s\n' "$output" | head -n 1 | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')" if [ -z "$version" ]; then - printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "empty version" + REPO_LIB_TOOL_ERROR="empty version" printf "%s\n" "$output" >&2 + return 1 + fi + + REPO_LIB_TOOL_VERSION="$version" + return 0 +} + +repo_lib_print_simple_header() { + local title_color_name="$1" + local icon="$2" + local title="$3" + local subtitle_color_name="$4" + local subtitle="$5" + + local title_color="${!title_color_name:-$GREEN}" + local subtitle_color="${!subtitle_color_name:-$GRAY}" + + printf "\n%s" "$title_color" + if [ -n "$icon" ]; then + printf "%s " "$icon" + fi + printf "%s%s" "$title" "$RESET" + if [ -n "$subtitle" ]; then + printf " %s%s%s" "$subtitle_color" "$subtitle" "$RESET" + fi + printf "\n\n" +} + +repo_lib_print_simple_tool() { + local name="$1" + local color_name="$2" + local icon="$3" + local icon_color_name="$4" + local required="$5" + local line_no="$6" + local group_no="$7" + local regex="$8" + local match_regex="$9" + local executable="${10}" + shift 10 + + local color="${!color_name:-$YELLOW}" + local effective_icon_color_name="$icon_color_name" + local icon_color="" + + if [ -z "$effective_icon_color_name" ]; then + effective_icon_color_name="$color_name" + fi + + if repo_lib_capture_tool "$required" "$line_no" "$group_no" "$regex" "$match_regex" "$executable" "$@"; then + icon_color="${!effective_icon_color_name:-$color}" + printf " " + if [ -n "$icon" ]; then + printf "%s%s%s " "$icon_color" "$icon" "$RESET" + fi + printf "$CYAN %-@TOOL_LABEL_WIDTH@s$RESET %s%s$RESET\n" "${name}:" "$color" "$REPO_LIB_TOOL_VERSION" + else + printf " " + if [ -n "$icon" ]; then + printf "%s%s%s " "$RED" "$icon" "$RESET" + fi + printf "$CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "$REPO_LIB_TOOL_ERROR" if [ "$required" = "1" ]; then exit 1 fi - return 0 + fi +} + +repo_lib_print_simple_legacy_tool() { + local name="$1" + local color_name="$2" + local icon="$3" + local icon_color_name="$4" + local required="$5" + local command_name="$6" + local version_command="$7" + + local color="${!color_name:-$YELLOW}" + local effective_icon_color_name="$icon_color_name" + local icon_color="" + + if [ -z "$effective_icon_color_name" ]; then + effective_icon_color_name="$color_name" fi - printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET %s%s$RESET\n" "${name}:" "$color" "$version" + if repo_lib_capture_legacy_tool "$required" "$command_name" "$version_command"; then + icon_color="${!effective_icon_color_name:-$color}" + printf " " + if [ -n "$icon" ]; then + printf "%s%s%s " "$icon_color" "$icon" "$RESET" + fi + printf "$CYAN %-@TOOL_LABEL_WIDTH@s$RESET %s%s$RESET\n" "${name}:" "$color" "$REPO_LIB_TOOL_VERSION" + else + printf " " + if [ -n "$icon" ]; then + printf "%s%s%s " "$RED" "$icon" "$RESET" + fi + printf "$CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "$REPO_LIB_TOOL_ERROR" + if [ "$required" = "1" ]; then + exit 1 + fi + fi +} + +repo_lib_print_pretty_header() { + local border_color_name="$1" + local title_color_name="$2" + local icon="$3" + local title="$4" + local subtitle_color_name="$5" + local subtitle="$6" + + local border_color="${!border_color_name:-$BLUE}" + local title_color="${!title_color_name:-$GREEN}" + local subtitle_color="${!subtitle_color_name:-$GRAY}" + + printf "\n%s╭─%s %s" "$border_color" "$RESET" "$title_color" + if [ -n "$icon" ]; then + printf "%s " "$icon" + fi + printf "%s%s" "$title" "$RESET" + if [ -n "$subtitle" ]; then + printf " %s%s%s" "$subtitle_color" "$subtitle" "$RESET" + fi + printf "\n" +} + +repo_lib_print_pretty_row() { + local border_color_name="$1" + local icon="$2" + local icon_color_name="$3" + local label="$4" + local value="$5" + local value_color_name="$6" + + local border_color="${!border_color_name:-$BLUE}" + local icon_color="${!icon_color_name:-$WHITE}" + local value_color="${!value_color_name:-$YELLOW}" + + if [ -z "$icon" ]; then + icon="•" + fi + + printf "%s│%s %s%s%s ${WHITE}%-@TOOL_LABEL_WIDTH@s${RESET} %s%s${RESET}\n" \ + "$border_color" "$RESET" "$icon_color" "$icon" "$RESET" "$label" "$value_color" "$value" +} + +repo_lib_print_pretty_tool() { + local border_color_name="$1" + local name="$2" + local color_name="$3" + local icon="$4" + local icon_color_name="$5" + local required="$6" + local line_no="$7" + local group_no="$8" + local regex="$9" + local match_regex="${10}" + local executable="${11}" + shift 11 + + local effective_icon_color_name="$icon_color_name" + local value_color_name="$color_name" + local value="" + + if [ -z "$effective_icon_color_name" ]; then + effective_icon_color_name="$color_name" + fi + + if repo_lib_capture_tool "$required" "$line_no" "$group_no" "$regex" "$match_regex" "$executable" "$@"; then + value="$REPO_LIB_TOOL_VERSION" + else + value="$REPO_LIB_TOOL_ERROR" + effective_icon_color_name="RED" + value_color_name="RED" + fi + + repo_lib_print_pretty_row \ + "$border_color_name" \ + "$icon" \ + "$effective_icon_color_name" \ + "$name" \ + "$value" \ + "$value_color_name" + + if [ "$value_color_name" = "RED" ] && [ "$required" = "1" ]; then + exit 1 + fi +} + +repo_lib_print_pretty_legacy_tool() { + local border_color_name="$1" + local name="$2" + local color_name="$3" + local icon="$4" + local icon_color_name="$5" + local required="$6" + local command_name="$7" + local version_command="$8" + + local effective_icon_color_name="$icon_color_name" + local value_color_name="$color_name" + local value="" + + if [ -z "$effective_icon_color_name" ]; then + effective_icon_color_name="$color_name" + fi + + if repo_lib_capture_legacy_tool "$required" "$command_name" "$version_command"; then + value="$REPO_LIB_TOOL_VERSION" + else + value="$REPO_LIB_TOOL_ERROR" + effective_icon_color_name="RED" + value_color_name="RED" + fi + + repo_lib_print_pretty_row \ + "$border_color_name" \ + "$icon" \ + "$effective_icon_color_name" \ + "$name" \ + "$value" \ + "$value_color_name" + + if [ "$value_color_name" = "RED" ] && [ "$required" = "1" ]; then + exit 1 + fi +} + +repo_lib_print_pretty_footer() { + local border_color_name="$1" + local border_color="${!border_color_name:-$BLUE}" + + printf "%s╰─%s\n\n" "$border_color" "$RESET" } @SHELL_ENV_SCRIPT@ @BOOTSTRAP@ -printf "\n$GREEN 🚀 Dev shell ready$RESET\n\n" -@TOOL_BANNER_SCRIPT@ -printf "\n" +@SHELL_BANNER_SCRIPT@ @EXTRA_SHELL_TEXT@ diff --git a/skills/repo-lib-consumer/references/api.md b/skills/repo-lib-consumer/references/api.md index 1da307b..110a375 100644 --- a/skills/repo-lib-consumer/references/api.md +++ b/skills/repo-lib-consumer/references/api.md @@ -136,15 +136,26 @@ Preferred shape in `perSystem.tools`: }) ``` +For a tool that should come from the host `PATH` instead of `nixpkgs`: + +```nix +(repo-lib.lib.tools.fromCommand { + name = "Nix"; + command = "nix"; + version.args = [ "--version" ]; +}) +``` + Helper: ```nix -repo-lib.lib.tools.simple "Nix" pkgs.nix [ "--version" ] +repo-lib.lib.tools.simple "Go" pkgs.go [ "version" ] ``` Tool behavior: - Tool packages are added to the shell automatically. +- Command-backed tools are probed from the existing `PATH` and are not added to the shell automatically. - Banner probing uses absolute executable paths. - `required = true` by default. - Required tool probe failure aborts shell startup. diff --git a/template/flake.nix b/template/flake.nix index 097271a..a576089 100644 --- a/template/flake.nix +++ b/template/flake.nix @@ -4,7 +4,7 @@ inputs = { nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; - repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=v3.0.0"; + repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.0.0"; repo-lib.inputs.nixpkgs.follows = "nixpkgs"; }; @@ -85,10 +85,17 @@ }: { tools = [ - (repo-lib.lib.tools.fromPackage { + (repo-lib.lib.tools.fromCommand { name = "Nix"; - package = pkgs.nix; - version.args = [ "--version" ]; + command = "nix"; + version = { + args = [ "--version" ]; + group = 1; + }; + banner = { + color = "BLUE"; + icon = ""; + }; }) # (repo-lib.lib.tools.fromPackage { diff --git a/tests/release.sh b/tests/release.sh index b95df77..1377064 100755 --- a/tests/release.sh +++ b/tests/release.sh @@ -6,6 +6,11 @@ ROOT_DIR="${REPO_LIB_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" RELEASE_TEMPLATE="$ROOT_DIR/packages/release/release.sh" NIXPKGS_FLAKE_PATH="${NIXPKGS_FLAKE_PATH:-}" CURRENT_LOG="" +QC_SEEN_TAGS=() + +if [[ -z "$NIXPKGS_FLAKE_PATH" ]]; then + NIXPKGS_FLAKE_PATH="$(nix eval --raw --impure --expr "(builtins.getFlake (toString ${ROOT_DIR})).inputs.nixpkgs.outPath")" +fi fail() { echo "[test] FAIL: $*" >&2 @@ -70,6 +75,8 @@ setup_repo() { run_capture_ok "setup_repo: git init failed" git -C "$repo_dir" init run_capture_ok "setup_repo: git config user.name failed" git -C "$repo_dir" config user.name "Release Test" run_capture_ok "setup_repo: git config user.email failed" git -C "$repo_dir" config user.email "release-test@example.com" + run_capture_ok "setup_repo: git config commit.gpgsign failed" git -C "$repo_dir" config commit.gpgsign false + run_capture_ok "setup_repo: git config tag.gpgsign failed" git -C "$repo_dir" config tag.gpgsign false cat >"$repo_dir/flake.nix" <<'EOF' { @@ -213,6 +220,49 @@ write_mk_repo_flake() { EOF } +write_mk_repo_command_tool_flake() { + local repo_dir="$1" + cat >"$repo_dir/flake.nix" <"$repo_dir/flake.nix" <"$repo_dir/flake.nix" } @@ -496,6 +546,18 @@ qc_oracle_init() { QC_STATE_BASE="1.0.0" QC_STATE_CHANNEL="stable" QC_STATE_PRE="" + QC_SEEN_TAGS=() +} + +qc_seen_tag() { + local tag="$1" + local existing + for existing in "${QC_SEEN_TAGS[@]:-}"; do + if [[ "$existing" == "$tag" ]]; then + return 0 + fi + done + return 1 } qc_oracle_current_full() { @@ -576,12 +638,16 @@ qc_oracle_apply() { if [[ $cmp_status -eq 0 || $cmp_status -eq 2 ]]; then return 0 fi + if qc_seen_tag "v$QC_FULL_VERSION"; then + return 0 + fi QC_STATE_BASE="$QC_BASE_VERSION" QC_STATE_CHANNEL="$QC_CHANNEL" QC_STATE_PRE="$QC_PRERELEASE_NUM" QC_EXPECT_SUCCESS=1 QC_EXPECT_VERSION="$QC_FULL_VERSION" + QC_SEEN_TAGS+=("v$QC_FULL_VERSION") return 0 fi @@ -649,12 +715,16 @@ qc_oracle_apply() { if [[ $QC_FULL_VERSION == "$current_full" ]]; then return 0 fi + if qc_seen_tag "v$QC_FULL_VERSION"; then + return 0 + fi QC_STATE_BASE="$QC_BASE_VERSION" QC_STATE_CHANNEL="$QC_CHANNEL" QC_STATE_PRE="$QC_PRERELEASE_NUM" QC_EXPECT_SUCCESS=1 QC_EXPECT_VERSION="$QC_FULL_VERSION" + QC_SEEN_TAGS+=("v$QC_FULL_VERSION") } run_randomized_quickcheck_cases() { @@ -985,6 +1055,65 @@ EOF echo "[test] PASS: $case_name" >&2 } +run_version_metadata_case() { + local case_name="release metadata is preserved and exported" + local release_steps + + read -r -d '' release_steps <<'EOF' || true +if [[ "$(version_meta_get desktop_backend_change_scope)" != "bindings" ]]; then + echo "metadata getter mismatch" >&2 + exit 1 +fi +if [[ "${VERSION_META_DESKTOP_BACKEND_CHANGE_SCOPE:-}" != "bindings" ]]; then + echo "metadata export mismatch" >&2 + exit 1 +fi +if [[ "${VERSION_META_DESKTOP_RELEASE_MODE:-}" != "binary" ]]; then + echo "metadata export mismatch" >&2 + exit 1 +fi + + version_meta_set desktop_release_mode codepush + version_meta_set desktop_binary_version_min 1.0.0 + version_meta_set desktop_binary_version_max "$FULL_VERSION" + version_meta_set desktop_backend_compat_id compat-123 + version_meta_unset desktop_unused +EOF + + local workdir + workdir="$(mktemp -d)" + local repo_dir="$workdir/repo" + local remote_dir="$workdir/remote.git" + CURRENT_LOG="$workdir/case.log" + + prepare_case_repo_with_release_script "$repo_dir" "$remote_dir" "$release_steps" ":" + cat >"$repo_dir/VERSION" <<'EOF' +1.0.0 +stable +0 +desktop_backend_change_scope=bindings +desktop_release_mode=binary +desktop_unused=temporary +EOF + run_capture_ok "$case_name: setup commit failed" git -C "$repo_dir" add VERSION + run_capture_ok "$case_name: setup commit failed" git -C "$repo_dir" commit -m "chore: seed metadata" + run_capture_ok "$case_name: release command failed" run_release "$repo_dir" patch + + assert_eq "1.0.1" "$(version_from_file "$repo_dir")" "$case_name: VERSION mismatch" + assert_contains "desktop_backend_change_scope=bindings" "$repo_dir/VERSION" "$case_name: missing preserved scope" + assert_contains "desktop_release_mode=codepush" "$repo_dir/VERSION" "$case_name: missing updated mode" + assert_contains "desktop_binary_version_min=1.0.0" "$repo_dir/VERSION" "$case_name: missing min version" + assert_contains "desktop_binary_version_max=1.0.1" "$repo_dir/VERSION" "$case_name: missing max version" + assert_contains "desktop_backend_compat_id=compat-123" "$repo_dir/VERSION" "$case_name: missing compat id" + if grep -Fq "desktop_unused=temporary" "$repo_dir/VERSION"; then + fail "$case_name: unset metadata key was preserved" + fi + + rm -rf "$workdir" + CURRENT_LOG="" + echo "[test] PASS: $case_name" >&2 +} + run_mk_repo_case() { local case_name="mkRepo exposes outputs and auto-installs tools" local workdir @@ -994,13 +1123,35 @@ run_mk_repo_case() { write_mk_repo_flake "$repo_dir" CURRENT_LOG="$workdir/mk-repo.log" - run_capture_ok "$case_name: flake show failed" nix flake show --json "$repo_dir" + run_capture_ok "$case_name: flake show failed" nix flake show --json --no-write-lock-file "$repo_dir" assert_contains '"pre-commit-check"' "$CURRENT_LOG" "$case_name: missing pre-commit-check" assert_contains '"release"' "$CURRENT_LOG" "$case_name: missing release package" assert_contains '"example"' "$CURRENT_LOG" "$case_name: missing merged package" - run_capture_ok "$case_name: tool package should be available in shell" nix develop "$repo_dir" -c hello --version - run_capture_ok "$case_name: release package should be available in shell" nix develop "$repo_dir" -c sh -c 'command -v release >/dev/null' + run_capture_ok "$case_name: tool package should be available in shell" bash -c 'cd "$1" && nix develop --no-write-lock-file . -c hello --version' _ "$repo_dir" + run_capture_ok "$case_name: release package should be available in shell" bash -c 'cd "$1" && nix develop --no-write-lock-file . -c sh -c "command -v release >/dev/null"' _ "$repo_dir" + + rm -rf "$workdir" + CURRENT_LOG="" + echo "[test] PASS: $case_name" >&2 +} + +run_mk_repo_command_tool_case() { + local case_name="mkRepo supports command-backed tools from PATH" + local workdir + workdir="$(mktemp -d)" + local repo_dir="$workdir/mk-repo-command-tool" + mkdir -p "$repo_dir" + write_mk_repo_command_tool_flake "$repo_dir" + CURRENT_LOG="$workdir/mk-repo-command-tool.log" + + run_capture_ok "$case_name: flake show failed" nix flake show --json --no-write-lock-file "$repo_dir" + assert_contains '"pre-commit-check"' "$CURRENT_LOG" "$case_name: missing pre-commit-check" + assert_contains '"release"' "$CURRENT_LOG" "$case_name: missing release package" + + run_capture_ok "$case_name: system nix should be available in shell" bash -c 'cd "$1" && nix develop --no-write-lock-file . -c nix --version' _ "$repo_dir" + assert_contains "" "$CURRENT_LOG" "$case_name: missing tool icon in banner" + run_capture_ok "$case_name: release package should be available in shell" bash -c 'cd "$1" && nix develop --no-write-lock-file . -c sh -c "command -v release >/dev/null"' _ "$repo_dir" rm -rf "$workdir" CURRENT_LOG="" @@ -1016,7 +1167,7 @@ run_mk_repo_tool_failure_case() { write_tool_failure_flake "$repo_dir" CURRENT_LOG="$workdir/tool-failure.log" - run_expect_failure "$case_name: shell startup should fail" nix develop "$repo_dir" -c true + run_expect_failure "$case_name: shell startup should fail" bash -c 'cd "$1" && nix develop . -c true' _ "$repo_dir" assert_contains "probe failed" "$CURRENT_LOG" "$case_name: failure reason missing" rm -rf "$workdir" @@ -1090,7 +1241,7 @@ run_release_replace_backref_case() { cat >"$repo_dir/template/flake.nix" <<'EOF' { inputs = { - repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=v0.0.0"; + repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v0.0.0"; }; } EOF @@ -1098,10 +1249,10 @@ EOF run_capture_ok "$case_name: setup commit failed" git -C "$repo_dir" add flake.nix template/flake.nix run_capture_ok "$case_name: setup commit failed" git -C "$repo_dir" commit -m "chore: add replace fixture" - run_capture_ok "$case_name: nix run release failed" bash -c 'cd "$1" && nix run .#release -- patch' _ "$repo_dir" + run_capture_ok "$case_name: nix run release failed" bash -c 'cd "$1" && nix run --no-write-lock-file .#release -- patch' _ "$repo_dir" - assert_contains 'repo-lib.url = "git+https://example.invalid/repo-lib?ref=v1.0.1";' "$repo_dir/template/flake.nix" "$case_name: replacement did not preserve captures" - if grep -Fq '\1git+https://example.invalid/repo-lib?ref=v1.0.1\2' "$repo_dir/template/flake.nix"; then + assert_contains 'repo-lib.url = "git+https://example.invalid/repo-lib?ref=refs/tags/v1.0.1";' "$repo_dir/template/flake.nix" "$case_name: replacement did not preserve captures" + if grep -Fq '\1git+https://example.invalid/repo-lib?ref=refs/tags/v1.0.1\2' "$repo_dir/template/flake.nix"; then fail "$case_name: replacement left literal backreferences in output" fi @@ -1117,7 +1268,9 @@ run_set_stable_then_full_noop_case run_set_stable_from_prerelease_requires_full_case run_patch_stable_from_prerelease_requires_full_case run_structured_release_steps_case +run_version_metadata_case run_mk_repo_case +run_mk_repo_command_tool_case run_mk_repo_tool_failure_case run_impure_bootstrap_validation_case run_legacy_api_eval_case