Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
87 changes: 80 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `(<file>)` 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 `(<file>)` 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`. |
Expand Down Expand Up @@ -467,7 +467,7 @@ at your checkout, and enable/configure the module you touched) → run `icedos r
| Check | What it does |
|---|---|
| `lib-tests` | Evaluates `tests/tests.nix`; fails if any result is not "ok" (or the eval throws). |
| `python-tests` | `unittest` over `build/tests/` — the orchestrator's arg parsing, `flake.lock` reading, and GitHub-token precedence, all pure functions needing no build. |
| `python-tests` | `unittest` over `build/tests/` — the orchestrator's arg parsing, `flake.lock` reading, and GitHub-token precedence, all pure functions needing no build.
| `nixfmt-check` | `nixfmt --check` over every `*.nix`; without it a commit lands unformatted and the next one absorbs the reformat. |

Run it **without `--no-build`**: `lib-tests` reaches `builtins.path`/`readDir` on a
Expand Down Expand Up @@ -514,8 +514,36 @@ consumed via flake inputs, and a committed lock would pin core's own inputs —

`icedos rebuild` flags (full list in `README.md`): `--boot`, `--build`, `--build-vm`, `--dry`/`-n`/`--dry-run`,
`--run-vm`, `--update`, `--update-core`, `--update-core-only`, `--update-state-inputs "..."`, `--update-repos`,
`--update-repos-only`, `--update-repos-select "..."`, `--update-repo-inputs-only`, `--update-hooks`, `--ask`,
`--builder <host>`, `--logs`, `--nh-args …`, `--build-args …` (must be last).
`--update-repos-only`, `--update-repos-select "..."`, `--update-repo-inputs-only`, `--unpin-inputs "..."`,
`--unpin-inputs-all`, `--update-hooks`, `--ask`, `--builder <host>`, `--logs`, `--nh-args …`,
`--build-args …` (must be last).

**Unpin path (`--unpin-inputs` / `--unpin-inputs-all`, needs `system.cache.enable` +
`system.cache.pinInputs`).** The orchestrator (`build/pins.py`) diffs each named
cache-pinned input's `tracked-inputs.json` rev (exposed by genflake as `pinRevs`)
against the input's upstream rev (`git ls-remote`): the declared url ref
(genflake exports it as `pinRefs`, since the state lock loses it once a rev is
baked in; both the `<ref>` path segment and the `?ref=` query spelling count),
else the remote HEAD. It then asks (TTY only) whether to re-pin.
Each accepted re-pin is remembered in
`.state/unpinned-inputs.json`; genflake bakes that rev into the sub-flake url in
place of the cache pin (`_unpinnedRevs` + the saved-pin branch of `_cachePin` in
`lib/icedos.nix` — still a pin, just a custom one). The whole pin machinery,
cache revs included, is gated on `_cachePinsActive` = `enable` && `pinInputs`;
`--unpin-inputs-all` covers every cache-tracked module-leaf input the config
locks (state-lock membership via `_lock_leaf`; root-declared repo-level tracked
names are skipped, and `--unpin-inputs` rejects them). A remembered pin is
dropped automatically once the cache-server's rev reaches or passes it
(`git merge-base --is-ancestor` in a throwaway blobless clone; unknown ancestry
keeps the pin), so steady state always returns to the cache-server's pins — plain
rebuilds run that cleanup themselves (`expire_unpinned`, only when the state file
exists, at the cost of one extra genflake evaluation per such rebuild).
`--genflake-only` /
`--dry` runs skip both unpin paths entirely. Patched module inputs reach the
custom rev only via their lock bypass
(`--update-repo-inputs-only`/`--update`), matching the cache-pin path. The
rebuild help only lists the flags when the gate is on. Deleting
the state file reverts to cache pins immediately.

## 8. Hard rules (do not violate)

Expand Down Expand Up @@ -567,7 +595,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. |
Expand Down Expand Up @@ -610,14 +638,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, `💡: <message>`), 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 <flag> [ … ]`, 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

Expand Down
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<category>.*` | 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. |

