From cf7786e22f8ba44f87aede24b0b135b1091cf644 Mon Sep 17 00:00:00 2001 From: IceDBorn <51162078+IceDBorn@users.noreply.github.com> Date: Tue, 8 Sep 2026 04:29:30 +0300 Subject: [PATCH 1/2] feat: add random tips message as bottom bar on icedos rebuild (#84) --- .gitignore | 1 + AGENTS.md | 53 ++++++++- README.md | 32 +++++- lib/bash.nix | 210 ++++++++++++++++++++++++++++++++++- lib/prelude.sh | 5 + modules/build-vm.nix | 4 + modules/cache.nix | 6 +- modules/config-diff.nix | 4 + modules/config-history.nix | 4 + modules/config-manage.nix | 4 + modules/config-rollback.nix | 4 + modules/configuration.nix | 11 ++ modules/git.nix | 21 +++- modules/nh.nix | 6 +- modules/nix.nix | 31 +++++- modules/options.nix | 53 +++++++++ modules/rebuild.nix | 13 ++- modules/repl.nix | 5 + modules/ssh.nix | 6 +- modules/status.nix | 4 + modules/sudo.nix | 6 +- modules/toolset.nix | 26 ++++- modules/users.nix | 4 + tests/tests.nix | 214 ++++++++++++++++++++++++++++++++++++ 24 files changed, 699 insertions(+), 28 deletions(-) diff --git a/.gitignore b/.gitignore index 727b6f21..3f05df4c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ __pycache__/ # Generated by `nix flake check` / any nix command in this repo — # deliberately NOT pinned (library flake; see AGENTS.md "Core lib tests"). flake.lock +/result diff --git a/AGENTS.md b/AGENTS.md index f74abd10..fca753d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,7 +119,7 @@ Exposed to every module as **`icedosLib`**. |---|---| | `lib/options/helpers.nix` | The `mk*Option` family: `mkBoolOption`, `mkStrOption`, `mkStrListOption`, `mkNumberOption`, `mkEnumOption`, `mkIntBetweenOption`, `mkFloatBetweenOption`, `mkNullableOption`, `mkListOption`, `mkAttrsOfOption`, `mkSubmodule{,List,Attrs}Option`, `mkRecordOption`, `mkUsersOption`. | | `lib/options/validate.nix` | `validate.{int,float,enum,str,nonEmpty,list,requires,abort}` — rich, path-aware error messages. | -| `lib/bash.nix` | `bash.{prelude,exportSystemPath,genHelpFlags,mkFlags,blueString,dimBlueString,greenString,dimGreenString,purpleString,dimPurpleString,redString,dimRedString,yellowString,dimYellowString,configSet,gcTimerCheckSnippet,requireConfigOwner}` — runtime shell helpers shared between Nix-embedded scripts and `prelude.sh` (color vars + the `*String` builders that emit `$(...)`-interpolated escape sequences — the **only** way the dispatcher/completions add color to command help text; `log_*`/`die`/`is_help_flag`; `bash.requireConfigOwner` is the permission guard for executing the baked `configurationLocation` — capture `ORIG_ARGS=("$@")` before arg parsing and only use where `$0` is the leaf command script). Also `injectIfExists` (emits `()` when a path exists — used by genflake for `/etc/nixos/extras.nix`). | +| `lib/bash.nix` | `bash.{prelude,exportSystemPath,genHelpFlags,mkFlags,blueString,dimBlueString,greenString,dimGreenString,purpleString,dimPurpleString,redString,dimRedString,yellowString,dimYellowString,configSet,gcTimerCheckSnippet,requireConfigOwner,printTip}` — runtime shell helpers shared between Nix-embedded scripts and `prelude.sh` (color vars + the `*String` builders that emit `$(...)`-interpolated escape sequences — the **only** way the dispatcher/completions add color to command help text; `log_*`/`die`/`is_help_flag`; `CLR_LINE` — line-erase prefix for printing over a progress line a child left behind, auto-stripped with the colours when stdout isn't a TTY; `bash.requireConfigOwner` is the permission guard for executing the baked `configurationLocation` — capture `ORIG_ARGS=("$@")` before arg parsing and only use where `$0` is the leaf command script; `bash.printTip` takes `config.icedos.system.tips` and returns `{ head, foot }` shell fragments for the tips bottom bar, both `""` when disabled or the list is empty — see §10). Also `injectIfExists` (emits `()` when a path exists — used by genflake for `/etc/nixos/extras.nix`). | | `lib/toolset.nix` | `toolset.mk{Dispatcher,BashCompletion,ZshCompletion,FishCompletion}` — the CLI dispatcher generator (used to build `icedos` itself and every subcommand attrset that has children) + the per-shell completion generators. | | `lib/users.nix` | `users.{getNormal,genDefaults,mkGroupInjector}`. | | `lib/color.nix` | `color.hexToRgbInts`. | @@ -567,7 +567,7 @@ command is a `toolsetCommandType` submodule (`modules/options.nix`): |---|---|---| | `command` | string (required) | subcommand name; must match `[a-zA-Z0-9_-]+`. | | `help` | string (required) | one-line help, shown in the parent listing and `icedos --tree`. | -| `script` | lines | inline bash. **Auto-prefixed with `bash.prelude`** (`modules/toolset.nix`), so `log_ok`/`log_warn`/`log_fail`/`log_info`/`log_step`/`die`/`is_help_flag` + colour vars are available. | +| `script` | lines | inline bash. **Auto-prefixed with `bash.prelude`** (`modules/toolset.nix`), so `log_ok`/`log_warn`/`log_fail`/`log_info`/`log_step`/`die`/`is_help_flag` + colour vars and `CLR_LINE` are available. Top-level leaves named in `tipsCommands` (`modules/toolset.nix`, currently just `rebuild`) are additionally wrapped in `bash.printTip`'s `head`/`foot` — see "Tips bottom bar" below. | | `bin` | string | absolute path to an executable instead of `script` (e.g. a `pkgs.writeShellScript`). | | `commands` | list | nested subcommands — arbitrarily deep. | | `completion.files` | bool | offer file-path completion for this leaf's arguments. | @@ -610,14 +610,59 @@ icedos.system.toolset.commands = [{ installed as `xdg.desktopEntries`. Modules adding session actions gate their own entries on the same flag. +### Tips bottom bar + +`icedos.system.tips.list` is a module-facing contribution list, like +`sessionCommands`: every loaded module appends its own tips and NixOS list merging +concatenates them, so a module advertises its own features and the user's +`config.toml` adds machine-local ones. Entries are bare message strings (rendered +with the lamp default, `💡: `), or `{ title, message }` records — an +explicit `title` renders `title: message`, and `title = ""` renders the message +alone. **Gate a tip on the option it advertises** (`optionals [ … ]`, as +`modules/{ssh,sudo,cache}.nix` do) or it will claim a feature the machine has +turned off. + +`icedosLib.bash.printTip` turns that list into `{ head, foot }`, which +`modules/toolset.nix` wraps around the top-level leaves listed in `tipsCommands` (currently +just `rebuild`). `head` pins one random tip to the terminal's last row and confines +output to a scroll region above it; `foot` restores the region while preserving the +leaf's exit status. Both are `""` when `tips.enable` is false or the list is empty. + +Constraints on a wrapped leaf: + +- **Don't install your own `EXIT` trap** — it would replace `head`'s + `trap _icedos_tip EXIT` and leave the scroll region set. Compose instead: + `command -v _icedos_tip >/dev/null 2>&1 && _icedos_tip` (see `modules/git.nix`, + `modules/nh.nix`). +- **Finalize before any `exec`** with the same guarded call — `exec` replaces the + shell, so the `EXIT` trap never runs (`modules/nix.nix`, `modules/repl.nix`). + `tests/tests.nix` asserts that every `exec` across `rebuild`, `nix`, `repl`, + `git` and `nh` is preceded by it — the guarded count must equal the total. +- **Don't erase to end of screen** (`\033[J`) in a redraw loop; it takes the pinned + bar with it. Erase only the rows you own, saving and restoring the cursor around + the walk with **DECSC/DECRC** — written `\e7`/`\e8`, since `printf %b` reads + `\0337` as a single octal byte. Not `\033[s`/`\033[u`: that pair is an ANSI.SYS + extension xterm honours only while DECLRMM is off, and a terminal that ignores it + leaves each later frame drawn below the last instead of over it. +- `ICEDOS_TIP_ACTIVE` marks "a bar is pinned in this process tree", so nested + `icedos` calls and owner re-runs keep the outer bar. `bash.requireConfigOwner` + forwards it across the re-exec; `_icedos_tip` unsets it on restore. + +The bar is TTY-only. It needs the cursor row, so it asks the terminal with a DSR +query; a terminal that does not answer within 200 ms gets the tip as a plain +trailing line instead, and the reply is drained so a late answer cannot surface as +stray keystrokes in a later `read`. + ## 11. Hook authoring contract `icedos.system.toolset.rebuild.hooks.{preRebuild,postRebuild,preUpdate,postUpdate}` and `icedos.system.gc.hooks.{preGc,postGc}` are lists of shell snippets. Each snippet is compiled to its **own** `pkgs.writeShellScript` with `bash.prelude` prepended (`modules/rebuild.nix`, `modules/nh.nix`), so it runs in a fresh shell with the same -helpers a command gets (`log_*`, `die`, `is_help_flag`, colour vars; colours auto-strip -when stdout isn't a TTY). +helpers a command gets (`log_*`, `die`, `is_help_flag`, colour vars, `CLR_LINE`; +colours and `CLR_LINE` auto-strip when stdout isn't a TTY). A hook runs in its own +shell, so it never owns the tips bar — it inherits `ICEDOS_TIP_ACTIVE` from the +rebuild that spawned it and must not pin one of its own. ### Execution identity — hooks don't run as root by default diff --git a/README.md b/README.md index a0f5bfc2..8728215b 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,7 @@ Everything under `icedos` is IceDOS's own, checked settings. The top-level group | Key | What it controls | | --- | --- | | `icedos.repositories` | Which module repos to load and which modules to enable (see above). | -| `icedos.system` | System-wide settings: `arch`, `version` (stateVersion), `nixpkgsChannel`, `allowUnfree`, `generations`, `packages`, `permittedInsecurePackages`, `loadHardwareConfiguration`, the binary `cache`, `gc` (auto-cleanup), the `toolset` (CLI + hooks), extra `channels`/`overlays`, and `build-vm`. | +| `icedos.system` | System-wide settings: `arch`, `version` (stateVersion), `nixpkgsChannel`, `allowUnfree`, `generations`, `packages`, `permittedInsecurePackages`, `loadHardwareConfiguration`, the binary `cache`, `gc` (auto-cleanup), the `toolset` (CLI + hooks), the rebuild `tips` line, extra `channels`/`overlays`, and `build-vm`. | | `icedos.users` | User accounts (home-manager integrated): password, groups, sudo, packages, … | | `icedos..*` | Options exposed by the module repos you load, grouped by category — e.g. `icedos.applications.*` (apps like `btop`, `steam`), `icedos.hardware.*`, `icedos.desktop.*`, `icedos.tweaks.*`. Which categories exist depends on which repos you enable. | @@ -430,6 +430,36 @@ New here? These cover almost everything: | `icedos gc [--dry] [--days ] [--gens ]` | Free up disk space (--dry to preview). | | `icedos repl` | Open a Nix REPL preloaded with your evaluated config, packages, and lib. | +### The tips line + +Once `icedos.system.tips.list` is populated, `icedos rebuild` runs with a +persistent bottom bar: output is confined above a blank line and a one-line tip +pinned to the terminal's last row, visible from the first frame to the last. +The bar stays on the last row for the whole run, so short output leaves it a +screenful below the final line; restoring the terminal on exit scrolls it up so +the shell prompt lands beneath it: + +``` +$ icedos rebuild +> Caching config set + +💡: icedos rebuild --dry prepares a rebuild without building anything. +``` + +`list` merges tips contributed by every loaded icedos module, so each +module advertises its own features; add machine-local tips from config if you +like. Set `enable = false` under `[icedos.system.tips]` to turn the line off. The +bar is TTY-only, so piped output stays clean. It makes room below your typed +command, restores the terminal on exit and on TERM/HUP/QUIT (SIGKILL excepted), +and leaves helpers and non-script dispatchers untouched. + +Pinning the bar needs the cursor position, so the terminal is asked for it with +a DSR query. Terminals that do not answer within 200 ms — and multiplexers or +recorders that swallow the reply — get the tip as a plain trailing line instead; +nothing is scrolled blind. A resize re-pins the bar, but bash only runs the +`WINCH` handler once the current foreground command returns, so resizing mid-build +leaves the bar at its old coordinates until that build step finishes. + ### Full command reference | Command | Description | diff --git a/lib/bash.nix b/lib/bash.nix index c4e4b714..0e6dfb02 100644 --- a/lib/bash.nix +++ b/lib/bash.nix @@ -1,7 +1,5 @@ { - icedosLib, lib, - self, ... }: @@ -17,9 +15,11 @@ let escapeShellArg fileContents max + replaceStrings ; + in -rec { +{ # Runtime helpers shared by Nix-embedded scripts (prelude auto-prepended by # toolset.nix) and standalone .sh files that source lib/prelude.sh. bash = { @@ -394,11 +394,11 @@ rec { owner_path="/run/wrappers/bin:$owner_home/.nix-profile/bin:$owner_home/.local/state/nix/profile/bin:/etc/profiles/per-user/$owner/bin:/nix/var/nix/profiles/default/bin:/run/current-system/sw/bin" /run/current-system/sw/bin/runuser -u "$owner" -- \ /run/current-system/sw/bin/env -i "HOME=$owner_home" "USER=$owner" "LOGNAME=$owner" "PATH=$owner_path" \ - ICEDOS_OWNER_RERUN=1 "$0" "$@" + ICEDOS_OWNER_RERUN=1 "ICEDOS_TIP_ACTIVE=''${ICEDOS_TIP_ACTIVE:-}" "$0" "$@" else # sudo resets the env, so pass the re-entry marker explicitly; only # the setuid wrapper path works here. - /run/wrappers/bin/sudo -u "$owner" -- /run/current-system/sw/bin/env ICEDOS_OWNER_RERUN=1 "$0" "$@" + /run/wrappers/bin/sudo -u "$owner" -- /run/current-system/sw/bin/env ICEDOS_OWNER_RERUN=1 "ICEDOS_TIP_ACTIVE=''${ICEDOS_TIP_ACTIVE:-}" "$0" "$@" fi rc=$? [ "$rc" -eq 0 ] && exit 0 @@ -417,6 +417,206 @@ rec { return 0 } ''; + + # Bottom bar for toolset leaves: one random tip pinned to the last line and + # command output confined to the scroll region above it; TTY-only. + printTip = + tips: + let + enabled = tips.enable && tips.list != [ ]; + # The tip is printed onto one pinned row, so a newline from a `"""…"""` + # tip in config.toml would scroll the screen and tear the region open. + oneLine = replaceStrings [ "\n" "\r" "\t" ] [ " " " " " " ]; + tipLine = + t: + if builtins.isString t then + "💡: ${t}" + else if !(t ? title) then + "💡: ${t.message}" + else if t.title == "" then + t.message + else + "${t.title}: ${t.message}"; + head = '' + _icedos_tips=( + ${concatStringsSep " +" (map (t: escapeShellArg (oneLine (tipLine t))) tips.list)} + ) + _icedos_tip_shown=0 + _icedos_tip_i=0 + _icedos_bar=0 + _icedos_tip_plain=0 + _icedos_rows= + _icedos_max= + _icedos_cpr_row= + _icedos_cpr_col= + # A cursor-position reply that misses the timeout would otherwise + # surface as stray keystrokes in the next `read` a leaf runs. + _icedos_drain_tty() { + local junk + read -rs -t 0.05 -n 4096 junk /dev/null + read -rs -t 0.05 -n 4096 junk /dev/null + return 0 + } + # Row and col come back empty when the terminal does not answer, or + # answers with something that is not a CPR. + _icedos_cpr() { + local p c + _icedos_cpr_row= + _icedos_cpr_col= + printf '\033[6n' >/dev/tty 2>/dev/null + if IFS=';' read -rsdR -t 0.2 p c /dev/null; then + p=''${p##*[} + p=''${p%%;*} + c=''${c%%R*} + case "$p" in + "" | *[!0-9]*) p= ;; + esac + case "$c" in + "" | *[!0-9]*) c= ;; + esac + else + p= + c= + fi + [ -n "$p" ] || _icedos_drain_tty + _icedos_cpr_row=$p + _icedos_cpr_col=$c + return 0 + } + _icedos_tip_init() { + [ -t 1 ] || return 0 + # A parent leaf already pinned the bar (nested icedos call, or an + # owner re-run that carried the marker): keep the outer bar. + [ -n "''${ICEDOS_TIP_ACTIVE:-}" ] && return 0 + local size rows cols max p n i + size=$(stty size /dev/null) || size="" + rows=''${size%% *} + cols=''${size##* } + [ "$rows" -ge 4 ] 2>/dev/null || rows=$(tput lines /dev/null || echo 24) + [ "$rows" -ge 4 ] 2>/dev/null || rows=24 + # `cols - 2` is a substring length: 0 blanks the bar and a negative + # value means "all but the last N chars", so keep it at least 1. + [ "$cols" -ge 3 ] 2>/dev/null || cols=80 + _icedos_rows=$rows + _icedos_max=$(( cols - 2 )) + _icedos_tip_i=$(( RANDOM % ''${#_icedos_tips[@]} )) + export ICEDOS_TIP_ACTIVE=1 + _icedos_cpr + p=$_icedos_cpr_row + if [ -z "$p" ]; then + # Scrolling blind would wipe the screen, so drop the bar and + # degrade to a plain tip line printed on exit. + _icedos_tip_plain=1 + return 0 + fi + max=$_icedos_max + _icedos_bar=1 + n=0 + if [ "$p" -ge "$((rows - 2))" ] 2>/dev/null; then + n=$(( p - rows + 3 )) + fi + if [ "$n" -gt 0 ]; then + # The prompt sits where the bar and its blank row go, so scroll it + # up by that overlap instead of drawing over the command line. + printf '\033[%d;1H' "$rows" + i=$n + while [ "$i" -gt 0 ]; do printf '\n'; i=$((i - 1)); done + fi + # Autowrap off around the tip: a tip wider than the terminal would + # otherwise wrap into the scroll region and push output up a row. + printf '\033[?7l\033[%d;1H\033[K%b%s%b\033[?7h' "$rows" "$DIM_GREEN" "''${_icedos_tips[$_icedos_tip_i]:0:$max}" "$NC" + printf '\033[%d;1H\033[K' "$((rows - 1))" + printf '\033[1;%dr' "$((rows - 2))" + # One line below the prompt, corrected for the rows just scrolled: + # setting the region homed the cursor, so it has to be placed back. + printf '\033[%d;1H' "$(( p + 1 - n ))" + } + # A resize leaves the region and bar at stale coordinates. Bash defers + # this until the foreground command returns, so a build holds it stale. + _icedos_tip_winch() { + [ "$_icedos_tip_shown" -eq 1 ] && return 0 + [ -t 1 ] || return 0 + [ "$_icedos_bar" = 1 ] || return 0 + local size rows cols max p c + size=$(stty size /dev/null) || size="" + rows=''${size%% *} + cols=''${size##* } + [ "$rows" -ge 4 ] 2>/dev/null || return 0 + [ "$cols" -ge 3 ] 2>/dev/null || return 0 + max=$(( cols - 2 )) + [ "$rows" = "$_icedos_rows" ] && [ "$max" = "$_icedos_max" ] && return 0 + local old_rows=$_icedos_rows + _icedos_rows=$rows + _icedos_max=$max + _icedos_cpr + p=$_icedos_cpr_row + c=$_icedos_cpr_col + printf '\033[r' + printf '\033[?7l\033[%d;1H\033[K%b%s%b\033[?7h' "$rows" "$DIM_GREEN" "''${_icedos_tips[$_icedos_tip_i]:0:$max}" "$NC" + printf '\033[%d;1H\033[K' "$((rows - 1))" + printf '\033[1;%dr' "$((rows - 2))" + # Growing puts the old bar rows inside the region; clear that band so + # resumed output cannot fuse with the text left there. + if [ "$rows" -gt "$old_rows" ]; then + local r + r=$((old_rows - 1)) + while [ "$r" -le "$old_rows" ]; do + printf '\033[%d;1H\033[K' "$r" + r=$((r + 1)) + done + fi + if [ -n "$p" ] && [ "$p" -le "$((rows - 2))" ] 2>/dev/null && [ -n "$c" ]; then + # Back to where output was, clamped: a narrower terminal leaves the + # pre-resize column past the new right edge. + [ "$c" -gt "$cols" ] 2>/dev/null && c=$cols + printf '\033[%d;%dH' "$p" "$c" + else + printf '\033[1;1H' + fi + } + _icedos_tip() { + [ "$_icedos_tip_shown" -eq 1 ] && return 0 + _icedos_tip_shown=1 + trap - WINCH + # Children spawned after the bar is gone (an exec'd program that + # calls icedos again) must be free to pin a bar of their own. + unset ICEDOS_TIP_ACTIVE + [ -t 1 ] || return 0 + if [ "$_icedos_tip_plain" = 1 ]; then + printf '%b%s%b\n' "$DIM_GREEN" "''${_icedos_tips[$_icedos_tip_i]}" "$NC" + return 0 + fi + [ "$_icedos_bar" = 1 ] || return 0 + local size rows + size=$(stty size /dev/null) || size="" + rows=''${size%% *} + [ "$rows" -ge 4 ] 2>/dev/null || rows=$_icedos_rows + # Newline on the bar's own row scrolls it up one, so the shell prompt + # lands below the tip instead of overwriting it. + printf '\033[r\033[%d;1H\n' "$rows" + } + _icedos_tip_init + trap _icedos_tip EXIT + # Signal death must also restore the region (SIGKILL cannot); the + # explicit exits give leaves the usual 128+SIG status. + trap '_icedos_tip; exit $((128 + 15))' TERM + trap '_icedos_tip; exit $((128 + 1))' HUP + trap '_icedos_tip; exit $((128 + 3))' QUIT + trap _icedos_tip_winch WINCH + + ''; + in + if !enabled then + { + head = ""; + foot = ""; + } + else + { + inherit head; + foot = "_icedos_tip_rc=$?\n_icedos_tip\nexit \"$_icedos_tip_rc\"\n"; + }; }; injectIfExists = diff --git a/lib/prelude.sh b/lib/prelude.sh index 25a2d423..673ea533 100644 --- a/lib/prelude.sh +++ b/lib/prelude.sh @@ -13,11 +13,16 @@ DIM_PURPLE='\033[0;35m' DIM_RED='\033[0;31m' DIM_YELLOW='\033[0;33m' +# Prefix for the first message after a child that ends mid-line: erases the +# progress line it left behind instead of appending to it. +CLR_LINE='\033[2K\r' + # Strip escape codes when stdout isn't a terminal so piped/redirected # output stays clean (e.g. `icedos download | cat`). if [ ! -t 1 ]; then NC='' BLUE='' GREEN='' PURPLE='' RED='' YELLOW='' DIM_BLUE='' DIM_GREEN='' DIM_PURPLE='' DIM_RED='' DIM_YELLOW='' + CLR_LINE='' fi log_info() { printf '%b>%b %s\n' "$DIM_BLUE" "$NC" "$*"; } diff --git a/modules/build-vm.nix b/modules/build-vm.nix index 84cd0a1b..ec5893df 100644 --- a/modules/build-vm.nix +++ b/modules/build-vm.nix @@ -40,4 +40,8 @@ in } ]; }; + + icedos.system.tips.list = [ + "icedos rebuild --run-vm tries your config in a throwaway VM first." + ]; } diff --git a/modules/cache.nix b/modules/cache.nix index c0c3ed91..2d630c9c 100644 --- a/modules/cache.nix +++ b/modules/cache.nix @@ -1,7 +1,7 @@ { config, lib, ... }: let - inherit (lib) mkIf; + inherit (lib) mkIf optionals; inherit (config.icedos.system.cache) enable @@ -15,4 +15,8 @@ in substituters = [ "${url}?priority=${toString priority}" ]; trusted-public-keys = [ key ]; }; + + icedos.system.tips.list = optionals enable [ + "The binary cache downloads prebuilt custom and unfree packages, so rebuilds compile less." + ]; } diff --git a/modules/config-diff.nix b/modules/config-diff.nix index 83664128..aac2c03c 100644 --- a/modules/config-diff.nix +++ b/modules/config-diff.nix @@ -60,4 +60,8 @@ in ''; } ]; + + icedos.system.tips.list = [ + "icedos configuration diff shows what you changed since the last rebuild." + ]; } diff --git a/modules/config-history.nix b/modules/config-history.nix index a52a7a53..b2646be1 100644 --- a/modules/config-history.nix +++ b/modules/config-history.nix @@ -167,4 +167,8 @@ in ''; } ]; + + icedos.system.tips.list = [ + "icedos configuration history browses your past configs, one per rebuild." + ]; } diff --git a/modules/config-manage.nix b/modules/config-manage.nix index 16449ab6..8a03ce17 100644 --- a/modules/config-manage.nix +++ b/modules/config-manage.nix @@ -302,4 +302,8 @@ in action = "disable"; }) ]; + + icedos.system.tips.list = [ + "icedos configuration enable/disable picks modules; rebuild applies them." + ]; } diff --git a/modules/config-rollback.nix b/modules/config-rollback.nix index fd6e5c49..348b7aaf 100644 --- a/modules/config-rollback.nix +++ b/modules/config-rollback.nix @@ -170,4 +170,8 @@ in ''; } ]; + + icedos.system.tips.list = [ + "icedos configuration rollback puts your system back to how it was." + ]; } diff --git a/modules/configuration.nix b/modules/configuration.nix index 31a3d3b3..06cb014a 100644 --- a/modules/configuration.nix +++ b/modules/configuration.nix @@ -368,4 +368,15 @@ in ++ configurationCommands; } ]; + + icedos.system.tips.list = [ + "icedos configuration search finds any setting or module by name." + "icedos configuration get shows what a setting is set to." + "icedos configuration validate checks your config before you rebuild." + "Split your settings across configs/*.toml; they all merge together." + "Put custom Nix modules under modules/; files and folders load automatically." + "Set [icedos.system] extraConfigs/extraModules to scan more folders." + "Add enable = false at the top of any extra config *.toml to skip that file." + "/.name.toml stays out of git, but it is not a place for secrets." + ]; } diff --git a/modules/git.nix b/modules/git.nix index c07019b1..221f2ae6 100644 --- a/modules/git.nix +++ b/modules/git.nix @@ -263,6 +263,7 @@ in printf "\033[?25h" fi ${coreutils}/bin/rm -rf "$tmpdir" + command -v _icedos_tip >/dev/null 2>&1 && _icedos_tip ' EXIT function showRepos() { @@ -520,7 +521,7 @@ in frame+=("$footer") fi - local out="" n="''${#frame[@]}" idx2=0 + local out="" n="''${#frame[@]}" idx2=0 t if [ "$prev_lines" -gt 1 ]; then out="\r\033[$((prev_lines - 1))A" elif [ "$prev_lines" -eq 1 ]; then @@ -531,7 +532,19 @@ in out="''${out}\033[2K''${i}" [ "$idx2" -lt "$n" ] && out="''${out}\n" done - out="''${out}\033[J" + # Erase only the rows a longer previous frame left; erase-to-end would + # take the pinned tip. Cursor-down clamps, so save/restore with DECSC — + # SCOSC is not universal, and `printf %b` folds `\0337` to one byte. + t=$(( prev_lines - n )) + if [ "$t" -gt 0 ]; then + idx2=0 + out="''${out}\e7" + while [ "$idx2" -lt "$t" ]; do + out="''${out}\033[B\r\033[2K" + idx2=$((idx2 + 1)) + done + out="''${out}\e8" + fi printf '%b' "$out" prev_lines=$n spin_i=$(( (spin_i + 1) % ''${#spin[@]} )) @@ -634,4 +647,8 @@ in ]; } ]; + + icedos.system.tips.list = [ + "icedos git rpull recursively updates every git repo under a folder at once." + ]; } diff --git a/modules/nh.nix b/modules/nh.nix index fe0be126..adecf104 100644 --- a/modules/nh.nix +++ b/modules/nh.nix @@ -432,7 +432,7 @@ in ${cacheCleanScript} $CACHE_ARGS --preview _summary_clean=$(mktemp) || die "mktemp failed" _summary_cache=$(mktemp) || die "mktemp failed" - trap "rm -f '$_summary_clean' '$_summary_cache'" EXIT + trap "rm -f '$_summary_clean' '$_summary_cache'; command -v _icedos_tip >/dev/null 2>&1 && _icedos_tip" EXIT ${cleanExtra} --remove --summary-file "$_summary_clean" ${cacheCleanScript} $CACHE_ARGS --remove --summary-file "$_summary_cache" cat "$_summary_clean" @@ -470,4 +470,8 @@ in ++ runHooksAsUsers "postGc" postGc; }; }; + + icedos.system.tips.list = [ + "icedos gc frees disk space by removing old system versions and junk." + ]; } diff --git a/modules/nix.nix b/modules/nix.nix index c770fc8f..e90a8716 100644 --- a/modules/nix.nix +++ b/modules/nix.nix @@ -110,6 +110,7 @@ in if [ -z "$RUN_ARG" ]; then echo "$OUT" else + command -v _icedos_tip >/dev/null 2>&1 && _icedos_tip exec "$OUT/bin/$RUN_ARG" "''${BIN_ARGS[@]}" fi ''; @@ -185,6 +186,7 @@ in setsid -f "$@" /dev/null 2>&1 exit 0 fi + command -v _icedos_tip >/dev/null 2>&1 && _icedos_tip exec "$@" } @@ -234,8 +236,27 @@ in { command = "repair"; - script = "nix-store --verify --check-contents --repair"; - help = "repair nix store"; + help = "verify the nix store and re-fetch damaged paths (elevates with sudo)"; + + script = '' + if [[ ${genHelpFlags { excludeNoArgs = true; }} ]]; then + echo "Usage: icedos repair" + echo "Verifies every path in the nix store and re-fetches damaged ones." + echo "Repairing is root-only, so this elevates with sudo when needed." + exit 0 + fi + + [ "$#" -gt 0 ] && die "unknown arg: $1" + + # The daemon rejects repairPath for untrusted users with a bare + # "operation is not supported by store 'daemon'", so elevate first. + if [ "$(id -u)" -eq 0 ]; then + nix-store --verify --check-contents --repair + else + log_step "repairing the nix store (needs root)..." + /run/wrappers/bin/sudo nix-store --verify --check-contents --repair + fi + ''; } { @@ -281,4 +302,10 @@ in }; nixpkgs.config = icedosLib.pkgs.mkConfig config.icedos; + + icedos.system.tips.list = [ + "icedos pkgs list shows everything in your system, dependencies included." + "icedos pkgs run runs an app once without installing it." + "icedos repair re-downloads damaged files in the package store." + ]; } diff --git a/modules/options.nix b/modules/options.nix index 0266c409..ed130a02 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -292,6 +292,59 @@ in rs = mkBoolOption { default = true; }; }; + tips = { + enable = mkBoolOption { + default = true; + + description = '' + Pin a tip from `list` to the terminal's bottom row while + `icedos rebuild` runs, keeping command output above it in a + scroll region. Skipped when stdout is not a terminal; when the + terminal does not answer a cursor-position query the tip is + printed as a plain trailing line instead of a pinned bar. + ''; + }; + + list = + mkListOption + { + default = [ ]; + + description = '' + Tips shown under `icedos rebuild` output, one per run. Every + loaded module contributes its own tips here; extend the list + with machine-local hints. Entries are bare message strings + (rendered with the default lamp title) or `{ title, message }` + records — the bottom bar shows "title: message". An empty list + hides the tip line even when `enable` is true. + ''; + } + ( + types.either types.str ( + types.submodule { + options = { + title = mkStrOption { + default = "💡"; + + description = '' + Label shown before `message` in the bottom bar. Defaults to + a lamp emoji; set it to the empty string for a plain + message-only tip with no title and no separator. + ''; + }; + + message = mkStrOption { + description = '' + The tip text itself. Keep it short — the bar clips anything + wider than the terminal. + ''; + }; + }; + } + ) + ); + }; + version = mkStrOption { }; # Set according to docs at https://search.nixos.org/options?show=system.stateVersion zsh = { diff --git a/modules/rebuild.nix b/modules/rebuild.nix index d0f9364d..52b8ee7f 100644 --- a/modules/rebuild.nix +++ b/modules/rebuild.nix @@ -344,7 +344,7 @@ in exit_if_interrupted "$BUILD_STATUS" if [ "$BUILD_STATUS" -ne 0 ]; then - echo -e "${redString "error"}: build failed with exit code $BUILD_STATUS" + echo -e "''${CLR_LINE}${redString "error"}: build failed with exit code $BUILD_STATUS" exit "$BUILD_STATUS" fi @@ -365,7 +365,7 @@ in if [ ''${#CACHED_NAMES[@]} -gt 0 ]; then printf -v JOINED '%s, ' "''${CACHED_NAMES[@]}" - echo -e "${dimGreenString ">"} Caching ''${JOINED%, }" + echo -e "''${CLR_LINE}${dimGreenString ">"} Caching ''${JOINED%, }" fi # Only switch/boot mint a generation, so only they record which snapshot @@ -416,8 +416,8 @@ in if [ ''${#REBOOT_REASONS[@]} -gt 0 ]; then printf -v REASONS_JOINED '%s, ' "''${REBOOT_REASONS[@]}" - echo -e "${purpleString "warning"}: reboot recommended for ''${REASONS_JOINED%, } changes to apply" - printf -v PROMPT '%b' "${dimGreenString ">"} Reboot now? [y/N] " + echo -e "''${CLR_LINE}${purpleString "warning"}: reboot recommended for ''${REASONS_JOINED%, } changes to apply" + printf -v PROMPT '%b' "''${CLR_LINE}${dimGreenString ">"} Reboot now? [y/N] " read -r -p "$PROMPT" ANSWER case "$ANSWER" in [yY]|[yY][eE][sS]) systemctl reboot -i || sudo systemctl reboot -i ;; @@ -427,4 +427,9 @@ in ''; } ]; + + icedos.system.tips.list = [ + "icedos rebuild --dry prepares a rebuild without building anything." + "icedos rebuild --update gets the newest packages, repos and inputs before rebuilding." + ]; } diff --git a/modules/repl.nix b/modules/repl.nix index 6a381b73..db708b71 100644 --- a/modules/repl.nix +++ b/modules/repl.nix @@ -47,8 +47,13 @@ in die "no generated flake at '${configurationLocation}'; run 'icedos rebuild' once." fi + command -v _icedos_tip >/dev/null 2>&1 && _icedos_tip exec nix repl --file ${replExpr} ''; } ]; + + icedos.system.tips.list = [ + "icedos repl opens a nix repl with your whole config loaded." + ]; } diff --git a/modules/ssh.nix b/modules/ssh.nix index f99793f1..815eb322 100644 --- a/modules/ssh.nix +++ b/modules/ssh.nix @@ -1,10 +1,14 @@ { config, lib, ... }: let - inherit (lib) mkIf; + inherit (lib) mkIf optionals; inherit (config.icedos.system) ssh; in { services.openssh.enable = mkIf ssh true; programs.zsh.shellAliases.ssh = mkIf ssh "TERM=xterm-256color ssh"; + + icedos.system.tips.list = optionals ssh [ + "SSH is on, so you can reach this machine from another computer." + ]; } diff --git a/modules/status.nix b/modules/status.nix index a1bea462..c934e27e 100644 --- a/modules/status.nix +++ b/modules/status.nix @@ -232,4 +232,8 @@ in ''; } ]; + + icedos.system.tips.list = [ + "icedos status shows your system info, disk use, and health checks." + ]; } diff --git a/modules/sudo.nix b/modules/sudo.nix index 71d684c4..aa493676 100644 --- a/modules/sudo.nix +++ b/modules/sudo.nix @@ -1,7 +1,7 @@ { config, lib, ... }: let - inherit (lib) mkIf; + inherit (lib) mkIf optionals; inherit (config.icedos.system.sudo) passwordFeedback rs; pwfeedback = mkIf passwordFeedback "Defaults pwfeedback"; # Show asterisks when typing sudo password @@ -14,4 +14,8 @@ in execWheelOnly = true; extraConfig = pwfeedback; }; + + icedos.system.tips.list = optionals passwordFeedback [ + "Most password prompts show asterisks as you type." + ]; } diff --git a/modules/toolset.nix b/modules/toolset.nix index 9f672e9b..9b7a7b10 100644 --- a/modules/toolset.nix +++ b/modules/toolset.nix @@ -9,6 +9,7 @@ let inherit (config.icedos.system.toolset) commands desktopEntries sessionCommands; inherit (icedosLib.bash) prelude; + printTip = icedosLib.bash.printTip config.icedos.system.tips; inherit (icedosLib.toolset) mkBashCompletion @@ -68,13 +69,13 @@ let leafCount = builtins.foldl' (acc: x: if x.hasScriptOrBin then acc + 1 else acc) 0 entries; in if n > 1 && anyBranch && anyLeaf then - builtins.abort '' + abort '' icedos toolset: command "${name}" is registered as BOTH a leaf (script/bin) and a branch (subcommands) by different modules. This is ambiguous — NixOS list ordering decides which definition wins. Use unique command names or consolidate the definitions into a single module.'' else if n > 1 && leafCount > 1 then - builtins.abort '' + abort '' icedos toolset: command "${name}" has multiple leaf definitions (script/bin) from different modules (${toString leafCount} registrations). Only the first survives — rename or consolidate.'' @@ -87,7 +88,7 @@ let builtins.seq validated ( map ( cmd: - builtins.removeAttrs cmd [ "_entries" ] + removeAttrs cmd [ "_entries" ] // { commands = mergeCommands cmd.commands; } @@ -116,12 +117,17 @@ let exec ${pkgs.systemd}/bin/run0 ${pkgs.systemd}/bin/systemctl suspend -i ''; + tipsCommands = [ "rebuild" ]; + + # `tipsCommands` names top-level leaves, so `top` keeps a nested subcommand + # (or a repo module's leaf) that shares the name from inheriting a bar. resolve = - cmd: + top: cmd: let - resolvedChildren = map resolve cmd.commands; + resolvedChildren = map (resolve false) cmd.commands; hasChildren = cmd.commands != [ ]; hasScript = cmd.script != ""; + wrapTips = top && hasScript && builtins.elem cmd.command tipsCommands; in cmd // { @@ -133,13 +139,17 @@ let commands = resolvedChildren; }) ) + else if wrapTips then + toString ( + pkgs.writeShellScript cmd.command "${prelude}\n${printTip.head}\n${cmd.script}\n${printTip.foot}" + ) else if hasScript then toString (pkgs.writeShellScript cmd.command "${prelude}\n${cmd.script}") else cmd.bin; }; - resolvedCommands = map resolve mergedCommands; + resolvedCommands = map (resolve true) mergedCommands; flatten = cmd: [ cmd ] ++ concatMap flatten cmd.commands; allCommands = concatMap flatten mergedCommands; @@ -329,4 +339,8 @@ in settings.Keywords = "suspend;sleep;"; }; }; + + icedos.system.tips.list = [ + "Hide this bar with [icedos.system.tips] enable = false in config.toml." + ]; } diff --git a/modules/users.nix b/modules/users.nix index fadbb869..93915fdb 100644 --- a/modules/users.nix +++ b/modules/users.nix @@ -93,4 +93,8 @@ in ] ) users ); + + icedos.system.tips.list = [ + "Each user gets their own packages and settings in toml config files." + ]; } diff --git a/tests/tests.nix b/tests/tests.nix index a8d36bc1..b626d8b1 100644 --- a/tests/tests.nix +++ b/tests/tests.nix @@ -494,6 +494,22 @@ let optional = i.repoFetchOptional; deps = i.repoFetchDeps; }; + + # --- icedosLib.bash.printTip (icedos.system.tips bottom bar) ------------- + tipsLib = + (import ../lib/bash.nix { + inherit lib; + self = "tests"; + icedosLib = { }; + }).bash; + tipsFor = opts: tipsLib.printTip opts; + tipsHead = + list: + (tipsFor { + enable = true; + inherit list; + }).head; + tipsSampleHead = tipsHead [ "a tip" ]; in { intHappy = expectOk ( @@ -3211,4 +3227,202 @@ in }; } ); + + # --- tips bottom bar (icedos.system.tips) -------------------------------- + tipsDisabled = + expectEq + { + head = ""; + foot = ""; + } + (tipsFor { + enable = false; + list = [ "a tip" ]; + }); + + tipsEmptyList = + expectEq + { + head = ""; + foot = ""; + } + (tipsFor { + enable = true; + list = [ ]; + }); + + tipsEnabledHeadHasTrap = expectOk (lib.strings.hasInfix "trap _icedos_tip EXIT" tipsSampleHead); + + tipsBarPinsTipAndRegion = expectOk ( + lib.strings.hasInfix "printf '\\033[?7l\\033[%d;1H\\033[K%b%s%b\\033[?7h'" tipsSampleHead + && lib.strings.hasInfix "printf '\\033[1;%dr'" tipsSampleHead + && lib.strings.hasInfix "stty size /dev/tty" tipsSampleHead + && lib.strings.hasInfix "read -rsdR -t 0.2" tipsSampleHead + ); + + # A reply that misses the 0.2s window would be swallowed by the next `read` + # a leaf runs, so every failed query drains the terminal first. + tipsCprDrainsOnTimeout = expectOk ( + lib.strings.hasInfix "_icedos_drain_tty() {" tipsSampleHead + && lib.strings.hasInfix "read -rs -t 0.05 -n 4096 junk 0 && guarded == total); + + # The file list above is hand-maintained, so pin `tipsCommands`: growing it + # trips here until the new leaf's module joins `tipsExecLeavesFinalize`. + tipsCommandsPinned = expectOk ( + lib.strings.hasInfix ''tipsCommands = [ "rebuild" ];'' (builtins.readFile ../modules/toolset.nix) + ); + + # Leaves with their own EXIT trap compose the finalize in, and _icedos_tip + # no-ops once shown, so signal traps cannot double-scroll. + tipsTrapComposition = expectOk ( + lib.strings.hasInfix "command -v _icedos_tip" (builtins.readFile ../modules/git.nix) + && lib.strings.hasInfix "command -v _icedos_tip" (builtins.readFile ../modules/nh.nix) + && lib.strings.hasInfix "_icedos_tip_shown" tipsSampleHead + && lib.strings.hasInfix "[ \"$_icedos_tip_shown\" -eq 1 ] && return 0" tipsSampleHead + ); + + # rpull's redraw must not erase to end of screen, and must save/restore with + # DECSC — SCOSC is not universal, and `\0337` is one octal byte to `printf %b`. + tipsRendererPreservesBar = + let + git = builtins.readFile ../modules/git.nix; + in + expectOk ( + !lib.strings.hasInfix "\\033[J" git + && lib.strings.hasInfix "\\e7" git + && lib.strings.hasInfix "\\e8" git + && !lib.strings.hasInfix "\\033[s" git + && !lib.strings.hasInfix "\\033[u" git + ); + + # The list is embedded via escapeShellArg, so a quote in a tip must not + # survive verbatim into the generated script. + tipsEscapesQuotes = expectOk (!lib.strings.hasInfix "it's" (tipsHead [ "it's broken" ])); + + # The bar prints onto one pinned row, so a newline reaching it would scroll + # the screen and leave the region without its bar. + tipsCollapsesNewlines = expectOk (lib.strings.hasInfix "'💡: a b c'" (tipsHead [ "a\nb\tc" ])); + + # Every tip must be embedded; rotation happens at runtime. `one` etc. appear + # nowhere else in the snippet, so bare presence is a safe check here. + tipsAllEmbedded = + let + head = tipsHead [ + "one" + "two" + "three" + ]; + in + expectOk ( + lib.strings.hasInfix "one" head + && lib.strings.hasInfix "two" head + && lib.strings.hasInfix "three" head + ); + + # Bare strings and title-less records take the lamp default, an explicit + # title renders "title: message", and an empty title drops both. + tipsRenderVariants = expectOk ( + lib.strings.hasInfix "'💡: bare'" (tipsHead [ "bare" ]) + && lib.strings.hasInfix "'💡: implicit'" (tipsHead [ { message = "implicit"; } ]) + && lib.strings.hasInfix "'note: titled'" (tipsHead [ + { + title = "note"; + message = "titled"; + } + ]) + # escapeShellArg leaves a shell-safe word unquoted, so the empty-title tip + # has to be matched bare rather than inside the quotes the others get. + && lib.strings.hasInfix "plain\n)" (tipsHead [ + { + title = ""; + message = "plain"; + } + ]) + && !lib.strings.hasInfix "💡: plain" (tipsHead [ + { + title = ""; + message = "plain"; + } + ]) + ); + + # The prefix must come from CLR_LINE, blanked for a non-terminal stdout, or + # `icedos rebuild | cat` grows literal escape codes. + preludeClearLineStripped = + let + prelude = builtins.readFile ../lib/prelude.sh; + in + expectOk ( + lib.strings.hasInfix "CLR_LINE='\\033[2K\\r'" prelude + && lib.strings.hasInfix "CLR_LINE=''" prelude + && !lib.strings.hasInfix "\\033[2K" (builtins.readFile ../modules/rebuild.nix) + ); + + tipsResizeTrap = expectOk ( + lib.strings.hasInfix "trap _icedos_tip_winch WINCH" (tipsHead [ "one" ]) + ); } From 5c1df3677501ef53037a83f165c91ceaeeab59aa Mon Sep 17 00:00:00 2001 From: IceDBorn Date: Fri, 4 Sep 2026 05:40:59 +0300 Subject: [PATCH 2/2] feat: add opt-in githubViaSsh transport for flake inputs --- AGENTS.md | 3 +- README.md | 10 + build/main.py | 10 + build/options.py | 8 + build/tests/test_options.py | 7 + lib/constants.nix | 21 + lib/genflake.nix | 47 +- lib/icedos.nix | 287 +++++++++++- lib/inputs.nix | 415 ++++++++++++++++- modules/options.nix | 12 + modules/rebuild.nix | 8 + tests/tests.nix | 904 ++++++++++++++++++++++++++++++++++++ 12 files changed, 1681 insertions(+), 51 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fca753d9..3d7a01fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,8 +126,9 @@ Exposed to every module as **`icedosLib`**. | `lib/pkgs.nix` | `pkgs.{mapper,mkConfig,overlaysFromChannel}`. | | `lib/packaging.nix` | `packaging.{extractAppImage,installDesktopEntry}` — shell-snippet builders for `installPhase`/`postFixup` bodies in icedos `package.nix` files. | | `lib/scan.nix` | `getModules`, `scanModules`, `hasModule` — module discovery. `hasModule` aborts on a malformed call (no `name` and no `modules`, or an empty `modules = []`) — always pass a `name` or a non-empty `modules` list. | -| `lib/inputs.nix` | `moduleInputName` (sub-flake-relative path of a module-declared input — `"/"` — the string-context twin of `_getModuleInputs`; a **breaking change** from the old top-level name), `moduleSubFlakeName`, `mkInputName`, flake-revision helpers (`_resolveFlakeRevisionLocked` — pure tail given a lock + node key — and `_resolveFlakeRevisionNested`/`_resolveFlakeRevisionNestedLocked` — two-hop lookup for a sub-flake input, the latter driven directly by the tests), `_parseFlakeUrl`, `_getModuleKey`, `freshInputs`. | +| `lib/inputs.nix` | `moduleInputName` (sub-flake-relative path of a module-declared input — `"/"` — the string-context twin of `_getModuleInputs`; a **breaking change** from the old top-level name), `moduleSubFlakeName`, `mkInputName`, flake-revision helpers (`_resolveFlakeRevisionLocked` — pure tail given a lock + node key — and `_resolveFlakeRevisionNested`/`_resolveFlakeRevisionNestedLocked` — two-hop lookup for a sub-flake input, the latter driven directly by the tests), `_parseFlakeUrl`, `_getModuleKey`, `freshInputs`. **githubViaSsh transport helpers** (emission-only — names, lock keys and cache lookups always stay keyed to the original `github:` url): `_githubUrlToGitSsh` rewrites `github:o/r[/]` to `git+ssh://git@github.com/o/r`, folding in ref spelling and rev reachability so every caller gets a fetchable url (`_githubUrlToGitSshQuiet` is the same rewrite without the ambiguity warning, for urls IceDOS itself supplies); `_gitRefSuffix` is the ONE place a ref is spelled onto a git-scheme url (`?rev=` for a 40-hex rev via `_revSeparator`, else `?ref=`) and carries the `_ambigRef` warning — a site that re-attaches a ref by hand and skips it silently loses that warning; `_withRevReachable` pairs a `?rev=` pin with its branch (`&ref=`) or, when `allRefsFallback` says the rev's provenance is unknown (a hand-written pin, a cache-server rev), `&allRefs=1` — nix resolves a bare `?rev=` against `HEAD` alone, but `allRefs` fetches every ref, so it is opt-in per call site; `_canonicalRepoUrl` gives `git+ssh://git@github.com/o/r` and `github:o/r` one identity so `_resolveFlakeRevisionLocked` keeps the pin across a transport flip; `_urlQueryParams`/`_urlHasParam` back those; `_mapInputUrls` applies a rewrite to a flake-input declaration's own `url` AND its nested `inputs..url` overrides. **A transport flip re-locks every input IceDOS does not pin itself** (nixpkgs, home-manager, channels, overlays, extraFlakes, unpinned module inputs): their `original` changes, so nix re-resolves them to the current tip. | | `lib/icedos.nix` | `fetchModulesRepository`, `resolveExternalDependencyRecursively`, `modulesFromConfig` — the external-repo/dependency engine + input masking. Stamps every module's emitted NixOS config with `#` provenance (`setDefaultModuleLocation`) so nixpkgs eval/type/conflict errors name the source module instead of an anonymous generated location. Emitted module values are deduplicated (`_dedupeNixosModules`): each arrives wrapped in a `setDefaultModuleLocation` shim (`{ _file; imports = [ m ]; }`), and nixpkgs keys modules by `_file`/position, so two IceDOS modules emitting the SAME value would load it twice — core unwraps the shim (only pure `{ _file; imports = [ m ]; }` shims), keys the payload with `_opaqueOrKey` (a structural key, every shape tagged by `kind` (list/attrs/path/str/bool/int/float/null) so `{ }`≠`[ ]`, a path≠a plain string, and `42`≠`42.0`; functions, derivations, and `_type`-bearing property wrappers — `mkIf`/`mkMerge`/`mkForce`/option types — and anything containing them are opaque `null` and never merged; derivations are detected via `type` alone and never forced, since a derivation is cyclic and `drvPath` access can trigger instantiation, and `_type` wrappers are never descended into because the module system drops their unforced branches (`mkIf false`); depth-capped so any other cyclic value degrades to opaque instead of `max-call-depth exceeded`; and wrapped in `tryEval`, which degrades values that `throw`/`assert` when forced — an `abort`, missing attribute, or type error still propagates), and keeps the first occurrence per key at both the per-source flatten (`_extractNixosModules`) and the final external+extra combine (`modulesFromConfig.nixosModules`). Only the `nixosModules` output is deduplicated — `modulesFromConfig.options` (the option-doc index) is intentionally left as-is, and option-declaring payloads are opaque (`lib.mkOption` produces `{ _type = "option"; … }`), so duplicate option declarations still fail loudly rather than being silently merged. The common `inputs..nixosModules.default` case (a path) was already handled by nixpkgs' own identical-path dedup; this closes the identical-attrset-config-value gap (e.g. a shared function-free module emitted by two modules, or a future `nixosModules.default` that is a pure attrset). `modulesFromConfig` also exports `loadedModules` (repo url → module names, the fully-resolved set) which `genflake.nix` injects into the module system as the read-only `icedos.system.loadedModules`. Extra-modules share this: an `icedos.nix` extra-module is labeled `config#`; a plain `default.nix` extra-module is imported by path, so it already carries its real on-disk location. **Module `lib` field contributions:** any `icedos.nix` module — a configured repo's module or a config-root extra module — may extend `icedosLib` with a top-level `lib` field, usually `lib = import ./lib.nix { inherit icedosLib lib; };`. Core folds every contribution into the module-facing lib via `_mergeModuleLibs` (guarded: non-attrset contribution or a duplicate name = a named error). The merge is **two-phase**: during dependency resolution module files are imported with the **base** lib (phase 1 — only `meta` + contributions are forced); once the closure is known, `modulesFromConfig` computes `closureLib = _mergeModuleLibs (deduped ++ extraModulesP1)` over the **fully-resolved closure** and re-imports each module file's outputs (`externalOutputs`) plus the extra modules (`extraModulesP2`) with that merged lib (phase 2). The generated flake's `outputs.icedosLib` **and** `specialArgs.icedosLib` both reuse `modulesFromConfig.closureLib`, so module files and the module system share one merged lib within a single flake evaluation; `repl-context.nix` reads `flake.icedosLib`. A repo pulled in as a dependency — e.g. desktop, a **required** dep of every DE repo — still contributes its helpers because its always-loaded `default` module carries the `lib` field. A contribution file must live inside the kept set of `genflake.nix`'s `configRootKeep`/`configRootKeepDirs` (extra-module/config dirs, declared patches — a `builtins.path` keep-list, **not** git tracking): genflake imports config live, the build stage from the filtered snapshot, so an import that escapes the kept set evaluates at genflake and then fails at build with a bare missing-path error. Upgrade note: the old magic auto-discovery of a config-root `lib.nix` is gone — a user extends `icedosLib` from their own config by adding a `lib` field to one of their extra modules instead. Tradeoffs (inherent): each external module file is imported twice per stage (meta + contributions in phase 1, outputs in phase 2); the merge is evaluated at genflake stage, build-stage `specialArgs`, and repl (fresh per-stage evaluations, but `flake.icedosLib` shares one value with `specialArgs`). The bare `icedosLib` name stays a static set — the merge is a lazy member, so the `default.nix` probe (`attrNames (import icedos.nix …)`) never forces it. A contribution sees only the base lib (passing the merged lib would recurse); repo-to-repo composition happens at the module layer. | +| `lib/icedos.nix` (transport switch) | `githubViaSsh` — tri-state: `ICEDOS_GITHUB_SSH` `1`/`0` (set by `--github-ssh`/`--no-github-ssh`, and **unset** by a rebuild with neither, so a stale exported value cannot override the option) wins, unset falls through to `icedos.system.githubViaSsh`. genflake resolves it and bakes the result into the generated flake's lib import as `githubViaSsh` (`constants.nix`'s `GITHUB_VIA_SSH_BAKED`, mirroring `enableLogging`), because the env var does not reach the build stage's pure eval — without the bake the build stage would fall back to the option and disagree with the emitted urls. It is read from the RAW config plus the flags, before the module system runs, so setting `icedos.system.githubViaSsh` from a NixOS module does nothing; genflake re-applies the resolved value with `mkForce` at both stages so the evaluated option always matches the emitted urls. `_reachableRev` wraps `_withRevReachable` but only where the switch actually rewrote the url (`emitUrl != fetchUrl`), so a `git+…` url the user wrote themselves is never re-locked or all-refs-fetched because a different input moved to ssh. | | `lib/config/load-user-config.nix` | Parse `config.toml` + every `configs/*.toml` (enumerated by `lib/config/config-files.nix`), strict-merge (duplicate scalar key across files = error; lists concatenated). Top-level `icedos` is schema-validated by `modules/options.nix`; **every other top-level table is applied as raw NixOS config** (see passthrough below). | | `lib/config/extra-options.nix` | `extraOptions.{marker,declare,inject}` — translates a user's `[extraOptions]` TOML table into real NixOS option declarations + genflake-stage value injection (see §6). | | `lib/config/config-files.nix` | Bare `configRoot: [{rel;content;}]` — the ordered, pre-parsed config set (`config.toml` + each enabled `configs/*.toml`), shared by `load-user-config.nix` and `modules/options.nix` so both load the identical set. Applies the per-file `enable = false` opt-out and strips the `enable` key. | diff --git a/README.md b/README.md index 8728215b..376481b7 100644 --- a/README.md +++ b/README.md @@ -497,6 +497,14 @@ With no flags this is a `switch`: it builds your configuration and activates it Rebuilds can pass a GitHub token to nix as a `github.com` access token (higher API rate limits, private `github:` inputs). A literal token is resolved first: `--github-token `, then the `ICEDOS_GITHUB_TOKEN` env var. Otherwise a token file is used: `--github-token-path `, the `ICEDOS_GITHUB_TOKEN_PATH` env var, or the `icedos.system.githubTokenPath` option (default `/etc/icedos-github-token`). A token file your user can't read is fetched with `sudo cat` — you'll be asked for your sudo password once (on a terminal); in non-interactive sessions the rebuild falls back to running without the token. The toolset wrapper resolves the same file before invoking the orchestrator and exports `NIX_CONFIG`, so nix calls that precede it (e.g. a stale-lock `nix run` re-resolving `github:` inputs) authenticate as well. Avoid `icedos.system.githubToken` (a literal token in `config.toml`): it gets baked into the world-readable nix store, and every rebuild warns about it. +Alternatively, `--github-ssh` (or the `icedos.system.githubViaSsh` option, which `--no-github-ssh` turns off for a single run) rewrites every `github:` input of the generated state flake — repositories, module inputs, channels, overlays, `extraFlakes`, `nixpkgs`, `home-manager` — to `git+ssh://git@github.com/...`, so your ssh key authenticates those fetches. It is not a replacement for the token: the config flake's own inputs (e.g. `icedos` core) still resolve over https, so the token is still read and passed to nix exactly as above. The build's nix may run as root, so make sure the key is reachable there too (`ssh-add` your key, or point `GIT_SSH_COMMAND` at an agent). The two flags are the only per-run route: they set `ICEDOS_GITHUB_SSH` (`1`/`0`) for the flake-generation step, and a rebuild with neither flag clears it, so a value left exported in your shell cannot quietly override the option on later rebuilds. + +Under the switch, an inline ref on a `github:` url becomes a query on the ssh url: a full 40-hex rev (any case) pins `?rev=`, a bare name pins `?ref=` — which nix resolves as the `refs/heads/` branch. A tag pinned as a bare name is therefore silently read as a branch of that name and fails to fetch. Nothing can tell the two apart at eval time, so every unqualified ref traces a warning; qualify it — `refs/heads/` or `refs/tags/` — and the warning goes away. Both qualified forms also resolve on the plain https path, so they are safe to leave in `config.toml`, which is shared by both transports. Short revs must be expanded to the full 40-hex hash. + +A `?rev=` pin is resolved by nix against `HEAD` alone, so every url the switch rewrites also carries the branch it was pinned from (`&ref=`), or `&allRefs=1` when no branch is known — otherwise a rev living outside the default branch's history would fail to fetch. `&allRefs=1` makes nix fetch *every* ref of that repo, so a rev-pinned `nixpkgsChannel` (`github:nixos/nixpkgs/<40-hex>`) becomes an all-refs clone of nixpkgs; pin such channels by branch name where you can. Urls that were already `git+...` in your `config.toml` keep their transport and their rev pins. One unrelated fix rides along for them regardless of the switch: a `narHash` pin is no longer spelled onto a git-scheme url, because nix has no such query parameter there and folds it into the remote address, producing something `git ls-remote` cannot resolve. Such an input is now emitted unpinned instead of unfetchable. + +**Flipping the switch re-locks every input IceDOS does not pin itself.** Changing the transport changes each input's recorded `original`, and nix re-locks anything whose `original` no longer matches the lock — resolving the current tip. Repository inputs and patched-module `_source` inputs keep their pin (IceDOS bakes the rev into those and matches the two spellings), but `nixpkgs`, `home-manager`, channels, overlays, `extraFlakes` and unpinned module inputs do not. So turning the switch on, and turning it off again with `--no-github-ssh`, each act as an update for those inputs. Treat a flip as an update, not as a free toggle: `--no-github-ssh` is there to get a build through without ssh, not to leave your lock untouched. Rewriting `nixpkgs`/`home-manager` channels also turns their tarball fetch into a full git clone, so the first ssh build transfers noticeably more than before. + #### Action flags — *what kind of build* | Flag | Effect | Typical use | @@ -532,6 +540,8 @@ Rebuilds can pass a GitHub token to nix as a `github.com` access token (higher A | `--target ` | Deploy/activate the built system on a remote host (pairs with `--builder`). | | `--github-token ` | Use this literal GitHub token for nix's `github.com` fetches. Wins over every file-based source. (Visible in the process list while the rebuild runs.) | | `--github-token-path ` | Use `` (a file containing a GitHub token) for nix's `github.com` fetches. Overrides `ICEDOS_GITHUB_TOKEN_PATH` and `icedos.system.githubTokenPath`. | +| `--github-ssh` | Fetch the generated state flake's `github:` inputs over `git+ssh://git@github.com/...` with your ssh key instead of the token. Per-run opt-in (`icedos.system.githubViaSsh` turns it on permanently); the config flake's own inputs still use the token. | +| `--no-github-ssh` | Force https for this run, overriding `icedos.system.githubViaSsh`. Use it to get a build through without ssh (e.g. while debugging an agent). Not free: flipping the transport re-locks every input IceDOS does not pin itself. | | `--nh-args ...` | Forward extra args to `nh os` (place after other flags; consumes until `--build-args`). | | `--build-args ...` | Forward all remaining args to the final rebuild command. **Must be last.** | | `--genflake-only` | *(advanced)* Generate and lock the state flake, then exit without building. | diff --git a/build/main.py b/build/main.py index f8032a62..8086c4e9 100644 --- a/build/main.py +++ b/build/main.py @@ -97,6 +97,16 @@ def main(argv: list[str] | None = None) -> int: os.environ["NIX_CONFIG"] = BASE_NIX_CONFIG if opts.logs: os.environ["ICEDOS_LOGGING"] = "1" + # Read by the genflake eval, which bakes the resolved value into the + # generated flake (the env var does not reach the build stage's pure eval). + # "0" is not the same as unset: it overrides a config.toml `true`. With no + # flag the variable is REMOVED rather than left alone, so a value exported + # in the user's shell cannot silently override the option on every later + # rebuild — the flags are the only way to reach it. + if opts.github_ssh is None: + os.environ.pop("ICEDOS_GITHUB_SSH", None) + else: + os.environ["ICEDOS_GITHUB_SSH"] = "1" if opts.github_ssh else "0" env = from_environment() trace = opts.trace diff --git a/build/options.py b/build/options.py index 875df070..0a010e3c 100644 --- a/build/options.py +++ b/build/options.py @@ -20,6 +20,8 @@ class Options: repos_select: list[str] = field(default_factory=list) github_token: str | None = None github_token_path: str | None = None + # Tri-state: None = not asked for, so icedos.system.githubViaSsh decides. + github_ssh: bool | None = None nh_build_args: list[str] = field(default_factory=list) global_build_args: list[str] = field(default_factory=list) logs: bool = False @@ -141,6 +143,12 @@ def parse_args(argv: list[str]) -> tuple[Options, list[str]]: _die("error: --github-token-path requires a path") opts.github_token_path = argv[i + 1] i += 2 + elif arg == "--github-ssh": + opts.github_ssh = True + i += 1 + elif arg == "--no-github-ssh": + opts.github_ssh = False + i += 1 elif arg == "--logs": opts.logs = True i += 1 diff --git a/build/tests/test_options.py b/build/tests/test_options.py index 84698a20..125e7c8a 100644 --- a/build/tests/test_options.py +++ b/build/tests/test_options.py @@ -94,6 +94,13 @@ def test_github_token_flags_are_captured(self): self.assertEqual(opts.github_token, "tok") self.assertEqual(opts.github_token_path, "/p") + def test_github_ssh_flag_is_captured(self): + # Tri-state: unset must stay None so config.toml decides, and + # --no-github-ssh must be distinguishable from "not asked for". + self.assertTrue(_parse(["--github-ssh"])[0].github_ssh) + self.assertIs(_parse(["--no-github-ssh"])[0].github_ssh, False) + self.assertIsNone(_parse([])[0].github_ssh) + def test_unknown_arg_exits(self): with self.assertRaises(SystemExit): _parse(["--nope"]) diff --git a/lib/constants.nix b/lib/constants.nix index e36fae19..c328f2fd 100644 --- a/lib/constants.nix +++ b/lib/constants.nix @@ -1,5 +1,6 @@ { enableLogging ? false, + githubViaSsh ? null, ... }: let @@ -13,6 +14,26 @@ in GITHUB_TOKEN_PATH = "/etc/icedos-github-token"; ENABLE_LOGGING = enableLogging || (getEnv "ICEDOS_LOGGING") == "1"; + # Tri-state override for `icedos.system.githubViaSsh`; `null` = nothing + # overrides the option. `--github-ssh`/`--no-github-ssh` set ICEDOS_GITHUB_SSH + # to "1"/"0", and genflake bakes the value it resolved into the generated + # flake's lib import (like enableLogging) because the env var does not survive + # into the build stage's pure eval — without the bake, the build stage would + # silently fall back to the option and disagree with the urls already emitted. + GITHUB_VIA_SSH_BAKED = + if githubViaSsh != null then + githubViaSsh + else + let + env = getEnv "ICEDOS_GITHUB_SSH"; + in + if env == "1" then + true + else if env == "0" then + false + else + null; + ICEDOS_CONFIG_ROOT = getEnv "ICEDOS_CONFIG_ROOT"; ICEDOS_FLAKE_INPUTS = getEnv "ICEDOS_FLAKE_INPUTS"; ICEDOS_ROOT = getEnv "ICEDOS_ROOT"; diff --git a/lib/genflake.nix b/lib/genflake.nix index f39e48eb..10ae9e01 100644 --- a/lib/genflake.nix +++ b/lib/genflake.nix @@ -39,10 +39,13 @@ let inherit (icedosLib) ICEDOS_CONFIG_ROOT ICEDOS_STATE_DIR + _githubUrlToGitSsh + _githubUrlToGitSshQuiet _loadModulesFromRepo _parseFlakeUrl _repoSelected _selectedRepos + githubViaSsh injectIfExists mkInputName modulesFromConfig @@ -148,7 +151,10 @@ let ]; }; - value = { inherit (e) url; }; + value = { + # Transport switch: overlay names stay keyed to the original url. + url = if githubViaSsh then _githubUrlToGitSsh e.url else e.url; + }; }) (filter isOverlayUrlMode overlayChannels); # extraFlake names become root inputs, so a collision with a channel, overlay, @@ -190,7 +196,22 @@ let name = "nixpkgs"; value = { - url = icedos.system.nixpkgsChannel or "github:nixos/nixpkgs/nixos-unstable"; + url = + let + default = "github:nixos/nixpkgs/nixos-unstable"; + channel = icedos.system.nixpkgsChannel or default; + in + if !githubViaSsh then + channel + # IceDOS' own default is a branch the user never wrote, so nagging them + # to qualify it is noise. The test is on the VALUE, so a config that + # spells the default out is exempt too — it names the same known-good + # branch, and the emitted url is identical either way. Any other channel + # still gets the warning. + else if channel == default then + _githubUrlToGitSshQuiet channel + else + _githubUrlToGitSsh channel; }; }; @@ -198,7 +219,11 @@ let name = "home-manager"; value = { - url = "github:nix-community/home-manager"; + url = + if githubViaSsh then + _githubUrlToGitSsh "github:nix-community/home-manager" + else + "github:nix-community/home-manager"; inputs.nixpkgs.follows = "nixpkgs"; }; }; @@ -212,7 +237,9 @@ let extraModulesInputs ++ (map (c: { inherit (c) name; - value = { inherit (c) url; }; + value = { + url = if githubViaSsh then _githubUrlToGitSsh c.url else c.url; + }; }) channels) ++ overlayInputs ++ [ @@ -249,6 +276,13 @@ let # No default (readOnly), so `toJSON evaluated` would throw without this. { icedos.system.isFirstBuild = isFirstBuild; } + # `--github-ssh`/`--no-github-ssh` override the option, so re-apply the + # resolved value: a module (or `icedos config get`) reading this must not + # see `false` while every emitted url is `git+ssh://`. Priority 10 beats + # even a user's `mkForce` (50), so a module-set value is the documented + # no-op instead of an opaque "conflicting definition values". + { icedos.system.githubViaSsh = lib.mkOverride 10 githubViaSsh; } + # Computed from the RAW config, so injecting it here cannot recurse. { icedos.system.loadedModules = modulesFromConfig.loadedModules; @@ -477,6 +511,10 @@ assert githubTokenStoreWarning; inherit lib pkgs inputs; config = icedos; enableLogging = ${boolToString icedosLib.ENABLE_LOGGING}; + # Baked, not re-read from the env: ICEDOS_GITHUB_SSH does not reach + # the build stage's pure eval, and a disagreement there would mean + # evaluating urls that differ from the ones emitted below. + githubViaSsh = ${boolToString githubViaSsh}; self = toString inputs.icedos-core; }; @@ -592,6 +630,7 @@ assert githubTokenStoreWarning; ) overlayChannels} { icedos.system.isFirstBuild = ${boolToString isFirstBuild}; } + ({ lib, ... }: { icedos.system.githubViaSsh = lib.mkOverride 10 ${boolToString githubViaSsh}; }) ${concatStringsSep "\n" (map (text: "(${text})") nixosModulesText)} diff --git a/lib/icedos.nix b/lib/icedos.nix index 3513708d..ad465fb0 100644 --- a/lib/icedos.nix +++ b/lib/icedos.nix @@ -40,6 +40,43 @@ let acc // { ${baseUrl} = value; } ) { } repositories; + # Opt-in: rewrite `github:` input urls to `git+ssh://` so ssh keys (not the + # token) authenticate fetches. Genflake-only: nothing bakes it, and every + # consumer below runs at genflake. + # `--github-ssh`/`--no-github-ssh` win over the option (so a config.toml + # `true` can be turned OFF for one run); genflake bakes what it resolved into + # the generated flake's lib import, so the build stage reads the same value + # instead of silently falling back to the option — `getEnv` is "" there. + githubViaSsh = + if icedosLib.GITHUB_VIA_SSH_BAKED != null then + icedosLib.GITHUB_VIA_SSH_BAKED + else + config.system.githubViaSsh or false; + + # `_withRevReachable`, but only for urls the switch actually rewrote. Gating + # on `githubViaSsh` alone would put `allRefs=1` on a `git+…` url the user + # wrote themselves — re-locking it, and fetching every ref of it, because a + # DIFFERENT input moved to ssh. + _reachableRev = + { + url, + ref ? null, + rewritten, + allRefsFallback ? false, + quiet ? false, + }: + if rewritten then + icedosLib._withRevReachable { + inherit + url + ref + allRefsFallback + quiet + ; + } + else + url; + # repo baseUrl -> `fetchOptionalDependencies`: applies to every module of # that repo, transitively pulled ones included. Opt-in, so any `true` wins. repoFetchOptional = _foldRepoFlag { @@ -194,8 +231,20 @@ let inherit (fetchParsed) baseUrl; inlineRef = fetchParsed.ref; + # Emission-only: names and lock keys stay keyed to the original url; the + # rewritten form appears only where the flake text spells the target. + emitBase = if githubViaSsh then icedosLib._githubUrlToGitSsh baseUrl else baseUrl; + + # The ref the emitted url carries. `_urlRef` (not `inlineRef`) because + # the ssh rewrite also honours a query-form `?ref=`, and a 40-hex ref + # becomes a `rev`, so it is not a ref at all. The lock pin is only + # reused while this still matches what the lock recorded. + urlRef = icedosLib._urlRef _fetchUrl; + emitRef = if urlRef == null || icedosLib._revSeparator urlRef == "?rev=" then null else urlRef; + lockRev = icedosLib._resolveFlakeRevision { - url = baseUrl; + url = emitBase; + ref = emitRef; inherit repoName; }; @@ -205,11 +254,40 @@ let if lockRev != "" then lockRev else if inlineRef != null then - if icedosLib._urlIsGitScheme baseUrl then "?rev=${inlineRef}" else "/${inlineRef}" + if icedosLib._urlIsGitScheme emitBase then + icedosLib._gitRefSuffix { + ref = inlineRef; + url = emitBase; + } + else + "/${inlineRef}" else ""; - flakeUrl = icedosLib._appendRevSuffix baseUrl flakeRev; + # `emitBase` is ref-stripped, so a lock rev would otherwise emit a bare + # `?rev=` that nix can only resolve against HEAD. + # A lock node that is one of our own rev-pinned emissions cannot say + # which ref its rev came from, so the current one must not be attached + # to it: `?rev=&ref=dev` aborts and never self-heals. + lockRefConfirmed = icedosLib._lockRefConfirmed { + inherit repoName; + ref = emitRef; + }; + + refUsable = lockRev == "" || lockRefConfirmed; + + flakeUrl = _reachableRev { + url = icedosLib._appendRevSuffix emitBase flakeRev; + ref = if refUsable then emitRef else null; + rewritten = emitBase != baseUrl; + # With no inline ref the rev is whatever nix resolved from this url's + # default branch, so `HEAD` finds it. A NAMED inline ref narrows the + # lookup itself, so only a 40-hex pin — a ref that cannot be spelled + # as one — is left with nowhere to look but every ref. An unconfirmed + # lock rev needs it too: one expensive fetch on the first ssh build, + # after which the lock records the ref and the cheap path returns. + allRefsFallback = (urlRef != null && emitRef == null) || !refUsable; + }; # Fresh at genflake, the locked input at build — where a patched repo's # input already IS the patched tree (see `fetchUrl`). @@ -242,6 +320,9 @@ let url = nameParsed.baseUrl; # Patched repo -> locked `path:` input; unpatched keeps its upstream url. fetchUrl = if hasPatches then "path:${patchedPath}" else baseUrl; + # config.toml's inline ref for `fetchUrl` ("" when there is none). Only + # `_modulesToInputs` reads it, to keep a git-scheme `?rev=` pin reachable. + fetchRef = if hasPatches || inlineRef == null then "" else inlineRef; # narHash of the realised tree; forcing it never touches `icedosModules`. inherit (moduleFlake) narHash; files = flatten modules; @@ -263,10 +344,22 @@ let # Original `url` names the input (stable across overrideUrl toggles); # `fetchUrl` (override-applied) is what the flake actually fetches. fetchUrl = _repoInfo.fetchUrl or url; + # Emission spelling of the fetch target (transport switch). + emitUrl = if githubViaSsh then icedosLib._githubUrlToGitSsh fetchUrl else fetchUrl; flakeRev = if (hasAttr "rev" _repoInfo) then - if icedosLib._urlIsGitScheme fetchUrl then "?rev=${_repoInfo.rev}" else "/${_repoInfo.rev}" - else if (hasAttr "narHash" _repoInfo) && !(icedosLib.stringStartsWith "path:" fetchUrl) then + if icedosLib._urlIsGitScheme emitUrl then + "${icedosLib._revSeparator _repoInfo.rev}${_repoInfo.rev}" + else + "/${_repoInfo.rev}" + # Not for a git scheme: nix has no `narHash` query param there, so it + # folds the whole thing into the REMOTE url and emits something + # `git ls-remote` cannot resolve. Unpinned beats unfetchable. + else if + (hasAttr "narHash" _repoInfo) + && !(icedosLib.stringStartsWith "path:" emitUrl) + && !(icedosLib._urlIsGitScheme emitUrl) + then "?narHash=${_repoInfo.narHash}" else ""; @@ -275,7 +368,24 @@ let name = icedosLib.mkInputName { parts = [ url ]; }; value = { - url = icedosLib._appendRevSuffix fetchUrl flakeRev; + url = _reachableRev { + url = icedosLib._appendRevSuffix emitUrl flakeRev; + ref = _repoInfo.fetchRef or ""; + rewritten = emitUrl != fetchUrl; + # `fetchModulesRepository` already spelled this same repo's ref on + # this same url; the warning is about the config entry, so once per + # rebuild is the point. + quiet = true; + # `_repoInfo.rev` is `baseFlake.rev` — the rev nix just resolved + # from this url — so it is reachable from `HEAD`, and a named + # `fetchRef` narrows it directly. Only a 40-hex `fetchRef` is left + # with nowhere to look but every ref. + allRefsFallback = + let + fetchRef = _repoInfo.fetchRef or ""; + in + fetchRef != "" && icedosLib._revSeparator fetchRef == "?rev="; + }; }; } ) (filter shouldIncludeAsInput modules); @@ -399,23 +509,80 @@ let patches = patchesFor i; hasPatches = patches != [ ]; + # Emission spelling (transport switch): ref-stripped base so lock + # `original.url` matches; the original url drives name/cache keys. + srcUrl = inputs.${i}.url or ""; + emitSrcBase = + if githubViaSsh then + icedosLib._githubUrlToGitSsh _patchSrcParsed.baseUrl + else + _patchSrcParsed.baseUrl; + # `override` is dead but still stripped: an old pinned repo would # otherwise leak the key into the sub-flake and fail opaquely. - decl = removeAttrs inputs.${i} [ - "override" - "patches" - ]; + # Ref-free ssh base + inline ref as query suffix, so a lock-less + # (`_cachePin == ""`) input still pins the author's ref. + decl = + removeAttrs + ( + inputs.${i} + // (lib.optionalAttrs githubViaSsh ( + (lib.optionalAttrs (inputs.${i} ? inputs) { + # Nested overrides are fetched too, so the switch has to + # reach them or a private nested `github:` input still + # goes out over unauthenticated https. + inputs = lib.mapAttrs (_: icedosLib._mapInputUrls icedosLib._githubUrlToGitSsh) inputs.${i}.inputs; + }) + // lib.optionalAttrs (emitSrcBase != _patchSrcParsed.baseUrl) { + url = _reachableRev { + url = + if _patchSrcParsed.ref != null then + icedosLib._appendRevSuffix emitSrcBase _patchSrcRefSuffix + else + emitSrcBase; + ref = _patchSrcParsed.ref; + rewritten = true; + allRefsFallback = true; + }; + } + )) + ) + [ + "override" + "patches" + ]; # The patched `src` and the `_source` url bake the same locked rev, # so a sub-flake re-lock cannot disagree with the realised tree. - _patchSrcParsed = icedosLib._parseFlakeUrl inputs.${i}.url; + _patchSrcParsed = icedosLib._parseFlakeUrl srcUrl; # Pre-lock fallback pin (the author's `github:o/r/`); once the # lock has the rev it wins, so a first build self-heals next run. _patchSrcInlineRef = if _patchSrcParsed.ref != null then _patchSrcParsed.ref else ""; + # ONE thunk for the author's ref: `decl` and `_patchSrcUrl` both + # spell it, and two calls would trace the ambiguity warning twice. + _patchSrcRefSuffix = + if _patchSrcParsed.ref == null then + "" + else + icedosLib._gitRefSuffix { + ref = _patchSrcParsed.ref; + url = emitSrcBase; + }; + + # Same rule for a module input's `_source`: an author's ref edit + # must drop the pin rather than pair a stale rev with a new ref. + _patchSrcRef = icedosLib._urlRef srcUrl; + _patchSrcEmitRef = + if _patchSrcRef == null || icedosLib._revSeparator _patchSrcRef == "?rev=" then + null + else + _patchSrcRef; + _patchSrcLockRev = icedosLib._resolveFlakeRevisionNested { - url = _patchSrcParsed.baseUrl; + url = emitSrcBase; + ref = _patchSrcEmitRef; inherit subFlakeName; inputName = "${i}_source"; }; @@ -445,15 +612,71 @@ let # Lock rev wins, then the cache pin, then the author's ref; only the # lock hands back a pre-formed suffix, the other two are bare revs. - _patchSrcUrl = - if _patchSrcLockRev != "" then - icedosLib._appendRevSuffix _patchSrcParsed.baseUrl _patchSrcLockRev + # github pins use a path segment; git-scheme pins use query params: + # `?rev=` for a 40-hex hash, `?ref=` for a name. `suffix` is the + # pre-spelled git-scheme form, so a shared thunk can be passed in + # rather than re-spelling (and re-warning about) the same ref. + _patchSrcPin = + { + rev, + suffix, + }: + if icedosLib._urlIsGitScheme emitSrcBase then + icedosLib._appendRevSuffix emitSrcBase suffix else icedosLib._appendRev { - inherit (_patchSrcParsed) baseUrl; - rev = if _cachePin != "" then _cachePin else _patchSrcInlineRef; + baseUrl = emitSrcBase; + inherit rev; }; + # The lock rev wins over the cache pin above, so "a cache pin + # exists" is not the same as "the url used it". + _usedCachePin = _patchSrcLockRev == "" && _cachePin != ""; + + # Same rule as the repository path: a lock rev may predate a ref + # edit, so only a lock that records the ref can vouch for it. + _patchSrcRefUsable = + _patchSrcLockRev == "" + || icedosLib._lockRefConfirmedNested { + inherit subFlakeName; + inputName = "${i}_source"; + ref = _patchSrcEmitRef; + }; + + _patchSrcUrl = _reachableRev { + url = + if _patchSrcLockRev != "" then + icedosLib._appendRevSuffix emitSrcBase _patchSrcLockRev + else if _cachePin != "" then + _patchSrcPin { + rev = _cachePin; + suffix = icedosLib._gitRefSuffix { + ref = _cachePin; + url = emitSrcBase; + }; + } + else if _patchSrcInlineRef != "" then + # The shared thunk, so the warning `decl` may already have + # traced for this same ref is not repeated. + _patchSrcPin { + rev = _patchSrcInlineRef; + suffix = _patchSrcRefSuffix; + } + else + emitSrcBase; + # Both fields must describe the pin the url ABOVE actually used, + # so they follow the same branch order it does. A lock rev was + # resolved FROM the author's ref and is reachable from it; a + # cache-server rev was not, so narrowing to that branch would + # abort the fetch — hand over no ref and let `allRefs` find it. + ref = if _usedCachePin || !_patchSrcRefUsable then null else _patchSrcRef; + rewritten = emitSrcBase != _patchSrcParsed.baseUrl; + # Only a cache rev, or a 40-hex author pin that cannot be spelled + # as a ref, is left with nowhere to look but every ref. + allRefsFallback = + _usedCachePin || (_patchSrcRef != null && _patchSrcEmitRef == null) || !_patchSrcRefUsable; + }; + patchedInputSource = _mkPatchedSource { name = "${subFlakeName}-${i}-patched"; src = getFlake _patchSrcUrl |> toString; @@ -488,9 +711,18 @@ let else decl // { - url = icedosLib._appendRev { - inherit (_patchSrcParsed) baseUrl; - rev = _cachePin; + url = _reachableRev { + url = icedosLib._appendRev { + baseUrl = emitSrcBase; + rev = _cachePin; + separator = + if icedosLib._urlIsGitScheme emitSrcBase then icedosLib._revSeparator _cachePin else "/"; + }; + # The pin IS the cache-server rev, which the + # author's branch is not known to contain. + ref = null; + rewritten = emitSrcBase != _patchSrcParsed.baseUrl; + allRefsFallback = true; }; }; } @@ -917,10 +1149,17 @@ let flakes: map (f: { name = f.name; - value = removeAttrs f [ - "name" - "modulesToLoad" - ]; + value = + removeAttrs + ( + # `_mapInputUrls` also reaches the nested `inputs..url` + # overrides an extraFlake may carry. + if githubViaSsh then icedosLib._mapInputUrls icedosLib._githubUrlToGitSsh f else f + ) + [ + "name" + "modulesToLoad" + ]; }) flakes; # Masked entries exposing each extra flake under its bare `name`, in the diff --git a/lib/inputs.nix b/lib/inputs.nix index 77268790..840a88bf 100644 --- a/lib/inputs.nix +++ b/lib/inputs.nix @@ -6,7 +6,10 @@ let inherit (builtins) + elemAt + filter fromJSON + head pathExists readFile replaceStrings @@ -109,6 +112,13 @@ rec { "?rev=${lock.nodes.${repoName}.locked.rev}" else if hasRev then "/${lock.nodes.${repoName}.locked.rev}" + # A git-scheme url has no `narHash` query param: nix folds it into the + # REMOTE url and emits something `git ls-remote` cannot resolve. Unpinned + # beats unfetchable (mirrors the same guard in `_modulesToInputs`). + # Deliberately NOT gated on githubViaSsh: a hand-written `git+…` url was + # always broken this way, and emitting an unfetchable pin is never right. + else if _urlIsGitScheme url then + "" else "?narHash=${lock.nodes.${repoName}.locked.narHash}"; @@ -119,28 +129,52 @@ rec { url, lock, nodeKey, + # The ref this url will actually be emitted with (`null` for none, and for + # a 40-hex pin — that becomes a `rev`, never a `ref`). When the lock node + # recorded a ref of its own it must still agree, or the pinned rev may no + # longer be reachable from the ref we are about to ask for: editing + # `github:o/r/dev` to `github:o/r` would otherwise pair rev D (on `dev`) + # with `HEAD`, and a git-scheme fetch aborts instead of re-resolving. + ref ? null, skipUpdateEnvCheck ? false, selectedRepos ? _selectedRepos, }: let + orig = lock.nodes.${nodeKey}.original or null; + lockedOriginalMatches = let - orig = lock.nodes.${nodeKey}.original or null; type = orig.type or ""; + # Transport-agnostic identity: githubViaSsh only changes HOW the same + # repo is fetched. Comparing raw spellings would read a flag flip as a + # different input, drop the pin, and silently re-resolve the branch tip + # on a plain rebuild (and again on every flip back). + canonUrl = _canonicalRepoUrl url; in orig != null && ( - if type == "github" || type == "gitlab" || type == "sourcehut" then - url == "${type}:${orig.owner}/${orig.repo}" - else if type == "path" then + if type == "path" then url == "path:${orig.path}" - else if type == "git" then - url == orig.url || url == "git+${orig.url}" else - false + let + canonOrig = _canonicalLockOriginal orig; + in + (canonOrig != null && canonOrig == canonUrl) + # Verbatim git urls still match without going through the canonical + # form, which only knows the ssh spelling of a forge repo. + || (type == "git" && (url == orig.url || url == "git+${orig.url}")) ); + # `github:` cannot spell a ref and a rev together, so IceDOS' own + # rev-pinned emission (`github:o/r/`) is recorded as `{owner; repo; + # rev;}` with NO ref. Reading that absence as "the ref changed" would + # unpin, re-resolve the branch tip, re-pin, and unpin again on alternating + # rebuilds. The git-scheme spelling keeps `?rev=&ref=` together, so an + # edit is still caught there — which is where a bare `?rev=` would abort. + refUnknowable = (orig ? rev) && !(orig ? ref); + + refMatches = refUnknowable || (orig.ref or null) == ref; in - if !lockedOriginalMatches then + if !lockedOriginalMatches || !refMatches then "" else _getRevisionFromLock { @@ -158,6 +192,7 @@ rec { { url, repoName, + ref ? null, }: let lock = _readFlakeLock; @@ -166,7 +201,7 @@ rec { "" else _resolveFlakeRevisionLocked { - inherit url lock; + inherit url lock ref; nodeKey = repoName; }; @@ -178,20 +213,10 @@ rec { lock, subFlakeName, inputName, + ref ? null, }: let - # A lock `inputs` value is either a node key (string) or a follows path - # (array); only the string form resolves a hop. - hop = - attrs: name: - let - v = attrs.${name} or null; - in - if builtins.isString v then v else null; - - subKey = hop (lock.nodes.root.inputs or { }) subFlakeName; - - inputKey = if subKey != null then hop (lock.nodes.${subKey}.inputs or { }) inputName else null; + inputKey = _lockNestedNodeKey { inherit lock subFlakeName inputName; }; in # Its own flag, so clearing the sub-flake bake never unpins repo urls (and # --update-repos-only never unpins module inputs). @@ -203,11 +228,78 @@ rec { "" else _resolveFlakeRevisionLocked { - inherit url lock; + inherit url lock ref; nodeKey = inputKey; skipUpdateEnvCheck = true; }; + # Does the lock CONFIRM that `ref` is where this node's rev came from? Our own + # rev-pinned emission (`github:o/r/`, `original = {owner; repo; rev;}`) + # cannot say — pairing its rev with the config's CURRENT ref would emit a + # lookup that aborts rather than re-resolving, since the two need not agree + # after a config edit. The git-scheme spelling records both, so from the first + # ssh build onward the answer is yes and the cheap `&ref=` path is used again. + _lockRefConfirmedLocked = + { + lock, + nodeKey, + ref ? null, + }: + let + orig = lock.nodes.${nodeKey}.original or null; + in + orig != null && ref != null && (orig.ref or null) == ref; + + _lockRefConfirmed = + { + repoName, + ref ? null, + }: + let + lock = _readFlakeLock; + in + lock != null + && _lockRefConfirmedLocked { + inherit lock ref; + nodeKey = repoName; + }; + + # root -> sub-flake node key -> input node key. `null` on any missing hop (or + # a follows-array where a key was expected). + _lockNestedNodeKey = + { + lock, + subFlakeName, + inputName, + }: + let + hop = + attrs: name: + let + v = attrs.${name} or null; + in + if builtins.isString v then v else null; + + subKey = hop (lock.nodes.root.inputs or { }) subFlakeName; + in + if subKey != null then hop (lock.nodes.${subKey}.inputs or { }) inputName else null; + + _lockRefConfirmedNested = + { + subFlakeName, + inputName, + ref ? null, + }: + let + lock = _readFlakeLock; + nodeKey = + if lock == null then null else _lockNestedNodeKey { inherit lock subFlakeName inputName; }; + in + nodeKey != null + && _lockRefConfirmedLocked { + inherit lock ref nodeKey; + }; + # Revision lookup for a module input, which lives one level down inside its # module's sub-flake. _resolveFlakeRevisionNested = @@ -215,6 +307,7 @@ rec { url, subFlakeName, inputName, + ref ? null, }: let lock = _readFlakeLock; @@ -228,6 +321,7 @@ rec { lock subFlakeName inputName + ref ; }; @@ -358,6 +452,283 @@ rec { ref = builtins.elemAt match 3; }; + # The ref a url actually names, whichever way it spells it: `o/r/` as a + # path segment or `?ref=` in the query. `null` when it names none. + _urlRef = + url: + let + parsed = _parseFlakeUrl url; + hits = map (lib.removePrefix "ref=") ( + filter (p: lib.hasPrefix "ref=" p) (_urlQueryParams parsed.baseUrl) + ); + in + if parsed.ref != null then + parsed.ref + else if hits == [ ] then + null + else + head hits; + + # Git-scheme ref spelling: `?rev=` only for a 40-hex rev (any case), else + # `?ref=` — a bare one is refs/heads/, so a tag needs refs/tags/. + # Both qualified forms also resolve in a `github:` url (verified against the + # API and `nix flake metadata`), so they are safe to write in config.toml, + # which is shared by both transports; see `_gitRefSuffix`. + _revSeparator = ref: if builtins.match "[0-9a-fA-F]{40}" ref != null then "?rev=" else "?ref="; + + # `a=1&b=2` of a url's query, [] when it carries none. + _urlQueryParams = + url: + let + m = builtins.match "[^?]*[?](.*)" url; + in + if m == null then [ ] else lib.splitString "&" (builtins.elemAt m 0); + + _urlHasParam = param: url: lib.any (p: lib.hasPrefix "${param}=" p) (_urlQueryParams url); + + # Nix resolves a git-scheme `?rev=` against `HEAD` ALONE, so a rev living on + # any other branch aborts with "Cannot find Git revision '' in ref + # 'HEAD' ... add allRefs = true". `github:` never had that limit, so a + # rewritten url must carry a way to reach its rev. + # + # `ref` must be a branch the rev is KNOWN to live on — the one it was resolved + # from. Passing an unrelated ref (say, the author's declared branch next to a + # rev the cache server published from somewhere else) narrows the lookup to a + # branch that may not contain it, which aborts the fetch; pass `null` there. + # + # `allRefsFallback` says whether a rev with no such branch NEEDS the escape + # hatch. It is true only where the rev's provenance is unknown — a + # hand-written 40-hex pin, or a rev the cache server published — because such + # a rev may live outside the default branch's history. A rev nix itself + # resolved from this very url is reachable from `HEAD` by construction, and + # `allRefs=1` there would make every rebuild fetch every branch, tag and + # `refs/pull/*` of every repo for nothing. + # + # No-op for non-git schemes and for urls that already resolve (no rev pin, or + # an explicit ref/allRefs). + _withRevReachable = + { + url, + ref ? null, + allRefsFallback ? false, + # For a caller re-emitting a url another site already spelled this ref on: + # the warning is about the config entry, not the emission, so tracing it + # once per rebuild is the point. + quiet ? false, + }: + if + !(_urlIsGitScheme url) + || !(_urlHasParam "rev" url) + || _urlHasParam "ref" url + || _urlHasParam "allRefs" url + then + url + # A 40-hex `ref` is a rev, not a branch: it cannot narrow the lookup. + # Spelled through `_gitRefSuffix` like every other ref, or a tag arriving on + # the locked-rev path (where the caller never spells one) would be turned + # into `refs/heads/` with no warning to explain the failed fetch. + else if ref != null && ref != "" && _revSeparator ref == "?ref=" then + _appendRevSuffix url (_gitRefSuffix { + inherit ref url quiet; + }) + else if allRefsFallback then + _appendRevSuffix url "?allRefs=1" + else + url; + + # Transport-agnostic repo identity: `git+ssh://git@/o/r` and the + # `github:` spelling of the same repo collapse to one `repo://` + # key. The host is part of it — `github:` takes `host=` for GitHub Enterprise, + # and the ssh rewrite honours it, so hardcoding github.com would drop a GHE + # repo's lock pin on every transport flip. Remaining query params are sorted, + # since the two spellings need not list them in the same order. Anything else + # (gitlab:, sourcehut:, a plain git+https url) passes through unchanged. + _canonicalRepoUrl = + url: + let + ssh = builtins.match "git[+]ssh://git@([^/]+)/([^/?]+)/([^/?]+)([?].*)?" url; + gh = builtins.match "github:([^/?]+)/([^/?]+)([?].*)?" url; + + params = _urlQueryParams url; + paramValue = + name: + let + hits = map (lib.removePrefix "${name}=") (filter (p: lib.hasPrefix "${name}=" p) params); + in + if hits == [ ] then null else head hits; + in + if ssh != null then + _canonicalRepoKey { + host = elemAt ssh 0; + owner = elemAt ssh 1; + repo = elemAt ssh 2; + dir = paramValue "dir"; + } + else if gh != null then + _canonicalRepoKey { + host = if paramValue "host" == null then "github.com" else paramValue "host"; + owner = elemAt gh 0; + repo = elemAt gh 1; + dir = paramValue "dir"; + } + else + url; + + # Identity is WHICH repo (and which subdirectory of it), never which revision + # of it: nix hoists `ref`, `rev`, `allRefs`, `submodules`, `shallow`, `dir`, + # `host` out of the url into separate `original` fields, so any of them left + # in the key would compare against a `original` that no longer spells them and + # lose the pin on every rebuild. + _canonicalRepoKey = + { + host, + owner, + repo, + dir ? null, + }: + "repo:${host}/${owner}/${lib.removeSuffix ".git" repo}" + + (lib.optionalString (dir != null) "?dir=${dir}"); + + # The same identity for a lock node's `original`, so both sides of the + # comparison are built the same way. `null` = a shape we cannot canonicalise. + _canonicalLockOriginal = + orig: + let + type = orig.type or ""; + in + if type == "github" then + _canonicalRepoKey { + host = orig.host or "github.com"; + inherit (orig) owner repo; + dir = orig.dir or null; + } + else if type == "gitlab" || type == "sourcehut" then + "${type}:${orig.owner}/${orig.repo}" + else if type == "git" then + # `orig.url` is the bare remote; `dir` was hoisted out of it, so add it + # back the same way the url side derives it. + let + base = _canonicalRepoUrl "git+${lib.removePrefix "git+" orig.url}"; + ssh = builtins.match "git[+]ssh://git@([^/]+)/([^/?]+)/([^/?]+)([?].*)?" ( + "git+${lib.removePrefix "git+" orig.url}" + ); + in + if ssh == null then + base + else + _canonicalRepoKey { + host = elemAt ssh 0; + owner = elemAt ssh 1; + repo = elemAt ssh 2; + dir = orig.dir or null; + } + else + null; + + # Advisory-only: ANY unqualified ref is ambiguous over ssh — nix expands it to + # refs/heads/, so a tag or a short rev fails with a confusing "couldn't + # find remote ref" and nothing points at the transport switch. Guessing which + # names look tag-shaped misses every tag that is not semver-shaped, so warn on + # all of them; `refs/...` and a full 40-hex rev are unambiguous and stay quiet. + # (`github:` accepts both `refs/...` forms too, so qualifying is portable.) + _ambigRef = ref: builtins.match "[0-9a-fA-F]{40}" ref == null && !(lib.hasPrefix "refs/" ref); + + # THE way to spell a ref onto a git-scheme url: `?rev=` or `?ref=`, + # with the ambiguity warning attached. Several sites rewrite a ref-stripped + # base and re-attach the ref by hand, so a warning living in one of them + # (`_githubUrlToGitSsh`) silently misses the others — repositories and module + # inputs, which are exactly where tags get pinned. `url` is message context. + _gitRefSuffix = + { + ref, + url ? "", + # Framework-supplied urls (the default nixpkgs channel) are known-good + # branches the user never wrote, so they must not nag about them. + quiet ? false, + }: + lib.warnIf (!quiet && _ambigRef ref) + "githubViaSsh: bare ref '${ref}'${ + lib.optionalString (url != "") " of ${url}" + } resolves as refs/heads/${ref} over ssh, so a tag of that name would be missed silently. Qualify it — refs/heads/${ref} for a branch, refs/tags/${ref} for a tag — or pin a full 40-hex rev; all three resolve with and without the switch." + "${_revSeparator ref}${ref}"; + + # Opt-in ssh transport: `github:` -> `git+ssh://git@github.com/...`; emission + # only — names/lock keys keep the original url, non-github urls pass unchanged. + _githubUrlToGitSshWith = + { + url, + quiet ? false, + }: + let + parsed = _parseFlakeUrl url; + base = builtins.match "github:([^/?]+)/([^/?]+)(.*)" parsed.baseUrl; + + params = _urlQueryParams parsed.baseUrl; + valuesOf = name: map (lib.removePrefix "${name}=") (filter (p: lib.hasPrefix "${name}=" p) params); + + # `github:` takes `host=` for GitHub Enterprise. ssh must point at THAT + # server — keeping github.com would silently fetch from the wrong one — + # and the param itself must not ride along, since the git scheme just + # folds an unknown param back into the remote address. + hosts = valuesOf "host"; + sshHost = if hosts == [ ] then "github.com" else head hosts; + + # A ref reaches us as a path segment (`github:o/r/`) or as a query + # param; either way it is spelled exactly once, by `_gitRefSuffix`. + ref = _urlRef url; + + rest = filter (p: !(lib.hasPrefix "host=" p) && !(lib.hasPrefix "ref=" p)) params; + + sshBase = + "git+ssh://git@${sshHost}/${builtins.elemAt base 0}/${builtins.elemAt base 1}" + + (lib.optionalString (rest != [ ]) "?${concatStringsSep "&" rest}"); + in + if base == null then + url + else + # Any rev pin here was hand-written — a 40-hex ref, or a query-form + # `rev=` — so it may sit on any branch, and a bare `?rev=` (which nix + # resolves from HEAD alone) would abort. Reachability is folded in rather + # than left to callers: genflake's channels/overlays/nixpkgs and + # `extraFlakeInputs` emit this result verbatim. A no-op when there is no + # rev pin at all. + _withRevReachable { + url = + if ref == null then + sshBase + else + _appendRevSuffix sshBase (_gitRefSuffix { + inherit ref quiet; + url = sshBase; + }); + allRefsFallback = true; + }; + + _githubUrlToGitSsh = url: _githubUrlToGitSshWith { inherit url; }; + + # `f` applied to every `url` in a flake-input declaration: its own, and the + # nested `inputs..url` overrides nix allows. Those are real fetches, so the + # transport switch has to reach them too. A `follows`-only entry carries no + # url and comes back untouched. + _mapInputUrls = + f: decl: + if !(builtins.isAttrs decl) then + decl + else + decl + // (lib.optionalAttrs (decl ? url && builtins.isString decl.url) { url = f decl.url; }) + // (lib.optionalAttrs (decl ? inputs && builtins.isAttrs decl.inputs) { + inputs = lib.mapAttrs (_: _mapInputUrls f) decl.inputs; + }); + + # Same rewrite, no ambiguity warning — for urls IceDOS itself supplies. + _githubUrlToGitSshQuiet = + url: + _githubUrlToGitSshWith { + inherit url; + quiet = true; + }; + # Generate a unique key for a module (url/name combination). _getModuleKey = url: name: "${url}/${name}"; diff --git a/modules/options.nix b/modules/options.nix index ed130a02..f93ee1ae 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -243,6 +243,18 @@ in # per-run overrides: --github-token-path or ICEDOS_GITHUB_TOKEN_PATH. githubTokenPath = mkStrOption { default = icedosLib.GITHUB_TOKEN_PATH; }; + # Opt-in: emit `github:` input urls as `git+ssh://` so the ssh key (not + # the token) authenticates fetches. Per-run override: --github-ssh / + # --no-github-ssh. + # + # Resolved from the RAW config (config.toml) plus those flags, before + # the module system runs — the urls it controls are written by genflake. + # Setting it from a NixOS module therefore changes nothing: genflake + # re-applies the resolved value at a priority above `mkForce`, so the + # evaluated option never disagrees with the urls actually emitted, and a + # module definition loses quietly rather than erroring. + githubViaSsh = mkBoolOption { default = false; }; + # Framework-owned; baked by genflake (users get `forceFirstBuild`). No # default: readOnly rejects a second definition. isFirstBuild = mkBoolOption { diff --git a/modules/rebuild.nix b/modules/rebuild.nix index 52b8ee7f..c56b6b78 100644 --- a/modules/rebuild.nix +++ b/modules/rebuild.nix @@ -69,6 +69,10 @@ in echo " --github-token literal GitHub token for nix github.com fetches" echo " --github-token-path " echo " file holding a GitHub token for nix github.com fetches" + echo " --github-ssh fetch GitHub inputs over git+ssh with the ssh key" + echo " instead of the token (per-run opt-in)" + echo " --no-github-ssh force https for one run, overriding githubViaSsh" + echo " (a transport flip re-locks unpinned inputs)" echo " --dir use alternate config directory" echo " --update update everything (core, nixpkgs, repos, repo inputs) + run update hooks" echo " --update-hooks run update hooks only (pre+post), no build" @@ -153,6 +157,10 @@ in # Resolve the token before any nix call: a stale lock makes `nix run` hit # the GitHub API before the orchestrator can set NIX_CONFIG itself. + # --github-ssh does NOT skip this: only the GENERATED state flake's + # `github:` inputs move to ssh. The config flake's own inputs (icedos + # core included) still resolve over https, and this `nix run` is what + # re-resolves them on a stale lock. tokenFile="''${ICEDOS_GITHUB_TOKEN_PATH:-${icedosLib.GITHUB_TOKEN_PATH}}" tokenEnv=() if [ -z "''${ICEDOS_GITHUB_TOKEN:-}" ] && [ -f "$tokenFile" ]; then diff --git a/tests/tests.nix b/tests/tests.nix index b626d8b1..d90f18ad 100644 --- a/tests/tests.nix +++ b/tests/tests.nix @@ -194,6 +194,63 @@ let ]; }; + # Mirror `miMod` with the transport switch on, so the gated emission sites + # (perInput decl, _patchSrcUrl, emitBase/flakeRev) run in rewrote form. + miModSsh = + inputs: + let + r = + (mkIcedos { + system.githubViaSsh = true; + })._getModuleInputs + [ + { + _repoInfo = { + url = "github:icedos/hardware"; + }; + meta = { + name = "probe"; + }; + inherit inputs; + } + ]; + in + builtins.head r; + + miSshBase = miModSsh { + base = { + url = "github:x/base"; + }; + }; + miSshRef = miModSsh { + fooref = { + url = "github:x/foo/main"; + }; + }; + miSshRev = miModSsh { + barrev = { + url = "github:x/bar/0123456789abcdef0123456789abcdef01234567"; + }; + }; + # A nested `inputs..url` override is a real fetch, so it must move too. + miSshNested = miModSsh { + nested = { + url = "github:x/base"; + inputs.nixpkgs.url = "github:private/nixpkgs"; + }; + }; + + # `_modulesToInputs`' gated emitUrl/flakeRev paths under the transport switch. + miInputsSsh = + info: + (builtins.head ( + (mkIcedos { system.githubViaSsh = true; })._modulesToInputs [ { _repoInfo = info; } ] + )).value.url; + + # Same, flag off: what a user who never opted in gets emitted. + miInputsPlain = + info: (builtins.head ((mkIcedos { })._modulesToInputs [ { _repoInfo = info; } ])).value.url; + opaqueOrKey = icedos._opaqueOrKey; dedupe = icedos._dedupeNixosModules; @@ -2127,6 +2184,101 @@ in } ] ); + + # Flag off (the default): a ref-carrying github url stays verbatim, ref and all. + efInputsOffKeepsOriginal = + expectEq + [ + { + name = "jovian"; + value = { + url = "github:jovian-experiments/jovian-nixos/main"; + inputs = { + nixpkgs = { + follows = "nixpkgs"; + }; + }; + }; + } + ] + ( + (mkIcedos { }).extraFlakeInputs [ + { + name = "jovian"; + url = "github:jovian-experiments/jovian-nixos/main"; + inputs = { + nixpkgs = { + follows = "nixpkgs"; + }; + }; + } + ] + ); + + # Flag on: the same ref becomes a `?ref=` query on the ssh url (branch + # semantics; F1). A refs/tags/... ref passes through unchanged. + efInputsGitHubViaSshRef = + expectEq + [ + { + name = "jovian"; + value = { + url = "git+ssh://git@github.com/jovian-experiments/jovian-nixos?ref=main"; + }; + } + ] + ( + (mkIcedos { + system.githubViaSsh = true; + }).extraFlakeInputs + [ + { + name = "jovian"; + url = "github:jovian-experiments/jovian-nixos/main"; + } + ] + ); + + # Transport switch rewrites the emitted url, keeps the name and `inputs`. + efInputsGitHubViaSsh = + expectEq + [ + { + name = "jovian"; + value = { + url = "git+ssh://git@github.com/jovian-experiments/jovian-nixos"; + inputs = { + nixpkgs = { + follows = "nixpkgs"; + }; + }; + }; + } + ] + ( + (mkIcedos { + system.githubViaSsh = true; + }).extraFlakeInputs + efIcedos.extraFlakes + ); + + # Non-github extra flake urls stay verbatim under the switch. + efInputsGitHubViaSshNonGithub = + expectEq + [ + { + name = "jovian"; + value = { + url = "u"; + }; + } + ] + ( + (mkIcedos { + system.githubViaSsh = true; + }).extraFlakeInputs + efBare.extraFlakes + ); # Masked-input entries carry the bare name in both fields (the # `_createMaskedInputs` contract). efMasked = expectEq [ @@ -3425,4 +3577,756 @@ in tipsResizeTrap = expectOk ( lib.strings.hasInfix "trap _icedos_tip_winch WINCH" (tipsHead [ "one" ]) ); + + # --- _githubUrlToGitSsh (`github:` -> `git+ssh://` transport switch) ----- + githubToGitSshPlainRepo = expectEq "git+ssh://git@github.com/o/r" ( + helpers._githubUrlToGitSsh "github:o/r" + ); + + githubToGitSshBranchRef = expectEq "git+ssh://git@github.com/o/r?ref=main" ( + helpers._githubUrlToGitSsh "github:o/r/main" + ); + + # 40-hex segments are revs, spelled `?rev=` for the git fetcher — and a bare + # `?rev=` only resolves from HEAD, so the rewrite itself adds `allRefs=1` + # (genflake's channels/overlays and extraFlakeInputs emit this verbatim). + githubToGitSshHexRev = expectEq "git+ssh://git@github.com/o/r?rev=0123456789abcdef0123456789abcdef01234567&allRefs=1" ( + helpers._githubUrlToGitSsh "github:o/r/0123456789abcdef0123456789abcdef01234567" + ); + + # The ref land after the query, joined with `&` like every other suffix. + githubToGitSshRefJoinsQuery = expectEq "git+ssh://git@github.com/o/r?dir=sub&ref=main" ( + helpers._githubUrlToGitSsh "github:o/r/main?dir=sub" + ); + + githubToGitSshQueryKept = expectEq "git+ssh://git@github.com/o/r?dir=sub" ( + helpers._githubUrlToGitSsh "github:o/r?dir=sub" + ); + + # Owner-only or non-github urls must pass through untouched (nix would reject + # the mangled form, so the helper must not invent structure). + githubToGitSshOwnerOnlyPassesThrough = expectEq "github:o" (helpers._githubUrlToGitSsh "github:o"); + + githubToGitSshNonGithubPassesThrough = expectEq "git+https://e.com/r.git" ( + helpers._githubUrlToGitSsh "git+https://e.com/r.git" + ); + + githubToGitSshPathPassesThrough = expectEq "path:/home/ice/apps" ( + helpers._githubUrlToGitSsh "path:/home/ice/apps" + ); + + # `github:` takes `host=` for GitHub Enterprise: ssh must point at THAT server + # (github.com would silently fetch the wrong one) and must not carry the param + # onward, since the git scheme folds an unknown param into the remote address. + githubToGitSshEnterpriseHost = expectEq "git+ssh://git@ghe.example.com/o/r" ( + helpers._githubUrlToGitSsh "github:o/r?host=ghe.example.com" + ); + githubToGitSshEnterpriseHostKeepsOtherParams = expectEq "git+ssh://git@ghe.example.com/o/r?dir=sub" ( + helpers._githubUrlToGitSsh "github:o/r?host=ghe.example.com&dir=sub" + ); + githubToGitSshEnterpriseHostWithRef = expectEq "git+ssh://git@ghe.example.com/o/r?ref=refs/heads/main" ( + helpers._githubUrlToGitSsh "github:o/r/refs/heads/main?host=ghe.example.com" + ); + + # A ref may arrive as a query param instead of a path segment; it must be + # spelled once, by `_gitRefSuffix`, not passed through untouched. + githubToGitSshQueryRef = expectEq "git+ssh://git@github.com/o/r?ref=refs/heads/dev" ( + helpers._githubUrlToGitSsh "github:o/r?ref=refs/heads/dev" + ); + githubToGitSshQueryRefHexBecomesRev = expectEq "git+ssh://git@github.com/o/r?rev=0123456789abcdef0123456789abcdef01234567&allRefs=1" ( + helpers._githubUrlToGitSsh "github:o/r?rev=0123456789abcdef0123456789abcdef01234567" + ); + + # --- _revSeparator / F1+F2 ref classification ----------------------------- + # A full 40-hex rev is `?rev=`, Nix's revRegex accepts any hex case (F2). + revSeparatorLowerHex = expectEq "?rev=" ( + helpers._revSeparator "0123456789abcdef0123456789abcdef01234567" + ); + revSeparatorUpperHex = expectEq "?rev=" ( + helpers._revSeparator "0123456789ABCDEF0123456789ABCDEF01234567" + ); + # Any other name is `?ref=`; nix expands a bare one to refs/heads/, so + # a refs/tags/... ref must stay fully qualified to keep tag semantics. + revSeparatorBranch = expectEq "?ref=" (helpers._revSeparator "main"); + revSeparatorQualifiedTag = expectEq "?ref=" (helpers._revSeparator "refs/tags/v1.2.3"); + + # Uppercase 40-hex github refs are revs, not branches (F2). + githubToGitSshUpperHexRev = expectEq "git+ssh://git@github.com/o/r?rev=0123456789ABCDEF0123456789ABCDEF01234567&allRefs=1" ( + helpers._githubUrlToGitSsh "github:o/r/0123456789ABCDEF0123456789ABCDEF01234567" + ); + + # A named ref is already a resolvable pin: no `allRefs` piled on top. + githubToGitSshBranchNoAllRefs = expectOk ( + !lib.strings.hasInfix "allRefs" (helpers._githubUrlToGitSsh "github:o/r/main") + ); + + # An `extraFlakes` entry is emitted verbatim, so its rev pin must be reachable. + efInputsGitHubViaSshHexRevAllRefs = + expectEq + [ + { + name = "jovian"; + value = { + url = "git+ssh://git@github.com/o/r?rev=0123456789abcdef0123456789abcdef01234567&allRefs=1"; + }; + } + ] + ( + (mkIcedos { + system.githubViaSsh = true; + }).extraFlakeInputs + [ + { + name = "jovian"; + url = "github:o/r/0123456789abcdef0123456789abcdef01234567"; + } + ] + ); + + # A refs/...-qualified tag passes through the helper unchanged, so the git + # fetcher keeps tag (not refs/heads/) semantics (F1/F3). + githubToGitSshQualifiedTag = expectEq "git+ssh://git@github.com/o/r?ref=refs/tags/v1.2.3" ( + helpers._githubUrlToGitSsh "github:o/r/refs/tags/v1.2.3" + ); + + # --- githubViaSsh on module-input sub-flakes (F3) ------------------------- + subFlakeSshPlain = expectOk ( + lib.strings.hasInfix "git+ssh://git@github.com/x/base" miSshBase.text + && !lib.strings.hasInfix "github:x/base" miSshBase.text + ); + # Branch/tag name -> `?ref=`; 40-hex rev -> `?rev=` on the ssh base. + subFlakeSshRef = expectOk ( + lib.strings.hasInfix "git+ssh://git@github.com/x/foo?ref=main" miSshRef.text + ); + subFlakeSshRev = expectOk ( + lib.strings.hasInfix "git+ssh://git@github.com/x/bar?rev=0123456789abcdef0123456789abcdef01234567" miSshRev.text + ); + + # The nested override moves transport with its parent; leaving it on `github:` + # would send a private nested input out over unauthenticated https. + subFlakeSshNested = expectOk ( + lib.strings.hasInfix "git+ssh://git@github.com/private/nixpkgs" miSshNested.text + && !lib.strings.hasInfix "github:private/nixpkgs" miSshNested.text + ); + + # F4: `_modulesToInputs` flag-on emission — rev/narHash/path cases under ssh. + miInputsSshBase = expectEq "git+ssh://git@github.com/icedos/hardware" (miInputsSsh { + url = "github:icedos/hardware"; + }); + miInputsSshBranch = expectEq "git+ssh://git@github.com/x/foo?ref=main" (miInputsSsh { + url = "github:x/foo/main"; + }); + # An author-written 40-hex ref can sit on any branch, so it falls back to + # `allRefs=1`: a bare `?rev=` is only resolvable from HEAD. + miInputsSshRevUrl = + expectEq "git+ssh://git@github.com/x/bar?rev=0123456789abcdef0123456789abcdef01234567&allRefs=1" + (miInputsSsh { + url = "github:x/bar/0123456789abcdef0123456789abcdef01234567"; + }); + # ...but `_repoInfo.rev` with no author ref is the rev nix just resolved from + # this url's default branch, so `HEAD` finds it and `allRefs` would fetch every + # branch, tag and refs/pull/* of the repo for nothing. + miInputsSshRevAttr = + expectEq "git+ssh://git@github.com/x/hw?rev=0123456789abcdef0123456789abcdef01234567" + (miInputsSsh { + url = "github:x/hw"; + rev = "0123456789abcdef0123456789abcdef01234567"; + }); + # ...but `fetchRef` (config.toml's inline ref, carried out of + # `_loadModulesFromRepo`) narrows it to the branch the rev actually lives on. + miInputsSshRevWithFetchRef = + expectEq "git+ssh://git@github.com/x/hw?rev=0123456789abcdef0123456789abcdef01234567&ref=dev" + (miInputsSsh { + url = "github:x/hw"; + fetchRef = "dev"; + rev = "0123456789abcdef0123456789abcdef01234567"; + }); + # A 40-hex `fetchRef` is a rev, not a branch, so it cannot narrow anything. + miInputsSshRevWithHexFetchRef = + expectEq "git+ssh://git@github.com/x/hw?rev=0123456789abcdef0123456789abcdef01234567&allRefs=1" + (miInputsSsh { + url = "github:x/hw"; + fetchRef = "0123456789ABCDEF0123456789ABCDEF01234567"; + rev = "0123456789abcdef0123456789abcdef01234567"; + }); + # Reachability is gated on the switch: a `git+…` url the user wrote themselves + # emits exactly as it did before, so nothing re-locks (or starts fetching every + # ref) for a feature they never enabled. + miInputsPlainGitSchemeNoAllRefs = + expectEq "git+https://e.com/r.git?rev=0123456789abcdef0123456789abcdef01234567" + (miInputsPlain { + url = "git+https://e.com/r.git"; + rev = "0123456789abcdef0123456789abcdef01234567"; + }); + # ...and the switch does not change that: a url it never rewrote must not + # start fetching every ref because a DIFFERENT input moved to ssh. + miInputsSshGitSchemePassthroughUntouched = + expectEq "git+https://e.com/r.git?rev=0123456789abcdef0123456789abcdef01234567" + (miInputsSsh { + url = "git+https://e.com/r.git"; + rev = "0123456789abcdef0123456789abcdef01234567"; + }); + + # A git-scheme url has no `narHash` query param — nix folds it into the REMOTE + # url and emits something `git ls-remote` cannot resolve. Unpinned beats + # unfetchable, so the pin is dropped once the url is git-scheme... + miInputsSshNarHash = expectEq "git+ssh://git@github.com/x/hw" (miInputsSsh { + url = "github:x/hw"; + narHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + }); + # ...including a `git+` url the user wrote themselves, where the same nix + # behaviour applies with the switch off. + miInputsPlainGitSchemeNarHashDropped = expectEq "git+https://e.com/r.git" (miInputsPlain { + url = "git+https://e.com/r.git"; + narHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + }); + # A `github:` url does carry a real `narHash` attribute, so that pin stays. + miInputsPlainNarHashKept = + expectEq "github:x/hw?narHash=sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + (miInputsPlain { + url = "github:x/hw"; + narHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + }); + miInputsSshPathPassthrough = expectEq "path:/nix/store/abc" (miInputsSsh { + url = "path:/nix/store/abc"; + narHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + }); + # _ambigRef: only a `refs/...`-qualified ref or a full 40-hex rev is + # unambiguous over ssh; everything else silently becomes refs/heads/. + ambigRefSilent = expectOk ( + !helpers._ambigRef "refs/tags/v1.2.3" + && !helpers._ambigRef "refs/heads/main" + && !helpers._ambigRef "0123456789abcdef0123456789abcdef01234567" + && !helpers._ambigRef "0123456789ABCDEF0123456789ABCDEF01234567" + ); + # Tags whose names are not semver-shaped are the case the old heuristic + # missed, so they must warn alongside the semver-ish and hexish ones. + ambigRefWarns = expectOk ( + helpers._ambigRef "v1.2.3" + && helpers._ambigRef "abc1234" + && helpers._ambigRef "24.11" + && helpers._ambigRef "stable-24.05" + && helpers._ambigRef "release" + && helpers._ambigRef "main" + ); + + # A lock node with only a narHash on a git-scheme url yields NO pin: nix folds + # an unknown query param into the remote url, so `?narHash=` would be + # unfetchable. The `github:` spelling keeps its pin. + revFromLockNarHashGitSchemeDropped = expectEq "" ( + helpers._getRevisionFromLock { + repoName = "apps"; + url = "git+ssh://git@github.com/icedos/apps"; + lock = mockLock { type = "git"; } { narHash = "sha256-x"; }; + } + ); + revFromLockNarHashGithubKept = expectEq "?narHash=sha256-x" ( + helpers._getRevisionFromLock { + repoName = "apps"; + url = "github:icedos/apps"; + lock = mockLock { type = "github"; } { narHash = "sha256-x"; }; + } + ); + + # --- _gitRefSuffix (the one place a ref is spelled onto a git url) -------- + gitRefSuffixHexIsRev = expectEq "?rev=0123456789abcdef0123456789abcdef01234567" ( + helpers._gitRefSuffix { ref = "0123456789abcdef0123456789abcdef01234567"; } + ); + gitRefSuffixNameIsRef = expectEq "?ref=main" (helpers._gitRefSuffix { ref = "main"; }); + gitRefSuffixQualifiedTag = expectEq "?ref=refs/tags/v1.2.3" ( + helpers._gitRefSuffix { ref = "refs/tags/v1.2.3"; } + ); + + # --- _urlHasParam / _withRevReachable (bare `?rev=` reachability) --------- + urlHasParamNoQuery = expectOk (!helpers._urlHasParam "rev" "github:o/r"); + urlHasParamFirst = expectOk (helpers._urlHasParam "rev" "git+ssh://h/r?rev=abc&dir=x"); + urlHasParamLater = expectOk (helpers._urlHasParam "ref" "git+ssh://h/r?dir=x&ref=main"); + # Prefix match only: `refs=` is not `ref=`, and `dir=` is not `rev=`. + urlHasParamNotASubstring = expectOk (!helpers._urlHasParam "ref" "git+ssh://h/r?refs=main"); + + # Non-git schemes spell revs as a path segment and are unaffected. + withRevReachableGithubUntouched = expectEq "github:o/r/deadbeef" ( + helpers._withRevReachable { + url = "github:o/r/deadbeef"; + ref = "main"; + } + ); + # No rev pin -> nothing to reach. + withRevReachableNoRev = expectEq "git+ssh://git@github.com/o/r?ref=main" ( + helpers._withRevReachable { url = "git+ssh://git@github.com/o/r?ref=main"; } + ); + # A rev with a known branch resolves against that branch... + withRevReachableAddsRef = expectEq "git+ssh://git@github.com/o/r?rev=abc&ref=dev" ( + helpers._withRevReachable { + url = "git+ssh://git@github.com/o/r?rev=abc"; + ref = "dev"; + } + ); + # ...and with none, `allRefs=1` is the only way nix can find a rev whose + # provenance is unknown. + withRevReachableFallsBackToAllRefs = expectEq "git+ssh://git@github.com/o/r?rev=abc&allRefs=1" ( + helpers._withRevReachable { + url = "git+ssh://git@github.com/o/r?rev=abc"; + allRefsFallback = true; + } + ); + # Without that flag the rev is one nix resolved from this very url, so it is + # reachable from HEAD and must stay a cheap single-branch fetch. + withRevReachableNoFallbackStaysBare = expectEq "git+ssh://git@github.com/o/r?rev=abc" ( + helpers._withRevReachable { url = "git+ssh://git@github.com/o/r?rev=abc"; } + ); + # A known branch beats the fallback: narrow, never all-refs. + withRevReachableRefBeatsAllRefs = expectEq "git+ssh://git@github.com/o/r?rev=abc&ref=dev" ( + helpers._withRevReachable { + url = "git+ssh://git@github.com/o/r?rev=abc"; + ref = "dev"; + allRefsFallback = true; + } + ); + # An explicit ref/allRefs already resolves: never add a second one. + withRevReachableRefAlreadyPresent = expectEq "git+ssh://git@github.com/o/r?rev=abc&ref=dev" ( + helpers._withRevReachable { + url = "git+ssh://git@github.com/o/r?rev=abc&ref=dev"; + ref = "other"; + } + ); + withRevReachableAllRefsAlreadyPresent = expectEq "git+ssh://git@github.com/o/r?rev=abc&allRefs=1" ( + helpers._withRevReachable { url = "git+ssh://git@github.com/o/r?rev=abc&allRefs=1"; } + ); + + # The quiet variant rewrites identically; only the warning is suppressed, so + # IceDOS' own default nixpkgs channel does not nag about a ref nobody wrote. + githubToGitSshQuietMatchesLoud = expectOk ( + helpers._githubUrlToGitSshQuiet "github:nixos/nixpkgs/nixos-unstable" + == helpers._githubUrlToGitSsh "github:nixos/nixpkgs/nixos-unstable" + ); + + # --- _mapInputUrls (own url + nested `inputs..url` overrides) ---------- + mapInputUrlsTopLevel = expectEq { url = "git+ssh://git@github.com/o/r"; } ( + helpers._mapInputUrls helpers._githubUrlToGitSsh { url = "github:o/r"; } + ); + # The nested override is a real fetch, so it must move transport too. + mapInputUrlsNested = + expectEq + { + url = "git+ssh://git@github.com/o/r"; + inputs = { + nixpkgs = { + url = "git+ssh://git@github.com/private/nixpkgs"; + }; + }; + } + ( + helpers._mapInputUrls helpers._githubUrlToGitSsh { + url = "github:o/r"; + inputs = { + nixpkgs = { + url = "github:private/nixpkgs"; + }; + }; + } + ); + # A follows-only entry has no url and must survive untouched. + mapInputUrlsFollowsUntouched = + expectEq + { + url = "git+ssh://git@github.com/o/r"; + inputs = { + nixpkgs = { + follows = "nixpkgs"; + }; + }; + } + ( + helpers._mapInputUrls helpers._githubUrlToGitSsh { + url = "github:o/r"; + inputs = { + nixpkgs = { + follows = "nixpkgs"; + }; + }; + } + ); + # Other keys are carried through, and a non-github url passes unchanged. + mapInputUrlsKeepsOtherKeys = + expectEq + { + url = "u"; + flake = false; + } + ( + helpers._mapInputUrls helpers._githubUrlToGitSsh { + url = "u"; + flake = false; + } + ); + + # --- _canonicalRepoUrl (transport-agnostic repo identity) ----------------- + # Both spellings of the same repo collapse to one host-qualified key. + canonicalRepoUrlSsh = expectEq "repo:github.com/o/r" ( + helpers._canonicalRepoUrl "git+ssh://git@github.com/o/r" + ); + canonicalRepoUrlSshDotGit = expectEq "repo:github.com/o/r" ( + helpers._canonicalRepoUrl "git+ssh://git@github.com/o/r.git" + ); + canonicalRepoUrlPassesThrough = expectEq "repo:github.com/o/r" ( + helpers._canonicalRepoUrl "github:o/r" + ); + # Remaining params are kept (a `?dir=` subflake is a different input) but + # sorted, since the two spellings need not order them the same way. + canonicalRepoUrlKeepsTail = expectEq "repo:github.com/o/r?dir=sub" ( + helpers._canonicalRepoUrl "git+ssh://git@github.com/o/r?dir=sub" + ); + # `host=` is part of the identity, not a leftover param: a GitHub Enterprise + # repo must match its own ssh spelling and NOT the github.com one. + canonicalRepoUrlEnterpriseGithub = expectEq "repo:ghe.example.com/o/r" ( + helpers._canonicalRepoUrl "github:o/r?host=ghe.example.com" + ); + canonicalRepoUrlEnterpriseSsh = expectEq "repo:ghe.example.com/o/r" ( + helpers._canonicalRepoUrl "git+ssh://git@ghe.example.com/o/r" + ); + canonicalRepoUrlEnterpriseParamOrderAgnostic = expectOk ( + helpers._canonicalRepoUrl "github:o/r?host=ghe.example.com&dir=sub" + == helpers._canonicalRepoUrl "git+ssh://git@ghe.example.com/o/r?dir=sub" + ); + canonicalRepoUrlNonForgePassesThrough = expectEq "git+https://e.com/r.git" ( + helpers._canonicalRepoUrl "git+https://e.com/r.git" + ); + # Identity is WHICH repo, not which revision of it: nix hoists ref/rev/allRefs + # out of the url into separate `original` fields, so keeping them in the key + # would compare against an `original` that no longer spells them. + canonicalRepoUrlIgnoresPinParams = expectEq "repo:github.com/o/r" ( + helpers._canonicalRepoUrl "git+ssh://git@github.com/o/r?rev=abc&ref=dev&allRefs=1" + ); + # ...but `?dir=` names a different input and must stay part of the identity. + canonicalRepoUrlKeepsDir = expectEq "repo:github.com/o/r?dir=sub" ( + helpers._canonicalRepoUrl "git+ssh://git@github.com/o/r?dir=sub&rev=abc" + ); + + # A `?dir=` input keeps its pin: nix hoists `dir` into `original`, so the lock + # side has to put it back or the pin is lost on every rebuild. + revLockedDirSubflakeKeepsPin = expectEq "?rev=abc" ( + helpers._resolveFlakeRevisionLocked { + url = "git+ssh://git@github.com/icedos/apps?dir=sub"; + nodeKey = "apps"; + lock = + mockLock + { + type = "github"; + owner = "icedos"; + repo = "apps"; + dir = "sub"; + } + { + type = "github"; + owner = "icedos"; + repo = "apps"; + rev = "abc"; + narHash = "h0"; + }; + } + ); + # A different subdirectory is a different input. + revLockedDirMismatchDropsPin = expectEq "" ( + helpers._resolveFlakeRevisionLocked { + url = "git+ssh://git@github.com/icedos/apps?dir=other"; + nodeKey = "apps"; + lock = + mockLock + { + type = "github"; + owner = "icedos"; + repo = "apps"; + dir = "sub"; + } + { + type = "github"; + owner = "icedos"; + repo = "apps"; + rev = "abc"; + narHash = "h0"; + }; + } + ); + + # --- _urlRef (a ref may be a path segment or a query param) --------------- + urlRefPathSegment = expectEq "main" (helpers._urlRef "github:o/r/main"); + urlRefQueryParam = expectEq "dev" (helpers._urlRef "github:o/r?ref=dev"); + urlRefQualified = expectEq "refs/tags/v1.2.3" (helpers._urlRef "github:o/r/refs/tags/v1.2.3"); + urlRefNone = expectEq null (helpers._urlRef "github:o/r?dir=sub"); + + # --- _lockRefConfirmedLocked --------------------------------------------- + # Only a lock that RECORDS the ref can vouch that the rev came from it. + lockRefConfirmedWhenRecorded = expectOk ( + helpers._lockRefConfirmedLocked { + nodeKey = "apps"; + ref = "dev"; + lock = mockLock { ref = "dev"; } { rev = "abc"; }; + } + ); + # Our own rev-pinned emission records no ref, so it confirms nothing — the + # config's current ref may have been edited since. + lockRefUnconfirmedWhenRevPinned = expectOk ( + !helpers._lockRefConfirmedLocked { + nodeKey = "apps"; + ref = "dev"; + lock = mockLock { rev = "abc"; } { rev = "abc"; }; + } + ); + lockRefUnconfirmedWhenChanged = expectOk ( + !helpers._lockRefConfirmedLocked { + nodeKey = "apps"; + ref = "main"; + lock = mockLock { ref = "dev"; } { rev = "abc"; }; + } + ); + + # A GitHub Enterprise repo must keep its pin across a transport flip, exactly + # like a github.com one — the ssh rewrite honours `host=`, so the identity has + # to as well or `--no-github-ssh` silently re-resolves it to the branch tip. + revLockedEnterpriseFlipKeepsPin = expectEq "/abc" ( + helpers._resolveFlakeRevisionLocked { + url = "github:icedos/apps?host=ghe.example.com"; + nodeKey = "apps"; + lock = + mockLock + { + type = "git"; + url = "ssh://git@ghe.example.com/icedos/apps"; + rev = "abc"; + } + { + type = "git"; + url = "ssh://git@ghe.example.com/icedos/apps"; + rev = "abc"; + }; + } + ); + # ...and a DIFFERENT host must still be a different repo. + revLockedEnterpriseWrongHostDropsPin = expectEq "" ( + helpers._resolveFlakeRevisionLocked { + url = "github:icedos/apps?host=ghe.example.com"; + nodeKey = "apps"; + lock = + mockLock + { + type = "github"; + owner = "icedos"; + repo = "apps"; + rev = "abc"; + } + { + type = "github"; + owner = "icedos"; + repo = "apps"; + rev = "abc"; + narHash = "h0"; + }; + } + ); + + # Flipping githubViaSsh must NOT drop the pin: the rev is the same, only the + # transport changed, so a plain rebuild would otherwise re-resolve the tip. + revLockedGithubNodeMatchesSshUrl = expectEq "?rev=abc" ( + helpers._resolveFlakeRevisionLocked { + url = "git+ssh://git@github.com/icedos/apps"; + nodeKey = "apps"; + lock = + mockLock + { + type = "github"; + owner = "icedos"; + repo = "apps"; + } + { + type = "github"; + owner = "icedos"; + repo = "apps"; + rev = "abc"; + narHash = "h0"; + }; + } + ); + # ...and the same on the way back, where the lock holds the ssh spelling. + revLockedSshNodeMatchesGithubUrl = expectEq "/abc" ( + helpers._resolveFlakeRevisionLocked { + url = "github:icedos/apps"; + nodeKey = "apps"; + lock = + mockLock + { + type = "git"; + url = "ssh://git@github.com/icedos/apps"; + } + { + type = "git"; + url = "ssh://git@github.com/icedos/apps"; + rev = "abc"; + }; + } + ); + # An inline-ref edit must drop the pin: the lock's rev was resolved from the + # OLD ref, and pairing it with the new one emits a `?rev=` the git fetcher + # cannot reach ("Cannot find Git revision ... in ref 'HEAD'"). Dropping it lets + # nix re-resolve, which is what the `github:` transport effectively did. + revLockedRefRemovedDropsPin = expectEq "" ( + helpers._resolveFlakeRevisionLocked { + url = "git+ssh://git@github.com/icedos/apps"; + nodeKey = "apps"; + ref = null; + lock = + mockLock + { + type = "github"; + owner = "icedos"; + repo = "apps"; + ref = "dev"; + } + { + type = "github"; + owner = "icedos"; + repo = "apps"; + rev = "abc"; + narHash = "h0"; + }; + } + ); + revLockedRefChangedDropsPin = expectEq "" ( + helpers._resolveFlakeRevisionLocked { + url = "git+ssh://git@github.com/icedos/apps"; + nodeKey = "apps"; + ref = "main"; + lock = + mockLock + { + type = "github"; + owner = "icedos"; + repo = "apps"; + ref = "dev"; + } + { + type = "github"; + owner = "icedos"; + repo = "apps"; + rev = "abc"; + narHash = "h0"; + }; + } + ); + # ...and an unchanged ref keeps it, so the common case still avoids a re-fetch. + revLockedRefUnchangedKeepsPin = expectEq "?rev=abc" ( + helpers._resolveFlakeRevisionLocked { + url = "git+ssh://git@github.com/icedos/apps"; + nodeKey = "apps"; + ref = "dev"; + lock = + mockLock + { + type = "github"; + owner = "icedos"; + repo = "apps"; + ref = "dev"; + } + { + type = "github"; + owner = "icedos"; + repo = "apps"; + rev = "abc"; + narHash = "h0"; + }; + } + ); + # A ref newly ADDED to a previously ref-less entry drops it too — that lock + # node is not a rev-pinned emission (no `rev` in `original`), so its missing + # ref really does mean "the config had none". + revLockedRefAddedDropsPin = expectEq "" ( + helpers._resolveFlakeRevisionLocked { + url = "github:icedos/apps"; + nodeKey = "apps"; + ref = "dev"; + lock = + mockLock + { + type = "github"; + owner = "icedos"; + repo = "apps"; + } + { + type = "github"; + owner = "icedos"; + repo = "apps"; + rev = "abc"; + narHash = "h0"; + }; + } + ); + + # ...but IceDOS' OWN rev-pinned emission records `rev` and no `ref`, and that + # absence must NOT read as "the ref changed": doing so unpins, re-resolves the + # branch tip, re-pins, and unpins again on alternating rebuilds. + revLockedRevPinnedOriginalKeepsPin = expectEq "/abc" ( + helpers._resolveFlakeRevisionLocked { + url = "github:icedos/apps"; + nodeKey = "apps"; + ref = "dev"; + lock = + mockLock + { + type = "github"; + owner = "icedos"; + repo = "apps"; + rev = "abc"; + } + { + type = "github"; + owner = "icedos"; + repo = "apps"; + rev = "abc"; + narHash = "h0"; + }; + } + ); + # The git-scheme spelling keeps ref AND rev, so an edit is still caught. + revLockedGitSchemeRefChangedDropsPin = expectEq "" ( + helpers._resolveFlakeRevisionLocked { + url = "git+ssh://git@github.com/icedos/apps"; + nodeKey = "apps"; + ref = "main"; + lock = + mockLock + { + type = "git"; + url = "ssh://git@github.com/icedos/apps"; + ref = "dev"; + rev = "abc"; + } + { + type = "git"; + url = "ssh://git@github.com/icedos/apps"; + rev = "abc"; + }; + } + ); + + # A genuinely different repo must still drop the pin. + revLockedSshNodeRejectsOtherRepo = expectEq "" ( + helpers._resolveFlakeRevisionLocked { + url = "git+ssh://git@github.com/icedos/other"; + nodeKey = "apps"; + lock = + mockLock + { + type = "github"; + owner = "icedos"; + repo = "apps"; + } + { + type = "github"; + owner = "icedos"; + repo = "apps"; + rev = "abc"; + narHash = "h0"; + }; + } + ); }