Expand Down Expand Up @@ -430,6 +430,36 @@ New here? These cover almost everything:
| `icedos gc [--dry] [--days <N>] [--gens <N>]` | 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 |
Expand Down Expand Up @@ -489,6 +519,8 @@ Rebuilds can pass a GitHub token to nix as a `github.com` access token (higher A
| `--update-repos-only` | Pull new revisions of the IceDOS module repos only. Does **not** re-lock inputs declared *inside* those modules — the sub-flake texts are generated before the repo bump in the same run, so if the bumped rev changes a module's declared inputs, those land on the **next** build (one-build lag, self-healing). | Get latest modules without bumping their dependencies. |
| `--update-repos-select "..."` | Pull new revisions of only the named repos (space-separated `[[icedos.repositories]].url` values), without re-locking inputs declared *inside* those modules. Same one-build lag as `--update-repos-only` for the selected repos. | Get latest modules for one or a few repos without bumping everything. |
| `--update-repo-inputs-only` | Re-lock every module-declared dependency without pulling new repo revisions. Each module's inputs live in its own input-namespace sub-flake. | Bump module dependencies without updating repos. |
| `--unpin-inputs "..."` | With cache `enable` + `pinInputs` set, offer to re-pin cache-pinned module inputs (space-separated tracked names) to their upstream revs — the input's declared url ref when it declares one, else the default branch. Repo-level tracked names are rejected. Each accepted re-pin is remembered in `.state/unpinned-inputs.json` and keeps overriding the cache pin until the cache-server's rev reaches or passes it — the memory is then dropped automatically on the next rebuild (or `--unpin-inputs` run). Non-interactive sessions keep the current pin instead of prompting. Module inputs declared with `patches` only pick the custom rev once their lock entry is gone — pair the run with `--update-repo-inputs-only` for those. | Track one input at master while the rest stay cache-pinned. |
| `--unpin-inputs-all` | Same as `--unpin-inputs` (requires cache `enable` + `pinInputs`), but offers the re-pin for every cache-tracked module-leaf input this config locks (repo-level tracked names are skipped). | Catch the whole config up to its default branches in one run. |
| `--update-hooks` | Run only the `preUpdate`/`postUpdate` hooks and exit — no build, no activation. | Refresh non-Nix things (e.g. `flatpak update`). |

#### Behavior flags
Expand Down
6 changes: 3 additions & 3 deletions build/genflake.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from .util import JSON, capture, run, write_json, write_text


def _nix_eval_json(env: BuildEnv, apply: str, trace: list[str]) -> str:
def nix_eval_json(env: BuildEnv, apply: str, trace: list[str]) -> str:
cmd = [
"nix",
"eval",
Expand All @@ -27,7 +27,7 @@ def export_search_index(env: BuildEnv, trace: list[str]) -> None:
cache = env.state_dir / ".cache"
_ = cache.mkdir(parents=True, exist_ok=True)

search_docs = _nix_eval_json(
search_docs = nix_eval_json(
env, "g: { inherit (g) optionsDoc modulesDoc; }", trace
)
docs = cast("dict[str, JSON]", json.loads(search_docs))
Expand All @@ -42,7 +42,7 @@ def export_search_index(env: BuildEnv, trace: list[str]) -> None:
write_json(cache / filename, value)
run(["jsonfmt", str(cache / filename), "-w"], check=True)

user_config = _nix_eval_json(env, "g: g.userConfigRaw", trace)
user_config = nix_eval_json(env, "g: g.userConfigRaw", trace)
write_json(cache / "config.json", cast(JSON, json.loads(user_config)))
run(["jsonfmt", str(cache / "config.json"), "-w"], check=True)

Expand Down
18 changes: 18 additions & 0 deletions build/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .context import from_environment
from .genflake import export_search_index, generate_flake
from .options import Options, parse_args
from .pins import UNPINNED_FILE, expire_unpinned, unpin_inputs
from .runner import build
from .update import maybe_re_exec_update_core, prepare_lock, refresh_config_root_paths
from .util import warn
Expand Down Expand Up @@ -112,6 +113,23 @@ def main(argv: list[str] | None = None) -> int:
refresh_config_root_paths(env, opts)
maybe_re_exec_update_core(env, opts, previous_arguments)

# --dry/--genflake-only must not mutate state: no expiry, no prompts.
mutating = not opts.genflake_only
if (
mutating
and (env.state_dir / UNPINNED_FILE).exists()
and not (opts.unpin_inputs or opts.unpin_all)
):
# Best-effort case-4 expiry; runs only while a remembered pin exists.
expire_unpinned(env, trace)

# Runs before genflake, which bakes the pins into the sub-flake urls.
if opts.unpin_inputs or opts.unpin_all:
if mutating:
unpin_inputs(env, opts, trace)
else:
warn("warning: --unpin-input* have no effect together with --genflake-only/--dry")

generate_flake(
env,
trace,
Expand Down
Loading