diff --git a/.editorconfig b/.editorconfig index 1dd1eec2..dcb32673 100644 --- a/.editorconfig +++ b/.editorconfig @@ -104,7 +104,3 @@ end_of_line = unset insert_final_newline = unset trim_trailing_whitespace = unset charset = unset - -[lib/tests/*.plist] -indent_style = tab -insert_final_newline = unset diff --git a/.gitignore b/.gitignore index a02a713f..d9d8b923 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ .sessions .vscode .zed + +# Generated by `nix flake check` / any nix command in this repo — +# deliberately NOT pinned (library flake; see AGENTS.md "Core lib tests"). +flake.lock diff --git a/AGENTS.md b/AGENTS.md index 00b8896d..b872f74c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ needed regardless. And **never run a plain `icedos rebuild`** (that is a `switch ``` /config.toml? (+ configs/*.toml) (config.toml optional; root marked by flake.nix) - │ lib/load-user-config.nix (parse TOML, strict-merge) + │ lib/config/load-user-config.nix (parse TOML, strict-merge) ▼ icedos.* options modules/options.nix declares the schema │ lib/genflake.nix (evalModules → validate every value) @@ -77,7 +77,7 @@ nh os path:. `["modules"]`) are imported the same way. - User config beyond `config.toml` is autoloaded from the `icedos.system.extraConfigs` dirs (default `["configs"]`): every `*.toml` (including hidden `.*.toml`) is enumerated - by `lib/config-files.nix` and strict-merged in, with `config.toml` as the base. Hidden + by `lib/config/config-files.nix` and strict-merged in, with `config.toml` as the base. Hidden `.*.toml` are a **gitignore-only** channel — their values are plaintext in the store (and rollback snapshots), so treat them as private, not secret. Both options are bootstrap paths — read from `config.toml` only (like `system.arch`), or their @@ -104,11 +104,18 @@ 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/helpers.nix` | `getModules`, `scanModules`, `hasModule`, `moduleInputName` (top-level generated-flake name of a module-declared input — the string-context twin of `_getModuleInputs`), `bash.prelude`, `bash.{blue,green,dim*}String`, `bash.requireConfigOwner` (permission guard for executing the baked `configurationLocation`; capture `ORIG_ARGS=("$@")` before arg parsing and only use where `$0` is the leaf command script), `toolset.mk{Dispatcher,BashCompletion,ZshCompletion,FishCompletion}`, `users.{getNormal,genDefaults,mkGroupInjector}`, `pkgs.{mapper,mkConfig,overlaysFromChannel}`, `packaging.{extractAppImage,installDesktopEntry}`, `mkInputName`, flake-revision helpers. `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/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/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`. | +| `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/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/load-user-config.nix` | Parse `config.toml` + every `configs/*.toml` (enumerated by `lib/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/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-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. | +| `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. | | `lib/common.nix` | `abortIf`, `filterByAttrs`, `findFirst`, `flatMap`, `generateAttrPath`, … | | `lib/constants.nix` | `ICEDOS_*` env/stage constants, `INPUTS_PREFIX`, `ENABLE_LOGGING` (either `ICEDOS_LOGGING=1` in the env **or** the `enableLogging` flag baked into the generated flake's lib import at genflake time — so `--logs` stays active for the whole nixos build even though the env var doesn't reach it). | | `lib/logger.nix` | `log`/`logValue`/`logAttrKeys` — active when `ENABLE_LOGGING` is set. | @@ -165,12 +172,39 @@ speedInBytes = true Optional module fields: - `inputs = { foo = { url = "…"; patches = [ … ]; }; };` — extra flake - inputs the module needs (merged into the generated state flake). The generated - top-level input name is namespaced to the declaring module — computed by - `icedosLib.moduleInputName { repo; module; input; }` — but the input is exposed to - every enabled module's `outputs.nixosModules` under the bare declared name `foo`. - A legacy `override = true` key is accepted and ignored (naming is now always - namespaced). + inputs the module needs. Each declaring module gets one thin **input-namespace + sub-flake** (`icedos-_`, a **content-addressed** `/nix/store/--subflake` + flake.nix — no per-sub-flake files are written to disk; the sub-flake's text is + `pkgs.writeTextDir`'d + `builtins.path`'d at genflake time, so a decl change flips the + store path and a plain `nix flake lock` re-locks the sub root — and declared as a single + `path:` store input of the generated state flake); the module's declared inputs live inside it. The input's + sub-flake-relative path is computed by `icedosLib.moduleInputName { repo; module; input; }` + (`"/"` — the string-context twin of `_getModuleInputs`), and + each input is exposed to every enabled module's `outputs.nixosModules` under the + bare declared name `foo`. A module input's `follows` — at ANY depth: the + input's own `follows`, or anywhere in its `inputs` tree (a nested two-level + follows is legal flake syntax) — may only target an ambient top-level input + of the generated flake (nixpkgs, home-manager, icedos-config, + icedos-core, icedos-state, a configured channel, a url-mode overlay, an + extraFlake, or a sibling input of the same module); cross-module `follows` — + e.g. built from `moduleInputName`, which now yields a sub-flake-relative path — + abort at genflake naming the declarer. An input that declares both `url` and + `follows` also aborts (nix rejects a flake input with both a flake reference + and a follows attribute, so the sub-flake could not lock); a url-less + follows-only input stays legal. Two modules declaring the same bare input + name with **different** urls also abort (naming both declarers); the same url but + **different** patch sets aborts too, since the two would realise different trees + and the masked set (`listToAttrs` keyed by the bare name) would silently pick one. + (Two byte-identical but separately-vendored patch files still count as different + patch sets — the store paths differ — so an author should share one patch file, + not copy it.) A legacy + `override = true` key is accepted and ignored (naming is now always namespaced). + **Shadowing:** a `follows` whose first segment is also a declared sibling input + of the same module resolves to that declared input, *not* the ambient one — e.g. + a module that declares its own `nixpkgs` input makes any `follows = "nixpkgs"` + on its other inputs pull in the module's `nixpkgs`, not the generated flake's + (a slot is not emitted for a name the module already declares, so the follows + targets the sibling inside the sub-flake). - `meta.dependencies = [ { url?; modules = [ … ]; } ];` and `meta.optionalDependencies` — other modules this one needs (pulled automatically). @@ -241,7 +275,7 @@ the path must fill every normal user, in its `outputs.nixosModules` config: icedos..users = icedosLib.users.genDefaults { inherit (config.icedos) users; }; ``` -`genDefaults` (`lib/helpers.nix`, `users.genDefaults`) writes `{ = {}; … }` +`genDefaults` (`lib/users.nix`, `users.genDefaults`) writes `{ = {}; … }` for every `isNormalUser`, which triggers each submodule's own field defaults; explicit `[icedos..users.]` TOML stanzas still merge on top (submodule attrs merge). **Without it**, a user must hand-write an empty per-user stanza just to get defaults, and @@ -300,16 +334,35 @@ modules = [ "btop", "steam", "me3" ] # which modules to enable - `lib/icedos.nix:resolveExternalDependencyRecursively` walks each module's `meta.dependencies` so you only list what you directly want; deps come along. -- A module's declared `inputs` become flake inputs of the generated state flake - (patched via `pkgs.applyPatches` if `patches` is set). **Input masking** gives modules - stable names (`inputs.`, `inputs.self`) regardless of how the repo was fetched. +- A module's declared `inputs` live inside its per-module **input-namespace + sub-flake** (a **content-addressed** `/nix/store/--subflake/flake.nix` — the + sub-flake text is `pkgs.writeTextDir`'d + `builtins.path`'d at genflake time, never + written to disk under `.state/`, and declared as a single `path:` store input of the + generated state flake — rewired to + the parent's ambient inputs via `inputs..inputs..follows`; patched + inputs become a `path:` node for the realised tree plus an upstream `_source` + node — see §5). `genflake.nix` emits the sub-flakes directly as the generated + `flake.nix`'s root `path:` inputs (no `subflakes.json` export — build.sh derives + sub-flake roots and their declared inputs from the resulting `flake.lock`), and + `--update-repos-inputs` refreshes + sub-flake inputs via `nix flake update "/" --refresh`. All lock steps run + against a **detached** copy of the state flake — `build.sh` rsyncs `.state` into a temp + dir (`mktemp`) and copies only the resulting `flake.lock` back, because a git flake + refuses to lock/refresh untracked `path:` inputs (`nix` says "git add ..."): locking in + `.state` would force every new or changed module input to be staged/committed. Sub-flakes + are never written as files and never committed; a changed sub-flake materializes a new + content-addressed store path, so a plain `nix flake lock` picks up new/edited module + inputs (preserving unchanged nested pins). Their nested inputs are inlined into the parent + `flake.lock`, so only that one file needs syncing. **Input masking** + gives modules stable names (`inputs.`, `inputs.self`) regardless of how the + repo was fetched. - Channels/overlays: `[[icedos.system.channels]]` and `[[icedos.system.overlays.fromChannel]]` add extra nixpkgs instances/overlays. ### Declaring user options from TOML: `[extraOptions]` A user can declare their **own** typed NixOS options without writing a Nix module — -purely from `config.toml` / `configs/*.toml`. `lib/extra-options.nix` translates the +purely from `config.toml` / `configs/*.toml`. `lib/config/extra-options.nix` translates the `[extraOptions]` table into real option declarations: - A node **with a `type` key** is a **leaf** — one declared option at its full dotted @@ -388,7 +441,7 @@ at your checkout, and enable/configure the module you touched) → run `icedos r **from wherever you are**. No `cd`, no `sudo`, no activation. You never switch — the user does. Core lib tests run as a flake check: `nix flake check` in the core repo evaluates -`lib/tests/tests.nix` and fails if any result is not "ok" (or the eval throws). +`tests/tests.nix` and fails if any result is not "ok" (or the eval throws). Core's `flake.lock` is gitignored and generated on demand (it is a library flake consumed via flake inputs, and a committed lock would pin core's own inputs — `nixpkgs`, `cache-server` — for consumers without `follows`). @@ -566,7 +619,7 @@ Environment a hook can rely on: | `ICEDOS_ROOT` | build app | the core store path. | | `ICEDOS_BUILD_DIR` | `build.sh` | temp build dir — set **after** `build.sh` starts, so **not** available in `preRebuild`/`preUpdate` (they run before it). | | `ICEDOS_HOOKS_ONLY=1` | `--update-hooks` only | tells `pre/postUpdate` that no HM activation follows, so they must complete standalone. | -| `ICEDOS_LOGGING` / `ICEDOS_STAGE` / `ICEDOS_UPDATE` | eval-internal | don't depend on these in runtime hooks. | +| `ICEDOS_LOGGING` / `ICEDOS_STAGE` / `ICEDOS_UPDATE` / `ICEDOS_UPDATE_MODULE_INPUTS` | eval-internal | don't depend on these in runtime hooks. | Order (`modules/rebuild.nix`): `--update-hooks` short-circuit (pre+postUpdate, then exit) → `preRebuild` → `preUpdate` (only with `--update`) → `build.sh` → `postUpdate` diff --git a/README.md b/README.md index dfc631ab..09e1e2ce 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,7 @@ modules = [ "btop", "steam" ] # which modules to enable # patches = [ "patches/apps.patch" ] # patch the whole repo source ``` -`url` accepts any Nix flake reference — `github:`, `gitlab:`, `git+https://…`, or a local `path:/…`, not just GitHub. (`overrideUrl` is a separate knob for *swapping* a repo's source during local testing while keeping its lock identity — you don't need it just to load a `path:` repo.) +`url` accepts any Nix flake reference — `github:`, `gitlab:`, `git+https://…`, or a local `path:/…`, not just GitHub. (`overrideUrl` is a separate knob for *swapping* a repo's source during local testing while keeping its lock identity — you don't need it just to load a `path:` repo.) A local `path:` must be **absolute**: lock steps run against a detached copy of the state flake (see `build.sh`), so a relative `path:./…` would resolve against that temp dir, not `.state`. ### `config.toml` at a glance @@ -307,7 +307,7 @@ url = "github:numtide/flake-utils" inputs = { nixpkgs.follows = "nixpkgs" } ``` -A `name` must not collide with a module-declared input, a repository input, a `[[icedos.system.channels]]`/overlay input, or the framework-reserved set (`nixpkgs`, `home-manager`, `self`, `icedos-config`, `icedos-core`, `icedos-state`) — all of those become top-level inputs too, and a duplicate would silently overwrite. +A `name` must not collide with a module-declared input (its sub-flake name or its masked bare names), a repository input, a `[[icedos.system.channels]]`/overlay input, or the framework-reserved set (`nixpkgs`, `home-manager`, `self`, `icedos-config`, `icedos-core`, `icedos-state`) — extraFlakes become top-level inputs, module-declared inputs live as bare names inside their module's sub-flake, and a duplicate would silently overwrite. ### Hardware configuration @@ -480,8 +480,8 @@ With no flags this is a `switch`: it builds your configuration and activates it | `--update` | Update everything (core, nixpkgs, module repos, and module-declared inputs) in one blanket bump. | Full update. | | `--update-core` | Update IceDOS core, then re-run the command once. | Update IceDOS itself. | | `--update-nixpkgs` | Update the nixpkgs channel only. | Newer packages without touching modules. | -| `--update-repos` | Pull new revisions of the IceDOS module repos (e.g. `apps`, `hardware`). Does **not** re-lock inputs declared *inside* those modules. | Get the latest modules. | -| `--update-repos-inputs` | Re-lock every module-declared dependency. The only way to bump inputs defined inside module files. | Bump module dependencies without bumping nixpkgs. | +| `--update-repos` | Pull new revisions of the IceDOS module repos (e.g. `apps`, `hardware`). 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 (that build's genflake re-reads the new decls and the plain lock re-locks the changed sub-flake; one-build lag, self-healing). | Get the latest modules. | +| `--update-repos-inputs` | Re-lock every module-declared dependency (each module's inputs live in its own input-namespace sub-flake — a content-addressed store path; this bumps them via `nix flake update "/"`). The only way to bump inputs defined inside module files. | Bump module dependencies without bumping nixpkgs. | | `--update-hooks` | Run only the `preUpdate`/`postUpdate` hooks and exit — no build, no activation. | Refresh non-Nix things (e.g. `flatpak update`). | #### Behavior flags diff --git a/build.sh b/build.sh index 4a9442db..941695ce 100644 --- a/build.sh +++ b/build.sh @@ -106,18 +106,13 @@ done export NIX_CONFIG="experimental-features = flakes nix-command pipe-operators" -# Lightweight index for `icedos configuration search` / `configuration validate` -# (and the webui config editor): emit the option + module docs plus a JSON dump -# of the user's config.toml, then exit. Runs before every mutating step below — -# it evaluates lib/genflake.nix directly, so it needs neither the generated -# flake nor a refreshed lock, and callers get a read-only check (the only writes -# are the .cache/*.json index files themselves). No full system eval, no build. +# Search/validate index for the CLI and webui. Evaluates genflake.nix directly — +# no generated flake, no lock refresh, no build — and exits. if [ "$export_search_index" == "1" ]; then ( cd "$ICEDOS_STATE_DIR" mkdir -p .cache - # One genflake eval producing both docs — evaluating the file per-doc would - # redo the whole config load + module resolution each time. + # One eval for both docs; per-doc evals would redo the whole module resolution. search_docs=$(ICEDOS_STAGE="genflake" nix eval --json $trace \ --file "$ICEDOS_ROOT/lib/genflake.nix" \ --apply 'g: { inherit (g) optionsDoc modulesDoc; }') @@ -127,11 +122,8 @@ if [ "$export_search_index" == "1" ]; then jsonfmt .cache/options-doc.json -w jsonfmt .cache/modules-doc.json -w - # Full merged user config as JSON (config.toml + every enabled - # configs/*.toml — see lib/load-user-config.nix). The webui editor reads - # this next to options-doc.json to tell which keys the user actually set - # and to recover submodule-list values (repositories, users) the options - # doc doesn't expand. + # The merged config set, so the webui editor can tell which keys the user set + # and recover submodule lists the options doc doesn't expand. user_config=$(ICEDOS_STAGE="genflake" nix eval --json $trace \ --file "$ICEDOS_ROOT/lib/genflake.nix" \ --apply 'g: g.userConfigRaw') @@ -142,11 +134,8 @@ if [ "$export_search_index" == "1" ]; then exit 0 fi -# Refresh every `type: "path"` input in config/flake.lock so a local- -# core override (inputs.icedos.url = "path:...") lands on every plain -# rebuild without requiring --update-core. github / git inputs stay -# pinned. Skipped when --update-core is set since the block below -# does a full --refresh on all inputs anyway. +# Refresh `path:` inputs so a local-core override lands without --update-core; +# github/git stay pinned. Skipped under --update-core (full refresh below). if [ "$update_core" != "1" ] \ && [ -n "$ICEDOS_CONFIG_ROOT" ] \ && [ -f "$ICEDOS_CONFIG_ROOT/flake.lock" ]; then @@ -170,40 +159,113 @@ if [ "$update_repos" == "1" ]; then refresh="--refresh" fi -# Generate flake -ICEDOS_UPDATE="$update_repos" ICEDOS_STAGE="genflake" nix eval $refresh $trace --file "$ICEDOS_ROOT/lib/genflake.nix" --raw flakeFinal >"$ICEDOS_STATE_DIR/$FLAKE" +# Separate bake-suppression flags: a baked rev would pin the very input the +# running update flag is about to bump. --update sets both. +update_flag="$update_repos" +update_module_inputs_flag="$update_repos_inputs" + +# Captured first, written second, so a failed eval leaves the previous +# `.state/flake.nix` intact instead of truncating it. +flake_final=$(ICEDOS_UPDATE="$update_flag" ICEDOS_UPDATE_MODULE_INPUTS="$update_module_inputs_flag" \ + ICEDOS_STAGE="genflake" nix eval --raw $refresh $trace \ + --file "$ICEDOS_ROOT/lib/genflake.nix" flakeFinal) +printf '%s\n' "$flake_final" >"$ICEDOS_STATE_DIR/$FLAKE" nixfmt "$ICEDOS_STATE_DIR/$FLAKE" +# Sub-flakes exist only as store paths in the generated flake's root inputs, so +# `flake.lock` is the single source of truth for which inputs belong to which. + +# Lock in a DETACHED copy: nix treats `.state` as a git flake, and a git flake +# refuses to lock an untracked `path:` input. Only flake.lock is copied back. +lock_dir="$(mktemp -d -t icedos-lock-XXXXXXX-0)" +trap 'rm -rf "$lock_dir" 2>/dev/null || true' EXIT +rsync -a --exclude=".cache" "$ICEDOS_STATE_DIR/" "$lock_dir/" + +sync_lock() { + if [ -f "$lock_dir/flake.lock" ]; then + cp "$lock_dir/flake.lock" "$ICEDOS_STATE_DIR/flake.lock" + else + echo "warning: no flake.lock in detached lock dir — nothing to sync" >&2 + fi +} + +# Sub-flake roots: `path:` root inputs whose store path ends `--subflake`. +# Keys come from `nodes.root.inputs` — nix suffixes colliding node names. +subflakes_from_lock() { + jq -r ' + . as $doc | $doc.nodes.root.inputs | to_entries[] + | .key as $k | .value as $key + | select(($key | type) == "string") + | select($doc.nodes[$key].locked.type == "path") + | select($doc.nodes[$key].locked.path | startswith("/nix/store/")) + | select($doc.nodes[$key].locked.path | endswith("-" + $k + "-subflake")) + | $k + ' "$1" 2>/dev/null +} + +# Captured before the lock step creates one: prefetch needs a lock, but a first +# build is exactly when the parallel prefetch is worth it. +first_lock=0 +[ -f "$lock_dir/flake.lock" ] || first_lock=1 ( set -e - cd "$ICEDOS_STATE_DIR" + cd "$lock_dir" - if [ ! -f flake.lock ] || [ -n "$update_core$update_nixpkgs$update_repos$update_repos_inputs" ]; then + # A changed sub-flake has a new store path, so this re-locks that root alone — + # `nix flake update ` would re-resolve its whole subtree to latest. + nix flake lock + + # Store warming only; needs the lock above. + if [ "$first_lock" == "1" ] || [ -n "$update_core$update_nixpkgs$update_repos$update_repos_inputs" ]; then nix flake prefetch-inputs fi - # Refresh every `type: "path"` input on each build so local sibling- - # repo edits (e.g. overrideUrl = "path:..." in config.toml) land - # without requiring --update-repos. github / git inputs stay pinned - # to their lock entries so we don't pay a network roundtrip per - # rebuild. - for input in $(jq -r '.nodes | to_entries[] | select(.value.locked.type == "path") | .key' flake.lock 2>/dev/null); do + # Local `path:` roots (overrideUrl checkouts) refresh every build; github/git + # stay pinned. Store paths are skipped — updating one unpins its whole subtree. + for input in $(jq -r '. as $doc | $doc.nodes.root.inputs | to_entries[] | .key as $k | .value as $key | select(($key | type) == "string") | select($doc.nodes[$key].locked.type == "path") | $k' flake.lock 2>/dev/null); do + locked_path=$(jq -r --arg k "$input" '.nodes.root.inputs[$k] as $key | select(($key | type) == "string") | .nodes[$key].locked.path // ""' flake.lock) + case "$locked_path" in + /nix/store/*) continue ;; + esac nix flake update "$input" 2>/dev/null || true done + # Same for local `path:` inputs NESTED in a sub-flake: a plain lock keeps their + # stale narHash (the url string never changed), so refresh them explicitly. + for sub in $(subflakes_from_lock flake.lock); do + for input in $(jq -r --arg sub "$sub" ' + . as $doc + | $doc.nodes.root.inputs[$sub] as $key + | select(($key | type) == "string") + | $doc.nodes[$key].inputs | to_entries[] + | select(.value | type == "string") + | .key as $in | .value as $lk + | select($doc.nodes[$lk].locked.type == "path") + | select(($doc.nodes[$lk].locked.path | startswith("/nix/store/")) | not) + | $in + ' flake.lock 2>/dev/null); do + nix flake update "$sub/$input" 2>/dev/null || true + done + done + [ "$update_core" == "1" ] && nix flake update icedos-core --refresh 2>/dev/null || true ) if [ "$update_all" == "1" ]; then ( set -e - cd "$ICEDOS_STATE_DIR" + cd "$lock_dir" nix flake update --refresh ) elif [ "$update_repos_inputs" == "1" ]; then ( set -e - cd "$ICEDOS_STATE_DIR" + cd "$lock_dir" + # Repos first, then each module input nested in its sub-flake. Only STRING + # entries are real nodes — arrays are `follows`, and bumping one unpins nixpkgs. + mapfile -t subflakes < <(subflakes_from_lock flake.lock) + declare -A subflake_set + for sub in "${subflakes[@]}"; do subflake_set["$sub"]=1; done for input in $(jq -r ' .nodes.root.inputs | to_entries[] @@ -211,34 +273,63 @@ elif [ "$update_repos_inputs" == "1" ]; then | .key | select(startswith("icedos-")) ' flake.lock 2>/dev/null); do - nix flake update "$input" --refresh 2>/dev/null || true + # Not `printf | grep`: under pipefail, grep's early exit SIGPIPEs printf and + # the negation becomes a false positive. + if [ -z "${subflake_set[$input]:-}" ]; then + nix flake update "$input" --refresh 2>/dev/null || true + fi + done + for sub in "${subflakes[@]}"; do + for input in $(jq -r --arg sub "$sub" '.nodes.root.inputs[$sub] as $key | select(($key | type) == "string") | .nodes[$key].inputs | to_entries[] | select(.value | type == "string") | .key' flake.lock 2>/dev/null); do + nix flake update "$sub/$input" --refresh 2>/dev/null || true + done done ) fi -# Stop after the flake (and its lock) have been generated, without -# building anything. Lets callers evaluate the generated flake (e.g. to -# query per-package output paths) without realising the system closure. +# Convergence: the genflake above ran with the bakes suppressed, so a PATCHED +# input still embeds its pre-bump tree. Re-run with the fresh lock and re-lock. +if [ "$update_repos_inputs" == "1" ] || [ "$update_all" == "1" ]; then + # Sync FIRST: genflake reads the lock from ICEDOS_STATE_DIR. + sync_lock + flake_final=$(ICEDOS_UPDATE="" ICEDOS_UPDATE_MODULE_INPUTS="" \ + ICEDOS_STAGE="genflake" nix eval --raw $trace \ + --file "$ICEDOS_ROOT/lib/genflake.nix" flakeFinal) + printf '%s\n' "$flake_final" >"$ICEDOS_STATE_DIR/$FLAKE" + nixfmt "$ICEDOS_STATE_DIR/$FLAKE" + # Re-lock the changed sub-flake roots; unchanged nodes keep their pins. + rsync -a --exclude=".cache" "$ICEDOS_STATE_DIR/" "$lock_dir/" + ( + set -e + cd "$lock_dir" + nix flake lock + ) +fi + +# A failed lock step exits via the trap WITHOUT syncing, leaving `.state` on its +# previous lock; the next run re-attempts. +sync_lock + +# Lets callers evaluate the generated flake without realising the closure. if [ "$genflake_only" == "1" ]; then exit 0 fi [ "$update_nixpkgs" == "1" ] && [ "$update_all" != "1" ] && ( set -e - cd "$ICEDOS_STATE_DIR" + cd "$lock_dir" nix flake update nixpkgs ) +sync_lock +rm -rf "$lock_dir" +trap - EXIT -# Created here, not earlier: every path that exits before the build -# (--genflake-only, --export-search-index) would otherwise leave an empty temp -# dir behind on each run. Nothing between the arg parse and here reads it — -# genflake takes its paths from ICEDOS_ROOT/ICEDOS_STATE_DIR/ICEDOS_CONFIG_ROOT. +# Created here, not earlier: every path that exits before the build would +# otherwise leave an empty temp dir behind. export ICEDOS_BUILD_DIR="$(mktemp -d -t icedos-build-XXXXXXX-0)" -# Hold an exclusive flock on the build dir's `.lock` for the whole build so -# the automatic nh-clean temp-dir sweep skips it while it is in flight. The -# lock is released automatically when this process exits or dies, so a dir -# left behind by a crashed build is still cleaned up on the next gc. +# Held for the whole build so the nh-clean sweep skips this dir; released on +# exit, so a crashed build's dir is still collected next gc. exec 9>"$ICEDOS_BUILD_DIR/.lock" flock -n 9 || echo "warning: could not lock $ICEDOS_BUILD_DIR/.lock; a gc sweep may delete this build dir" >&2 diff --git a/flake.nix b/flake.nix index 921c7ff3..3b72ff9b 100644 --- a/flake.nix +++ b/flake.nix @@ -24,9 +24,7 @@ inherit (builtins) isString pathExists; isFlake = value: (value._type or null) == "flake"; - # config.toml is optional — a config root may be defined entirely by - # configs/*.toml and/or modules/. The flake itself (flake.nix) is the - # marker that identifies the root. + # config.toml is optional; flake.nix is what marks the config root. _configRoot = if ((isFlake configRoot) && (pathExists "${configRoot}/flake.nix")) then configRoot @@ -36,7 +34,7 @@ _stateDir = if (isString stateDir) then stateDir else (throw "The value of `stateDir` should be a string."); - inherit (import ./lib/load-user-config.nix _configRoot) icedos; + inherit (import ./lib/config/load-user-config.nix _configRoot) icedos; system = icedos.system.arch or "x86_64-linux"; pkgs = nixpkgs.legacyPackages.${system}; @@ -116,7 +114,7 @@ }; }; - # Eval-only lib tests (`lib/tests/tests.nix`) as a flake check. Any result + # Eval-only lib tests (`tests/tests.nix`) as a flake check. Any result # value other than "ok" fails the derivation. checks = let @@ -135,7 +133,7 @@ r = builtins.tryEval value; in if r.success then r.value else "FAIL: ${name} threw during evaluation" - ) (import ./lib/tests/tests.nix { inherit (pkgs) lib; }); + ) (import ./tests/tests.nix { inherit (pkgs) lib; }); failures = lib.filterAttrs (_: value: value != "ok") results; in diff --git a/lib/bash.nix b/lib/bash.nix new file mode 100644 index 00000000..c4e4b714 --- /dev/null +++ b/lib/bash.nix @@ -0,0 +1,433 @@ +{ + icedosLib, + lib, + self, + ... +}: + +let + inherit (builtins) + foldl' + pathExists + stringLength + ; + + inherit (lib) + concatStringsSep + escapeShellArg + fileContents + max + ; +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 = { + prelude = builtins.readFile ./prelude.sh; + + # PATH for systemd user services that shell out to host binaries and the + # per-user profile; `~/.nix-profile/bin` is a harmless legacy fallback. + exportSystemPath = '' + base_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + nix_system_path="/run/current-system/sw/bin" + nix_peruser_path="/etc/profiles/per-user/''${USER}/bin" + nix_user_path="''${HOME}/.nix-profile/bin" + export PATH="''${base_path}:''${nix_system_path}:''${nix_peruser_path}:''${nix_user_path}:$PATH" + ''; + + genHelpFlags = + { + excludeNoArgs ? false, + }: + let + base = ''"$1" == "--help" || "$1" == "-h" || "$1" == "help" || "$1" == "h"''; + in + if excludeNoArgs then base else ''"$1" == "" || '' + base; + + # Bash arg parser from a Nix flag spec, spliced into a script body. Emits a + # `die` polyfill for standalone consumers (toolset leaves get the prelude). + mkFlags = + { + prefix, + flags, + passthroughUnknown ? false, + }: + let + # Map flag name to bash variable name (e.g. "gpu-layers" → "LLAMACPP_GPU_LAYERS") + toVarName = name: "${prefix}_${lib.toUpper (builtins.replaceStrings [ "-" ] [ "_" ] name)}"; + + # Map flag name to "was set" tracking variable + toSetVar = name: "${toVarName name}_SET"; + + # Short flag pattern for case arm, e.g. "-H|" or "" + shortPat = f: if f ? short then "-${f.short}|" else ""; + + # Flag spec for help text alignment + flagSpec = + f: + let + shortPart = if f ? short then "-${f.short}, " else " "; + flagPart = "--${f.name}"; + typePart = + if f.type == "string" then + " " + else if f.type == "int" then + " " + else if f.type == "bool" then + "" + else if f.type == "enum" then + " <${lib.concatStringsSep "|" f.choices}>" + else + ""; + in + "${shortPart}${flagPart}${typePart}"; + + # Compute max flag spec width for alignment + maxSpecLen = foldl' max 0 (map (f: stringLength (flagSpec f)) flags); + + # Help text line for one flag + helpLine = + f: + let + spec = flagSpec f; + pad = maxSpecLen - stringLength spec + 2; + defaultHelp = + if f.type == "bool" then + if f.default then "true" else "false" + else if f.type == "int" then + toString (builtins.floor f.default) + else + toString f.default; + in + " ${spec}${ + lib.concatStringsSep "" (lib.genList (_: " ") pad) + }${f.description} (default: ${defaultHelp})"; + + # Full help text with real newlines + helpText = "Flags:\n${lib.concatStringsSep "\n" (map helpLine flags)}"; + + # Escaped default value for bash + escDefault = + f: + if f.type == "bool" then + if f.default then "true" else "false" + else if f.type == "int" then + lib.escapeShellArg (toString (builtins.floor f.default)) + else + lib.escapeShellArg (toString f.default); + + # Variable declarations for one flag + varDecl = f: "${toVarName f.name}=${escDefault f}\n${toSetVar f.name}=0"; + + # Generate case arms for one flag + genCaseArm = + f: + let + var = toVarName f.name; + svar = toSetVar f.name; + long = "--${f.name}"; + sp = shortPat f; + in + if f.type == "bool" then + '' + ${sp}${long}) + ${var}="true"; ${svar}=1 + shift + ;; + --no-${f.name}) + ${var}="false"; ${svar}=1 + shift + ;; + ${long}=true|${long}=false) + ${var}="''${1#${long}=}"; ${svar}=1 + shift + ;; + '' + else + '' + ${sp}${long}) + [[ $# -ge 2 ]] || die "${long} requires a value" + ${lib.optionalString ( + f.type == "int" + ) ''[[ "$2" =~ ^-?[0-9]+$ ]] || die "${long} must be an integer"''} + ${lib.optionalString (f.type == "enum") '' + case "$2" in + ${lib.concatStringsSep "|" f.choices}) ;; + *) die "invalid value for ${long}: $2 (choose: ${lib.concatStringsSep ", " f.choices})" ;; + esac + ''} + ${var}="$2"; ${svar}=1 + shift 2 + ;; + ${long}=*) + v="''${1#${long}=}" + ${lib.optionalString ( + f.type == "int" + ) ''[[ "$v" =~ ^-?[0-9]+$ ]] || die "${long} must be an integer"''} + ${lib.optionalString (f.type == "enum") '' + case "$v" in + ${lib.concatStringsSep "|" f.choices}) ;; + *) die "invalid value for ${long}: $v (choose: ${lib.concatStringsSep ", " f.choices})" ;; + esac + ''} + ${var}="$v"; ${svar}=1 + shift + ;; + ''; + + shorts = lib.filter (s: s != null) (map (f: if f ? short then f.short else null) flags); + in + assert lib.assertMsg (lib.all (n: builtins.match "^[a-z0-9-]+$" n != null) ( + map (f: f.name) flags + )) "mkFlags (${prefix}): flag names must match [a-z0-9-]+"; + assert lib.assertMsg (lib.all ( + s: builtins.match "^[a-zA-Z0-9-]+$" s != null + ) shorts) "mkFlags (${prefix}): short flags must match [a-zA-Z0-9-]+"; + assert lib.assertMsg ( + lib.length (lib.unique (map (f: toVarName f.name) flags)) == lib.length flags + ) "mkFlags (${prefix}): duplicate variable names generated"; + assert lib.assertMsg ( + lib.length (lib.unique shorts) == lib.length shorts + ) "mkFlags (${prefix}): duplicate short flags"; + assert lib.assertMsg ( + !(lib.elem "help" (map (f: f.name) flags)) + ) "mkFlags (${prefix}): 'help' is a reserved flag name"; + assert lib.assertMsg (!(lib.elem "h" shorts)) "mkFlags (${prefix}): 'h' is a reserved short flag"; + '' + if ! declare -F die >/dev/null 2>&1; then + die() { printf 'error: %s\n' "$*" >&2; exit 1; } + fi + + ${lib.concatStringsSep "\n" (map varDecl flags)} + + _HELP_TEXT=$(cat <<'__ICEDOS_MKFLAGS_EOF__' + ${helpText} + __ICEDOS_MKFLAGS_EOF__ + ) + + _REST=() + while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + echo "$_HELP_TEXT" + exit 0 + ;; + ${lib.concatStringsSep "\n" (map genCaseArm flags)} + --) + shift + _REST+=("$@") + break + ;; + ${ + if passthroughUnknown then + '' + -*) + _REST+=("$1") + shift + ;; + '' + else + '' + -*) + die "unknown flag: $1" + ;; + '' + } + *) + _REST+=("$1") + shift + ;; + esac + done + set -- "''${_REST[@]}" + ''; + + blueString = s: "\${BLUE}${s}\${NC}"; + greenString = s: "\${GREEN}${s}\${NC}"; + purpleString = s: "\${PURPLE}${s}\${NC}"; + redString = s: "\${RED}${s}\${NC}"; + yellowString = s: "\${YELLOW}${s}\${NC}"; + + dimBlueString = s: "\${DIM_BLUE}${s}\${NC}"; + dimGreenString = s: "\${DIM_GREEN}${s}\${NC}"; + dimPurpleString = s: "\${DIM_PURPLE}${s}\${NC}"; + dimRedString = s: "\${DIM_RED}${s}\${NC}"; + dimYellowString = s: "\${DIM_YELLOW}${s}\${NC}"; + + # Shared by `icedos configuration` diff/rollback/history: pairs a snapshot + # folder against the working tree over the same config-set file list. + configSet = + config: + let + inherit (config.icedos) configurationLocation; + configRoot = "${configurationLocation}/.."; + workingConfig = "${configRoot}/config.toml"; + configDirsArgs = concatStringsSep " " (map escapeShellArg config.icedos.system.extraConfigs); + in + { + inherit configRoot workingConfig configDirsArgs; + cacheDir = "${configurationLocation}/.cache"; + + # `walk_config_set ` calls fn per file in the UNION of both + # sides (added/removed included), in the caller's shell so fn can set vars. + walk = '' + CONFIG_DIRS=(${configDirsArgs}) + + walk_config_set() { + local root="''${1%/}" fn="$2" d b names f + "$fn" "config.toml" "$root/config.toml" "${workingConfig}" + shopt -s nullglob + for d in "''${CONFIG_DIRS[@]}"; do + names="$( + for f in "$root/$d/"*.toml "$root/$d/".*.toml \ + "${configRoot}/$d/"*.toml "${configRoot}/$d/".*.toml; do + basename "$f" + done | sort -u + )" + while IFS= read -r b; do + [ -n "$b" ] || continue + "$fn" "$d/$b" "$root/$d/$b" "${configRoot}/$d/$b" + done <<< "$names" + done + shopt -u nullglob + } + ''; + }; + + # Sets gc_age/gc_dt from the nh-clean timer's LastTriggerUSec (persists + # across reboots), or leaves them empty. Callers set `now` and format. + gcTimerCheckSnippet = { systemctl }: '' + last=$(${systemctl} show nh-clean.timer -p LastTriggerUSec --value --timestamp=unix 2>/dev/null) + ts="''${last#@}" + if [ -n "$ts" ] && [ "$ts" -gt 0 ] 2>/dev/null; then + gc_age=$(((now - ts) / 86400)) + gc_dt=$(date -d "@$ts" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "?") + else + gc_age="" + gc_dt="" + fi + ''; + + # Permission guard: running another user's baked build.sh executes their code + # as the invoker (or root). Args: config root, state dir, ORIGINAL args. + requireConfigOwner = '' + require_config_owner() { + local root="$1" state_dir="$2" orig="$1" owner me state answer rc PROMPT readable probe warn_suffix owner_home owner_path + shift 2 + # `test -d` cannot tell absent from inaccessible (a 0700 parent fails with + # EACCES), so probe with stat and treat only ENOENT as absent. + root=$(realpath -m "$root" 2>/dev/null) || root="$orig" + warn_suffix="" + if ! owner=$(LC_ALL=C stat -c %U "$root" 2>&1); then + case "$owner" in + *'No such file or directory'* | *'Not a directory'* | *'not a directory'*) + return 0 + ;; + esac + # Inaccessible — walk up to the first stat-able ancestor to find + # who owns the tree this command would execute. + owner="" + probe="$root" + while [ -n "$probe" ] && [ "$probe" != "/" ]; do + if owner=$(stat -c %U "$probe" 2>/dev/null); then + break + fi + case "$probe" in + */*) probe="''${probe%/*}" ;; + *) break ;; + esac + done + [ -n "$owner" ] || return 0 + warn_suffix=" (nearest accessible ancestor)" + fi + me=$(id -un) + state="''${state_dir:-$root/.state}" + + # Re-entered after an owner re-run — never prompt again, or unrelated + # sudo hops could ping-pong between users. + if [ -n "''${ICEDOS_OWNER_RERUN:-}" ]; then + return 0 + fi + + if [ -r "$root" ] && [ -x "$root" ] \ + && { [ ! -e "$state" ] || { [ -r "$state" ] && [ -x "$state" ]; }; }; then + readable=1 + else + readable=0 + fi + + if [ "$owner" != "$me" ]; then + # Another user's config root — warn whoever runs it, even when + # readable, so they know whose build.sh executes as them. + echo -e "''${YELLOW}warning''${NC}: configuration root '$root' is owned by '$owner'$warn_suffix; running it as '$me'." >&2 + # Root always prompts: `test -r`/`-x` pass for uid 0, so readability + # alone would let root silently run another user's build.sh. + if [ "$readable" = "0" ] || [ "$(id -u)" -eq 0 ]; then + echo " Re-running as '$owner' executes that user's build.sh with '$me' privileges." >&2 + if [ -t 0 ]; then + printf -v PROMPT '%b' "''${DIM_GREEN}>''${NC} Run 'icedos' as '$owner'? [y/N] " + read -r -p "$PROMPT" answer + case "$answer" in + [yY]|[yY][eE][sS]) ;; + # Root must abort: every test passes for uid 0, so no caller + # fall-through would stop the build. Non-root falls through flagged. + *) + if [ "$(id -u)" -eq 0 ]; then + die "aborted: declined to execute the configuration root of '$owner' as root" + fi + ICEDOS_OWNER_DECLINED=1 + return 0 + ;; + esac + else + # Unattended: an unreadable root aborts; an accessible one proceeds + # (already warned), so scripted rebuilds that elevate still work. + [ "$readable" = "0" ] && die "aborted: no permission to configuration root '$root' (owned by '$owner')" + return 0 + fi + + if [ "$(id -u)" -eq 0 ]; then + # `runuser` would inherit root's HOME/PATH and break the owner's nix, + # so pin the identity and a login PATH via `env -i` (mirrors nh.nix). + owner_home=$(getent passwd "$owner" | cut -d: -f6) + [ -n "$owner_home" ] || owner_home="/home/$owner" + 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" "$@" + 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" "$@" + fi + rc=$? + [ "$rc" -eq 0 ] && exit 0 + echo "re-run as '$owner' exited with $rc" >&2 + exit "$rc" + fi + return 0 + fi + + # Same owner, but the config isn't readable — they'll likely hit a + # confusing build failure, so point at the permissions up front. + if [ "$readable" = "0" ]; then + echo -e "''${YELLOW}warning''${NC}: you do not have read/execute permission on configuration root '$root'." >&2 + echo " Fix the permissions before running 'icedos rebuild' — the build will fail otherwise." >&2 + fi + return 0 + } + ''; + }; + + injectIfExists = + { file }: + if (pathExists file) then + '' + ( + ${fileContents file} + ) + '' + else + ""; + +} diff --git a/lib/build-package.nix b/lib/build-package.nix index 04fc759c..56697320 100644 --- a/lib/build-package.nix +++ b/lib/build-package.nix @@ -1,17 +1,5 @@ -# Thin wrapper over repl-context.nix that builds a single package.nix with -# icedosLib.packaging (extractAppImage, installDesktopEntry) injected into -# callPackage's auto-arg scope. Usage (from modules/nix.nix): -# -# nix-build --no-out-link --argstr packagePath "$PATH_ARG" -E '(import )' -# -# nix-build auto-calls the imported function; stateDir is baked into the -# wrapper and packagePath arrives via --argstr (never string-interpolated, so -# the caller's value can't inject Nix code). Do not regress to the old -# explicit-application form `{ stateDir = "..."; packagePath = "'"$PATH_ARG"'"; }` -# — that interpolated `$PATH_ARG` into the expression text. -# -# Reuses repl-context.nix so the getFlake/icedosLib instantiation logic is -# never duplicated between repl, nix_eval (MCP) and pkgs build. +# Builds one package.nix on the repl-context scope, with icedosLib.packaging in +# callPackage's args. `packagePath` MUST arrive via --argstr, never interpolated. { stateDir, packagePath, diff --git a/lib/color.nix b/lib/color.nix new file mode 100644 index 00000000..7548bba7 --- /dev/null +++ b/lib/color.nix @@ -0,0 +1,24 @@ +{ + icedosLib, + lib, + self, + ... +}: + +rec { + color = { + hexToRgbInts = + hex: + let + inherit (lib) fromHexString removePrefix; + inherit (builtins) substring; + h = removePrefix "#" hex; + in + [ + (fromHexString (substring 0 2 h)) + (fromHexString (substring 2 2 h)) + (fromHexString (substring 4 2 h)) + ]; + }; + +} diff --git a/lib/config-files.nix b/lib/config-files.nix deleted file mode 100644 index e40e353d..00000000 --- a/lib/config-files.nix +++ /dev/null @@ -1,107 +0,0 @@ -# Enumerate + load the ordered set of user config TOML files for a config root: -# -# [ config.toml, /*.toml (sorted), … ] -# -# `config.toml` is always the global base; every `*.toml` under each -# `icedos.system.extraConfigs` directory (default `configs`) is autoloaded on -# top of it. Hidden `..toml` files are included too — they load exactly -# like non-hidden ones (hiding is a gitignore concern, not a loader one), which -# is what makes `configs/.claude.toml` a local-only override. -# -# Per-file opt-out: any extra config file may set a top-level `enable = false` -# to skip loading itself (default `true` = loaded). `config.toml` is the base -# and always loads. `enable` is metadata — it is stripped from the returned -# content so it never reaches the raw NixOS passthrough as `config.enable`. -# -# Returns a list of `{ rel; content; }`: `content` is the parsed TOML (with the -# `enable` toggle removed), `rel` (config-root relative) is for error -# attribution / `setDefaultModuleLocation`. -# -# Kept self-contained (no `icedosLib`/`lib`): this is imported bare by -# `load-user-config.nix`, which itself runs before any icedosLib exists (and is -# re-imported by the generated build flake against `inputs.icedos-config`). -# Both consumers — `load-user-config.nix` and `modules/options.nix` — route -# through here so the loaded file set never drifts between them. -configRoot: -let - inherit (builtins) - attrNames - concatMap - filter - fromTOML - pathExists - readDir - readFile - removeAttrs - sort - stringLength - substring - ; - - hasSuffix = - suffix: str: - let - sl = stringLength suffix; - l = stringLength str; - in - l >= sl && substring (l - sl) sl str == suffix; - - readCfg = abs: fromTOML (readFile abs); - - # config.toml is OPTIONAL — a config root may be defined entirely by - # configs/*.toml and/or modules/. When it is absent, `extraConfigs` falls back - # to its default and config.toml simply isn't part of the loaded set. - mainPath = "${configRoot}/config.toml"; - hasMain = pathExists mainPath; - main = if hasMain then readCfg mainPath else { }; - - # Bootstrap value: `extraConfigs` is read from config.toml only (like - # system.arch / system.version), never from the extra-configs it selects; it - # defaults when there is no config.toml. - extraConfigsDirs = main.icedos.system.extraConfigs or [ "configs" ]; - - # Every regular `*.toml` directly under `dir`, name-sorted for a deterministic - # merge order, parsed. Missing dirs contribute nothing. - tomlFilesIn = - dir: - let - abs = "${configRoot}/${dir}"; - in - if !(pathExists abs) then - [ ] - else - let - entries = readDir abs; - names = sort (a: b: a < b) ( - filter (n: entries.${n} == "regular" && hasSuffix ".toml" n) (attrNames entries) - ); - in - map (n: { - rel = "${dir}/${n}"; - content = readCfg "${abs}/${n}"; - }) names; - - # Extra config files. A top-level `enable = false` drops the file (default: - # loaded). config.toml (the base) is never subject to this gate. - enabledExtra = filter (e: (e.content.enable or true) != false) ( - concatMap tomlFilesIn extraConfigsDirs - ); - - baseEntries = - if hasMain then - [ - { - rel = "config.toml"; - content = main; - } - ] - else - [ ]; - - entries = baseEntries ++ enabledExtra; -in -# Strip the `enable` toggle so it never reaches config (raw NixOS passthrough). -map (e: { - inherit (e) rel; - content = removeAttrs e.content [ "enable" ]; -}) entries diff --git a/lib/config/config-files.nix b/lib/config/config-files.nix new file mode 100644 index 00000000..8cf12aae --- /dev/null +++ b/lib/config/config-files.nix @@ -0,0 +1,81 @@ +# The ordered user config set -> [ { rel; content; } ]: config.toml, then every +# `*.toml` (hidden included, `enable = false` skipped) under extraConfigs. +configRoot: +let + inherit (builtins) + attrNames + concatMap + filter + fromTOML + pathExists + readDir + readFile + removeAttrs + sort + stringLength + substring + ; + + hasSuffix = + suffix: str: + let + sl = stringLength suffix; + l = stringLength str; + in + l >= sl && substring (l - sl) sl str == suffix; + + readCfg = abs: fromTOML (readFile abs); + + # Optional: a config root may be defined entirely by configs/*.toml or modules/. + mainPath = "${configRoot}/config.toml"; + hasMain = pathExists mainPath; + main = if hasMain then readCfg mainPath else { }; + + # Bootstrap value: read from config.toml only, never from the files it selects. + extraConfigsDirs = main.icedos.system.extraConfigs or [ "configs" ]; + + # Every regular `*.toml` directly under `dir`, name-sorted for a deterministic + # merge order, parsed. Missing dirs contribute nothing. + tomlFilesIn = + dir: + let + abs = "${configRoot}/${dir}"; + in + if !(pathExists abs) then + [ ] + else + let + entries = readDir abs; + names = sort (a: b: a < b) ( + filter (n: entries.${n} == "regular" && hasSuffix ".toml" n) (attrNames entries) + ); + in + map (n: { + rel = "${dir}/${n}"; + content = readCfg "${abs}/${n}"; + }) names; + + # Extra config files. A top-level `enable = false` drops the file (default: + # loaded). config.toml (the base) is never subject to this gate. + enabledExtra = filter (e: (e.content.enable or true) != false) ( + concatMap tomlFilesIn extraConfigsDirs + ); + + baseEntries = + if hasMain then + [ + { + rel = "config.toml"; + content = main; + } + ] + else + [ ]; + + entries = baseEntries ++ enabledExtra; +in +# Strip the `enable` toggle so it never reaches config (raw NixOS passthrough). +map (e: { + inherit (e) rel; + content = removeAttrs e.content [ "enable" ]; +}) entries diff --git a/lib/extra-options.nix b/lib/config/extra-options.nix similarity index 70% rename from lib/extra-options.nix rename to lib/config/extra-options.nix index 69fae3ea..0a9b32af 100644 --- a/lib/extra-options.nix +++ b/lib/config/extra-options.nix @@ -1,36 +1,5 @@ -# Translate the user-written `[extraOptions]` TOML table into a NixOS module -# that declares those options — letting a user define their own typed options -# purely from config.toml / configs/*.toml, with no Nix module to write. -# -# The `[extraOptions]` table is a recursive tree: -# * a node with a `type` key is a TYPED LEAF — one declared option. Every -# other key must be a known meta key (`default`, `description`, and the -# type's own constraints). -# * a node without `type` is a NAMESPACE — a pure grouping segment; every key -# is a child path segment (another table), never a scalar value. -# Option paths are full dotted paths from the config root, so -# `[extraOptions.icedos.applications.myapp]` with a leaf `enable` declares -# `options.icedos.applications.myapp.enable`, and `[extraOptions.services.myapp]` -# declares `options.services.myapp`. Non-icedos paths are allowed and merge with -# nixpkgs / IceDOS options. -# -# Values reach the running system by the same routes as ordinary config: -# * `icedos.*` options through the per-file `config.icedos` imports in -# modules/options.nix; -# * non-icedos options through the raw NixOS passthrough in genflake.nix. -# At the genflake stage that passthrough is absent, so `inject` re-applies the -# declared non-icedos values per-path — that is what makes the search index show -# real values instead of null for custom options. -# -# The generated module is wrapped in `lib.setDefaultModuleLocation` with -# `marker`, which lands the marker string in every declared option's -# `declarations` — genflake's optionsDoc keep-filter matches on it to include -# non-icedos custom options in the index. -# -# loadLibs constraint: `lib/default.nix` probes each lib file with a bare -# icedosLib and enumerates only the top-level exported names, so this file -# exports a single top-level attrset (`extraOptions`) and references icedosLib -# members only inside the exported functions. +# `[extraOptions]` TOML -> a NixOS module declaring those options. A node with a +# `type` is a typed leaf, one without is a namespace; paths are full dotted paths. { icedosLib, lib, @@ -70,10 +39,8 @@ let inherit (icedosLib) abortIf validate; - # Shared provenance marker. Landed in the `_file` of every generated module so - # (a) `o.declarations` carries it for the optionsDoc keep-filter, and (b) type - # / merge errors point at "config.toml / configs/*.toml" instead of an - # anonymous generated location. Also the `source` attribute for validate.*. + # Lands in every generated module's `_file`, so optionsDoc can keep-filter on it + # and errors name the config files. Also the validate.* `source`. marker = "extraOptions (config.toml / configs/*.toml)"; source = marker; @@ -93,9 +60,8 @@ let isNum = v: isInt v || isFloat v; - # Bare-name item types: the scalar types a `list`/`attrs` `item` may name - # directly. Composite / constraint-carrying items are written as a descriptor - # table instead (see `validateItem`). + # Scalar types a `list`/`attrs` `item` may name directly; anything constrained + # is written as a descriptor table instead. scalarTypes = [ "bool" "string" @@ -205,10 +171,8 @@ let ) ) true (attrNames node); - # Every field of a typed leaf must be a known meta key. `validateLeaf` returns - # `true` or throws; `validateItem` / `validateFields` recurse into the - # descriptor subtrees (`item`, `fields`) that shape a type rather than the - # option tree. + # Every field of a typed leaf must be a known meta key. The item/fields + # descriptors are validated by their own recursions. validateLeaf = path: node: let @@ -358,10 +322,8 @@ let max = node.max or null; }; - # Scalar types, with validate.* checks attached where constraints apply. The - # checks reuse the same path-aware validators as the `mk*Option` family, so a - # bad value reports `extraOptions.` (and `source`) instead of a bare - # nixpkgs type error. + # The same path-aware validators the `mk*Option` family uses, so a bad value + # reports `extraOptions.` instead of a bare nixpkgs type error. scalarType = path: node: let @@ -376,10 +338,8 @@ let else if t == "int" then types.addCheck types.int (validate.int (numConstraints node) (fmtPath path) source) else if t == "float" then - # `types.number` (not `types.float`): TOML parses whole numbers as ints, - # and validate.float already accepts ints — `types.float`'s `isFloat` - # check would reject them with a bare nixpkgs error before ours ran. - # Mirrors `mkFloatBetweenOption` in lib/options/helpers.nix. + # `types.number`, not `types.float`: TOML parses whole numbers as ints and + # `isFloat` would reject them before our validator ran. types.addCheck types.number (validate.float (numConstraints node) (fmtPath path) source) else if t == "nonEmptyString" then types.nonEmptyStr @@ -433,9 +393,7 @@ let else if t == "intList" then types.listOf types.int else if t == "floatList" then - # Same reasoning as scalar `float` (above): TOML parses whole numbers as - # ints, so a float list must accept ints too or `[1, 2]` gets rejected by - # `types.float`'s `isFloat` check before our constraints could run. + # Same as scalar `float`: `[1, 2]` is a list of ints in TOML. types.listOf types.number else if t == "list" then types.listOf (itemType path node) @@ -448,21 +406,8 @@ let else scalarType path node; - # Fail fast on a bad schema `default` OR a bad injected user value with the - # same rich, path-aware validator error a constrained scalar would get — - # surfaces at genflake, before any build. Constraint-carrying types (string / - # number / int / float / enum) run their `validate.*` validator (which throws - # the message); every other type falls back to `leafType.check`, which also - # covers the bool / list / attrs shapes nixpkgs would otherwise only check - # lazily when the option is read. Composite types are checked ONE level deep: - # list elements and attrs values against their `item` type, and record fields - # against their `fields` schema (with undeclared fields rejected), so a bad - # element/field fails at genflake too — not just a wrong container shape. - # Record's own `check` accepts any attrset (nixpkgs' submodule check: - # `isAttrs || isFunction || path.check`), so the shape gate passes and - # `recordOk` walks the fields; a non-attrs value for a record leaf is still - # rejected by the shape gate. Nested item/field `record`s are NOT recursively - # checked here (their submodule types check when forced) — document that limit. + # Eager, path-aware check of a schema default or injected value at genflake. + # Composites are checked ONE level deep; nested records check when forced. eagerCheck = path: node: value: let @@ -494,9 +439,8 @@ let else null; - # Deep check for the composite families. `all`/`filter` over the container - # is only reached after the container's own `check` passed, so `value` is - # an attrset for attrs/record and a list for the list family. + # Only reached after the container's own `check` passed, so `value` already + # has the right shape. compositeOk = if t == "record" then let @@ -541,13 +485,8 @@ let mkOptionValue = path: node: let - # Only set `default` when the schema declares one. nixpkgs feeds - # `mkOptionDefault opt.default` into the definition list and type-checks it, - # so an implicit `default = null` on a typed option makes an *unset* option - # fail as "null doesn't match the type" instead of resolving — and an option - # the user never touches then aborts the whole build when read. Omitting it - # gives the standard nixpkgs contract: resolve the user value if set, else - # "was accessed but has no value defined" (tryEval'd to null in the search index). + # Only when the schema declares one: an implicit `default = null` is fed + # through the type check and fails every unset typed option. option = mkOption ( { type = leafType path node; @@ -581,9 +520,7 @@ in extraOptions = { inherit marker; - # Turn the `[extraOptions]` schema into a NixOS module declaring every - # option it describes. The root must be a namespace — options are declared - # at full dotted paths below it, never at the root itself. + # Schema -> a module declaring every option. The root must be a namespace. declare = schema: let @@ -601,15 +538,8 @@ in options = buildTree [ ] schema; }; - # genflake-stage value injection: per-path `config. = ` for - # every declared NON-icedos option the user actually set, so the search index - # shows real values. `icedos.*` options are skipped (their values already - # flow through the per-file `config.icedos` imports in modules/options.nix), - # as are paths under `extraOptions` itself (that table holds schema, not - # values). A declared path that traverses a non-table intermediate aborts - # loudly instead of degrading to a silent null, and every injected value is - # eagerly type-checked against its declared leaf — a wrong-typed value fails - # at genflake, not silently until something reads the option. + # Genflake-stage `config. = ` for every declared non-icedos + # option the user set (icedos.* already flow through the config imports). inject = schema: userConfig: let diff --git a/lib/load-user-config.nix b/lib/config/load-user-config.nix similarity index 68% rename from lib/load-user-config.nix rename to lib/config/load-user-config.nix index e6270edb..21acb299 100644 --- a/lib/load-user-config.nix +++ b/lib/config/load-user-config.nix @@ -20,15 +20,12 @@ let }) (filter (n: pred n set.${n}) (attrNames set)) ); - # Ordered, pre-parsed config files: config.toml (global base) then every - # enabled configs/*.toml (see lib/config-files.nix, which also applies the - # per-file `enable` toggle and strips it). Shared with modules/options.nix so - # the two consumers can never disagree about which files are loaded. + # Shared with modules/options.nix, so the two can never disagree about which + # files are loaded. configFiles = import ./config-files.nix configRoot; - # Deep-merge b into a: attrs recurse, lists concatenate, and defining the same - # scalar key in two different files is a hard error. `bRel` names the file b - # came from so the collision is actionable. + # Attrs recurse, lists concatenate, the same scalar key in two files is a hard + # error naming both. mergeStrict = bRel: path: a: b: let diff --git a/lib/default.nix b/lib/default.nix index ec5d0e0d..ff9990d1 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -43,7 +43,14 @@ loadLibs [ ./logger.nix ./options/validate.nix ./options/helpers.nix - ./extra-options.nix - ./helpers.nix + ./config/extra-options.nix + ./bash.nix + ./color.nix + ./users.nix + ./pkgs.nix + ./packaging.nix + ./toolset.nix + ./scan.nix + ./inputs.nix ./icedos.nix ] diff --git a/lib/genflake.nix b/lib/genflake.nix index abb968d7..ba266a2e 100644 --- a/lib/genflake.nix +++ b/lib/genflake.nix @@ -1,6 +1,6 @@ let inherit (builtins) toJSON; - userConfig = import ./load-user-config.nix ICEDOS_CONFIG_ROOT; + userConfig = import ./config/load-user-config.nix ICEDOS_CONFIG_ROOT; inherit (userConfig) icedos; system = icedos.system.arch or "x86_64-linux"; @@ -48,24 +48,13 @@ let validate ; - # `[extraOptions]` (config.toml / configs/*.toml): a recursive table of - # user-declared, typed options. The schema lives under the `extraOptions` - # key; each declared option's VALUES live at their real paths in userConfig - # (e.g. `[extraOptions.services.myapp]` declares `options.services.myapp.*`, - # `[services.myapp]` sets them). `declare` turns the schema into a NixOS - # module declaring every option; `inject` re-applies the user-set non-icedos - # values per-path at the genflake stage (where the raw NixOS passthrough that - # carries them to the build stage does not run) so the search index shows real - # values instead of null. An absent schema degrades to `declare {}` (empty - # options) + `inject {} userConfig` (empty list) — no-op, no error. + # `[extraOptions]` declares user options; their VALUES live at their real paths. + # `inject` re-applies them here, where the build-stage passthrough doesn't run. extraSchema = userConfig.extraOptions or { }; extraOptionsDeclare = icedosLib.extraOptions.declare extraSchema; extraOptionsInject = icedosLib.extraOptions.inject extraSchema userConfig; - # User module/config directories (config-root relative), read raw here the - # same way modules/options.nix declares their defaults. Drive the config-flake - # filter (below), the generated extra-module imports, and — for configs — - # lib/config-files.nix via load-user-config.nix / options.nix. + # Read raw (bootstrap path), mirroring the defaults in modules/options.nix. extraModulesDirs = icedos.system.extraModules or [ "modules" ]; extraConfigsDirs = icedos.system.extraConfigs or [ "configs" ]; @@ -75,23 +64,12 @@ let "config.toml" ]; - # Directory prefixes (config-root relative) kept in the filtered config - # flake. `extraModulesDirs` / `extraConfigsDirs` drive both the filter and - # the actual module/config imports. - # - # Keep-list constraint: anything an extra-module file (or a `lib` field it - # imports, e.g. a module-adjacent `lib.nix`) requires must live inside the - # kept set above — genflake imports the config root 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. An extra module's contribution file belongs under one of the - # extra-module dirs (e.g. `modules//lib.nix`), which is kept. + # Anything an extra module imports must live in the kept set: genflake reads the + # config root live, the build stage only this snapshot. configRootKeepDirs = extraModulesDirs ++ extraConfigsDirs; - # Patch files declared by `[[icedos.repositories]]` `patches`. They must - # survive into the filtered config flake so the build stage can read them - # from `inputs.icedos-config`: build-stage eval is pure and cannot reach the - # host config root the way the impure genflake eval can. + # Repo patch files must survive into the snapshot: build-stage eval is pure and + # cannot reach the host config root. repoPatchKeep = flatten ( map (r: (r.patches or [ ]) ++ map (ip: ip.patches or [ ]) (r.inputPatches or [ ])) ( icedos.repositories or [ ] @@ -115,13 +93,8 @@ let channels = icedos.system.channels or [ ]; - # isFirstBuild is framework-owned (readOnly, no default — see - # modules/options.nix). A user-set value now aborts eval with nixpkgs' - # generic "read-only, but it's set multiple times"; catch it here (before - # evalModules) with a message pointing at the real toggle. Asserted from the - # exported attrset below, so both genflake entry points — the search index - # (`optionsDoc`/`userConfigRaw`) and the generated flake (`flakeFinal`) — - # abort on the same friendly error. + # Catch a user-set `isFirstBuild` before evalModules turns it into nixpkgs' + # generic readOnly error, and point at `forceFirstBuild` instead. isFirstBuildGuard = validate.abort { when = builtins.hasAttr "isFirstBuild" (icedos.system or { }); path = "icedos.system.isFirstBuild"; @@ -130,18 +103,12 @@ let isFirstBuild = !pathExists "/run/current-system/source" || (icedos.system.forceFirstBuild or false); - # Whether to inline the host's /etc/nixos/hardware-configuration.nix into - # the generated system. On by default so the machine's essentials - # (filesystems, kernel modules, microcode, …) always apply; read raw here - # since the injection decision happens at genflake stage. Mirrors the - # `icedos.system.loadHardwareConfiguration` option default in modules/options.nix. + # Inline /etc/nixos/hardware-configuration.nix; read raw because the injection + # decision happens here. Default mirrors modules/options.nix. loadHardwareConfiguration = icedos.system.loadHardwareConfiguration or true; - # `[[icedos.system.overlays.fromChannel]]` entries. Each must set either - # `channel` (existing `[[icedos.system.channels]]` name) or `url` (flake - # URL — registered as `icedos-overlay-`); `channel` wins - # when both are set. Validation aborts here with rich path messages so - # users see the offending entry, not a deep nix trace. + # Each entry sets `channel` or `url` (`channel` wins). Validated here so the + # user sees the offending entry, not a deep nix trace. overlayChannelsRaw = icedos.system.overlays.fromChannel or [ ]; # Read raw TOML — missing fields default to "" / [] so validation messages @@ -160,9 +127,8 @@ let msg = "must set either 'channel' (existing [[icedos.system.channels]] name) or 'url' (flake URL)"; }; - # Force every check; failures already threw. `if-then-raw` keeps the second - # branch unreachable but ties the validation result to the produced list. - # Entries with empty `packages` are silently dropped (no-op overlay). + # `if-then-raw` ties the (already-throwing) checks to the produced list. + # Entries with empty `packages` are dropped as no-ops. overlayChannels = let normalised = map overlayEntry overlayChannelsRaw; @@ -183,17 +149,14 @@ let value = { inherit (e) url; }; }) (filter isOverlayUrlMode overlayChannels); - # `[[icedos.system.extraFlakes]]` `name` values become top-level flake inputs, - # so they must not collide with genflake's own generated input names, channel - # names, or overlay input names — a duplicate key would silently overwrite in - # `listToAttrs` below. (The `icedos.system.extraFlakes` option doc carries the - # same rule; this fires at genflake for the generated-flake path. Masked-input - # collisions with module-declared inputs are caught by `_extractNixosModules`.) + # extraFlake names become root inputs, so a collision with a channel, overlay, + # sub-flake or reserved name would silently overwrite in `listToAttrs` below. extraFlakeNameGuard = validate.abort { when = (lib.intersectLists (map (f: f.name or "") (icedos.system.extraFlakes or [ ])) ( (map (c: c.name or "") channels) ++ (map (e: e.name) overlayInputs) + ++ (builtins.attrNames modulesFromConfig.subFlakes) ++ [ "nixpkgs" "home-manager" @@ -203,7 +166,7 @@ let ] )) != [ ]; path = "icedos.system.extraFlakes"; - msg = "name collides with a [[icedos.system.channels]] name, an overlay input name, or a genflake-reserved input name"; + msg = "name collides with a [[icedos.system.channels]] name, an overlay input name, a module sub-flake name, or a genflake-reserved input name"; }; nixpkgsInput = { @@ -266,22 +229,17 @@ let inherit icedosLib lib; inputs.icedos-config = ICEDOS_CONFIG_ROOT; }) - # Inject the genflake-stage computation of isFirstBuild: it has no default - # (readOnly), so `toJSON evaluated` below would otherwise throw - # "was accessed but has no value defined". Build-stage injection happens in `flakeFinal`. + # No default (readOnly), so `toJSON evaluated` would throw without this. { icedos.system.isFirstBuild = isFirstBuild; } - # Derived, read-only view of the loaded module set. Computed from the raw - # icedos config by modulesFromConfig (no dependency on the evaluated - # config), so injecting it here cannot create a circular evaluation. + # Computed from the RAW config, so injecting it here cannot recurse. { icedos.system.loadedModules = modulesFromConfig.loadedModules; } ] ++ modulesFromConfig.options - # Declare every `[extraOptions]` option, and re-apply its non-icedos values - # per-path (the raw passthrough only runs at build stage, so without this the - # genflake-stage eval — optionsDoc / evaluatedConfig — would show null). + # Without the re-apply the genflake-stage eval would show null (the raw + # passthrough only runs at build stage). ++ [ extraOptionsDeclare ] ++ extraOptionsInject; }; @@ -290,10 +248,8 @@ let evaluatedConfig = toJSON evaluated; - # Map an absolute declaration/source path to a stable repo-relative one. - # Production evals resolve paths into /nix/store/-source/…; dev (path: - # override) resolves them under the core root. Strip whichever applies so the - # emitted pointer is usable against a repo checkout — and identical in both modes. + # Absolute declaration path -> repo-relative, identical in store and dev-path + # evals, so the emitted pointer works against a checkout. repoRelative = p: let @@ -308,27 +264,15 @@ let else s; - # Searchable index of every IceDOS option (path, type, description, current - # value) — consumed by `icedos configuration search`. Reuses the same evalModules - # as `evaluatedConfig`: type/description come from `.options`, the value from - # `.config` (`evaluated`). + # Option index for `icedos configuration search`: type/description from + # `.options`, current value from the merged `.config`. optionsDoc = let - # Walk the evaluated options tree with `collect isOption`, which treats - # each option as a leaf and never expands submodule internals. This is - # deliberate: `optionAttrSetToDocList` would recurse through - # `getSubOptions`, and `toolsetCommandType` (commands → commands → …) is - # infinitely self-recursive, overflowing the stack. The cost is that - # submodule-list fields (users..*, repositories.*) aren't listed - # individually; plain nested options (build-vm.memory, system.packages, …) - # all are. - - # The option's effective value: user override if set, else the resolved - # default. Read from `evaluated` (the merged `.config`), not the raw - # `.options` default. `tryEval` guards `throw`/`assert`-based defaults; - # note it can NOT catch missing-attribute errors, so any default that - # forces an absent input must be made presence-safe at its source (see - # `cache.key` in modules/options.nix) or it aborts the whole index. + # `collect isOption`, not `optionAttrSetToDocList`: the latter recurses into + # submodules, and `toolsetCommandType` is infinitely self-recursive. + + # `tryEval` guards throwing defaults but NOT missing attributes — a default + # forcing an absent input must be presence-safe at its source. renderValue = o: let @@ -336,8 +280,7 @@ let in if r.success then r.value else null; - # Descriptions are plain strings on modern nixpkgs but may arrive as an - # `{ _type = "mdDoc"; text; }` literal — normalise to a bare string. + # Descriptions may arrive as an `{ _type = "mdDoc"; text; }` literal. renderDescription = o: let @@ -345,8 +288,7 @@ let in if builtins.isAttrs d then (d.text or null) else d; - # Where the option is declared, as ":". Prefer - # declarationPositions (carries line); fall back to declarations (file only). + # ":"; `declarations` (file only) is the fallback. renderDeclaredAt = o: let @@ -366,9 +308,7 @@ let else null; - # A `[extraOptions]`-declared option: its declaring module is wrapped in - # `setDefaultModuleLocation` with the marker, which lands in every - # declared option's `declarations` list. + # The marker lands in every `[extraOptions]`-declared option's `declarations`. isExtraOption = o: lib.elem icedosLib.extraOptions.marker (o.declarations or [ ]); in toJSON ( @@ -390,17 +330,13 @@ let ) ); - # Full module graph for `icedos modules`: every module available in every repo - # that contributes a loaded module — configured *and* transitive dependency - # repos — each flagged enabled (loaded) / explicit (user-listed) plus its - # dependency edges, so disabled siblings show up next to the enabled ones. + # Module graph for `icedos modules`: every module of every contributing repo, + # flagged enabled/explicit with its dependency edges. modulesDoc = let repos = icedos.repositories or [ ]; - # The loaded set: explicitly-enabled modules + their resolved deps. Used - # to flag which catalog entries are active and to discover every repo in - # play — each module's _repoInfo already carries the full file list. + # Enabled modules + resolved deps; also how every repo in play is discovered. resolved = resolveExternalDependencyRecursively { newDeps = repos; loadOverrides = true; @@ -409,10 +345,8 @@ let moduleKey = m: "${m._repoInfo.url}/${m.meta.name}"; loadedKeys = map moduleKey resolved.modules; - # Every distinct fetched repo (deduped by url), configured *and* transitive. - # `_repoInfo.files` is the complete module list, so re-loading it surfaces - # disabled siblings (e.g. providers' jovian) with no extra fetch. - # Extra-modules (url = "config") carry no `files`. + # `_repoInfo.files` is the complete module list, so disabled siblings surface + # with no extra fetch. Extra modules (url = "config") carry no `files`. realRepoInfos = builtins.attrValues ( listToAttrs ( map (ri: { @@ -449,9 +383,7 @@ let (m.meta.name == "default") || elem m.meta.name (explicitByRepo.${m._repoInfo.url} or [ ]); }; - # Drop every `default` module: it's an always-on baseline aggregator (one - # per repo), not a user-selectable module — its deps still appear as their - # own entries. + # `default` is an always-on aggregator, not a user-selectable module. deduped = builtins.attrValues ( listToAttrs ( map (m: { @@ -468,11 +400,11 @@ let allowPrettyValues = true; } flakeInputs; - # Full merged user config as JSON — config.toml + every enabled - # configs/*.toml (see lib/load-user-config.nix). Consumed by build.sh - # (--export-search-index) to replace the old toml2json-of-config.toml - # export, so the webui sees the complete config set, not just config.toml. + # The complete merged config set for the webui, not just config.toml. userConfigRaw = toJSON userConfig; + + # Sub-flake texts leave this stage only as the root inputs' store paths; nothing + # else is exported (build.sh reads the resulting flake.lock). in assert isFirstBuildGuard; assert extraFlakeNameGuard; @@ -511,7 +443,7 @@ assert extraFlakeNameGuard; inherit (pkgs) lib; inherit (builtins) pathExists; - userConfig = import "''${inputs.icedos-core}/lib/load-user-config.nix" "''${inputs.icedos-config}"; + userConfig = import "''${inputs.icedos-core}/lib/config/load-user-config.nix" "''${inputs.icedos-config}"; inherit (userConfig) icedos; icedosLib = import "''${inputs.icedos-core}/lib" { @@ -523,27 +455,17 @@ assert extraFlakeNameGuard; inherit (icedosLib) getModules modulesFromConfig; - # Build-stage re-declaration of `[extraOptions]` options. Re-derived - # here (not interpolated from the genflake value): the generated flake - # evaluates against the filtered config snapshot, and the schema must - # match what this stage reads. + # Re-derived, not interpolated: this stage reads the filtered snapshot. extraOptionsDeclare = icedosLib.extraOptions.declare (userConfig.extraOptions or { }); in { - # The module-facing lib as a first-class flake output: the exact - # value `specialArgs.icedosLib` shares (one `modulesFromConfig` - # evaluation), so repl-context / MCP `nix_eval` read the same merged - # lib the module system used. + # The same value `specialArgs.icedosLib` gets, so repl-context and MCP + # `nix_eval` read the lib the module system actually used. icedosLib = modulesFromConfig.closureLib; nixosConfigurations.icedos = nixpkgs.lib.nixosSystem rec { specialArgs = { - # Modules see the merged lib: base + every module's top-level - # `lib` field contribution, merged over the FULLY-RESOLVED - # closure. Reuses `modulesFromConfig.closureLib` — the exact - # value the phase-2 module-file/extra-module re-imports were - # made with — so the module system and the module files share - # one merged lib and no second `_mergeModuleLibs` fold happens - # here. The genflake-side uses below keep the base `icedosLib`. + # Reused (not re-merged), so module files and the module system share + # one lib. Genflake-side uses below keep the base `icedosLib`. icedosLib = modulesFromConfig.closureLib; inherit inputs; }; @@ -556,10 +478,8 @@ assert extraFlakeNameGuard; inherit (icedosLib) mkStrOption; in { - # readOnly: not declared in modules/options.nix, so a - # config.toml-set value already aborts at genflake with - # "option does not exist"; readOnly additionally guards - # module-set values at build stage. + # config.toml values already abort at genflake ("option does not + # exist"); readOnly guards module-set values at build stage. options.icedos.configurationLocation = mkStrOption { readOnly = true; default = "${ICEDOS_STATE_DIR}"; @@ -572,10 +492,8 @@ assert extraFlakeNameGuard; documentation.nixos.enable = false; } - # Loaded module set (derived, read-only): repo base url -> names. - # Computed by modulesFromConfig from the raw icedos config, so no - # circular dependency on the evaluated config. Backs - # `icedosLib.hasModule`. + # repo url -> names, computed from the RAW config (no circularity). + # Backs `icedosLib.hasModule`. { icedos.system.loadedModules = modulesFromConfig.loadedModules; } @@ -584,9 +502,7 @@ assert extraFlakeNameGuard; imports = getModules "''${inputs.icedos-core}/modules"; } - # Extra modules and stateVersion. Each configured extra-module - # directory (default `modules`) is scanned and imported; missing - # ones are skipped. + # Extra modules and stateVersion; missing dirs are skipped. { imports = lib.flatten (map ( d: @@ -598,13 +514,8 @@ assert extraFlakeNameGuard; config.system.stateVersion = "${icedos.system.version}"; } - # Raw NixOS config passthrough: every top-level table in - # config.toml / configs/*.toml *except* [icedos.*] is applied verbatim - # as NixOS config. nixpkgs' module system types & validates each option — - # IceDOS declares no schema. (home-manager is reachable the usual way, - # under [home-manager.users..*].) The `extraOptions` table is a - # declaration schema, not values, so it is excluded here (its options are - # declared by `extraOptionsDeclare` below). + # Every top-level table except [icedos.*] is applied verbatim as NixOS + # config; `extraOptions` is a schema, not values, so it is excluded. (lib.setDefaultModuleLocation "config.toml / configs/*.toml (raw NixOS passthrough)" { config = builtins.removeAttrs userConfig [ "icedos" "extraOptions" ]; }) @@ -645,12 +556,8 @@ assert extraFlakeNameGuard; '' ( { config, lib, ... }: { - # `lib.mkBefore` keeps these overlays at the head of - # `nixpkgs.overlays` so they swap the package source - # *before* downstream patch overlays (e.g. cosmic - # patches) run via `prev..overrideAttrs`. Without - # it the swap clobbers patches that already landed on - # the base derivation. + # Head of the list, so the source swap runs BEFORE downstream + # `overrideAttrs` patch overlays it would otherwise clobber. nixpkgs.overlays = lib.mkBefore (icedosLib.pkgs.overlaysFromChannel config.icedos ${target} [ ${pkgList} ]); } ) diff --git a/lib/helpers.nix b/lib/helpers.nix deleted file mode 100644 index 531bdb44..00000000 --- a/lib/helpers.nix +++ /dev/null @@ -1,1285 +0,0 @@ -{ - icedosLib, - lib, - self, - ... -}: - -let - inherit (builtins) - attrNames - fromJSON - listToAttrs - pathExists - readFile - replaceStrings - seq - ; - - inherit (icedosLib) generateAttrPath; - - inherit (builtins) foldl' stringLength; - - inherit (lib) - concatMap - concatMapStrings - concatStrings - concatStringsSep - escapeShellArg - fileContents - filterAttrs - flatten - genList - hasAttr - hasAttrByPath - hasSuffix - mapAttrs - mapAttrsToList - max - optional - optionalString - sort - ; - - inherit (icedosLib) - abortIf - ICEDOS_STAGE - ICEDOS_STATE_DIR - INPUTS_PREFIX - stringStartsWith - ; - -in -rec { - # Lists module entry points under `path`: subdir paths whose dir contains - # a `default.nix`, plus flat `.nix` files (excluding `default.nix` - # itself). Preserves the input type — Nix-path stays path, string stays - # string — so the result drops straight into `imports`. - getModules = - path: - let - entries = builtins.readDir path; - isDir = _: v: v == "directory"; - isNixFile = n: v: v == "regular" && hasSuffix ".nix" n && n != "default.nix"; - dirs = attrNames (filterAttrs isDir entries); - files = attrNames (filterAttrs isNixFile entries); - dirHasDefault = dir: pathExists (path + "/${dir}/default.nix"); - in - map (dir: path + "/${dir}") (builtins.filter dirHasDefault dirs) - ++ map (file: path + "/${file}") files; - - # Whether an IceDOS module is part of this config. Replaces probing fake - # option paths for recognition (e.g. `(config.icedos.desktop.kde.dynamic-workspaces or null) != null`). - # Resolution order: - # - `url` given -> check `loadedModules.${url}` contains every entry - # of `modules` (or `name`). - # - `repoUrl` given -> check `loadedModules.${repoUrl}` (the calling - # module's own repo, threaded through `_extractNixosModules`). - # - neither -> scan every repo's loaded list for every entry. - # A malformed call aborts: pass `name`, or a non-empty `modules` list — an - # empty `modules = []` would make the membership check vacuously true. - # DE detection: a DE repo is present iff it is configured, which is exactly - # `hasModule { inherit config; url = "github:icedos/"; modules = [ "default" ]; }` - # — every DE repo always loads its `default` module when configured. The - # DE-specific consumers of this pattern (session targets, accent resolution) - # live in the desktop repo's repo-root `lib.nix` (contributed via that repo's - # `default` module `lib` field); keep `hasModule` itself generic and - # repo-agnostic. - hasModule = - { - config, - name ? null, - url ? null, - repoUrl ? null, - modules ? null, - }: - let - inherit (config.icedos.system) loadedModules; - # A malformed call is a bug: `modules = []` makes `lib.all` vacuously - # true (silently reporting the module "present"), and omitting BOTH - # `name` and `modules` asks for nothing. `seq` forces the abort even when - # `loadedModules == {}` would otherwise short-circuit the scan below. - names = - seq - (abortIf ( - modules == [ ] || (name == null && modules == null) - ) "hasModule: pass a module name or a non-empty modules list") - (if modules != null then modules else [ name ]); - inUrl = u: lib.all (n: lib.elem n (loadedModules.${u} or [ ])) names; - in - seq names ( - if url != null then - inUrl url - else if repoUrl != null then - inUrl repoUrl - else - lib.any inUrl (builtins.attrNames loadedModules) - ); - - # Runtime bash helpers shared between Nix-embedded scripts (via the - # auto-prepended `prelude` from toolset.nix:41) and standalone .sh files - # (which `source` core/lib/prelude.sh directly). Both layers see the - # same color vars, log_* / die / is_help_flag functions. - bash = { - prelude = builtins.readFile ./prelude.sh; - - # PATH export used by icedos systemd user services that shell out to - # binaries from the host (e.g. systemctl, loginctl) and the user's - # per-user system profile (`/etc/profiles/per-user/$USER`, where home-manager - # installs packages under `useUserPackages`), in addition to whatever - # derivation the unit ships. The legacy `~/.nix-profile/bin` is kept as a - # harmless fallback (empty once `home.packages` move to the per-user profile). - # Spliced into writeShellScript bodies via `${icedosLib.bash.exportSystemPath}`. - exportSystemPath = '' - base_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" - nix_system_path="/run/current-system/sw/bin" - nix_peruser_path="/etc/profiles/per-user/''${USER}/bin" - nix_user_path="''${HOME}/.nix-profile/bin" - export PATH="''${base_path}:''${nix_system_path}:''${nix_peruser_path}:''${nix_user_path}:$PATH" - ''; - - genHelpFlags = - { - excludeNoArgs ? false, - }: - let - base = ''"$1" == "--help" || "$1" == "-h" || "$1" == "help" || "$1" == "h"''; - in - if excludeNoArgs then base else ''"$1" == "" || '' + base; - - # Generate a bash arg parser from a Nix flag spec. Returns bash source that - # must be spliced into a script body (e.g. via `${icedosLib.bash.mkFlags {}}`). - # Toolset leaves get the prelude auto-injected, so `die` is always available; - # standalone consumers get a `die` polyfill at the top of the emitted block. - mkFlags = - { - prefix, - flags, - passthroughUnknown ? false, - }: - let - # Map flag name to bash variable name (e.g. "gpu-layers" → "LLAMACPP_GPU_LAYERS") - toVarName = name: "${prefix}_${lib.toUpper (builtins.replaceStrings [ "-" ] [ "_" ] name)}"; - - # Map flag name to "was set" tracking variable - toSetVar = name: "${toVarName name}_SET"; - - # Short flag pattern for case arm, e.g. "-H|" or "" - shortPat = f: if f ? short then "-${f.short}|" else ""; - - # Flag spec for help text alignment - flagSpec = - f: - let - shortPart = if f ? short then "-${f.short}, " else " "; - flagPart = "--${f.name}"; - typePart = - if f.type == "string" then - " " - else if f.type == "int" then - " " - else if f.type == "bool" then - "" - else if f.type == "enum" then - " <${lib.concatStringsSep "|" f.choices}>" - else - ""; - in - "${shortPart}${flagPart}${typePart}"; - - # Compute max flag spec width for alignment - maxSpecLen = foldl' max 0 (map (f: stringLength (flagSpec f)) flags); - - # Help text line for one flag - helpLine = - f: - let - spec = flagSpec f; - pad = maxSpecLen - stringLength spec + 2; - defaultHelp = - if f.type == "bool" then - if f.default then "true" else "false" - else if f.type == "int" then - toString (builtins.floor f.default) - else - toString f.default; - in - " ${spec}${ - lib.concatStringsSep "" (lib.genList (_: " ") pad) - }${f.description} (default: ${defaultHelp})"; - - # Full help text with real newlines - helpText = "Flags:\n${lib.concatStringsSep "\n" (map helpLine flags)}"; - - # Escaped default value for bash - escDefault = - f: - if f.type == "bool" then - if f.default then "true" else "false" - else if f.type == "int" then - lib.escapeShellArg (toString (builtins.floor f.default)) - else - lib.escapeShellArg (toString f.default); - - # Variable declarations for one flag - varDecl = f: "${toVarName f.name}=${escDefault f}\n${toSetVar f.name}=0"; - - # Generate case arms for one flag - genCaseArm = - f: - let - var = toVarName f.name; - svar = toSetVar f.name; - long = "--${f.name}"; - sp = shortPat f; - in - if f.type == "bool" then - '' - ${sp}${long}) - ${var}="true"; ${svar}=1 - shift - ;; - --no-${f.name}) - ${var}="false"; ${svar}=1 - shift - ;; - ${long}=true|${long}=false) - ${var}="''${1#${long}=}"; ${svar}=1 - shift - ;; - '' - else - '' - ${sp}${long}) - [[ $# -ge 2 ]] || die "${long} requires a value" - ${lib.optionalString ( - f.type == "int" - ) ''[[ "$2" =~ ^-?[0-9]+$ ]] || die "${long} must be an integer"''} - ${lib.optionalString (f.type == "enum") '' - case "$2" in - ${lib.concatStringsSep "|" f.choices}) ;; - *) die "invalid value for ${long}: $2 (choose: ${lib.concatStringsSep ", " f.choices})" ;; - esac - ''} - ${var}="$2"; ${svar}=1 - shift 2 - ;; - ${long}=*) - v="''${1#${long}=}" - ${lib.optionalString ( - f.type == "int" - ) ''[[ "$v" =~ ^-?[0-9]+$ ]] || die "${long} must be an integer"''} - ${lib.optionalString (f.type == "enum") '' - case "$v" in - ${lib.concatStringsSep "|" f.choices}) ;; - *) die "invalid value for ${long}: $v (choose: ${lib.concatStringsSep ", " f.choices})" ;; - esac - ''} - ${var}="$v"; ${svar}=1 - shift - ;; - ''; - - shorts = lib.filter (s: s != null) (map (f: if f ? short then f.short else null) flags); - in - assert lib.assertMsg (lib.all (n: builtins.match "^[a-z0-9-]+$" n != null) ( - map (f: f.name) flags - )) "mkFlags (${prefix}): flag names must match [a-z0-9-]+"; - assert lib.assertMsg (lib.all ( - s: builtins.match "^[a-zA-Z0-9-]+$" s != null - ) shorts) "mkFlags (${prefix}): short flags must match [a-zA-Z0-9-]+"; - assert lib.assertMsg ( - lib.length (lib.unique (map (f: toVarName f.name) flags)) == lib.length flags - ) "mkFlags (${prefix}): duplicate variable names generated"; - assert lib.assertMsg ( - lib.length (lib.unique shorts) == lib.length shorts - ) "mkFlags (${prefix}): duplicate short flags"; - assert lib.assertMsg ( - !(lib.elem "help" (map (f: f.name) flags)) - ) "mkFlags (${prefix}): 'help' is a reserved flag name"; - assert lib.assertMsg (!(lib.elem "h" shorts)) "mkFlags (${prefix}): 'h' is a reserved short flag"; - '' - if ! declare -F die >/dev/null 2>&1; then - die() { printf 'error: %s\n' "$*" >&2; exit 1; } - fi - - ${lib.concatStringsSep "\n" (map varDecl flags)} - - _HELP_TEXT=$(cat <<'__ICEDOS_MKFLAGS_EOF__' - ${helpText} - __ICEDOS_MKFLAGS_EOF__ - ) - - _REST=() - while [[ $# -gt 0 ]]; do - case "$1" in - -h|--help) - echo "$_HELP_TEXT" - exit 0 - ;; - ${lib.concatStringsSep "\n" (map genCaseArm flags)} - --) - shift - _REST+=("$@") - break - ;; - ${ - if passthroughUnknown then - '' - -*) - _REST+=("$1") - shift - ;; - '' - else - '' - -*) - die "unknown flag: $1" - ;; - '' - } - *) - _REST+=("$1") - shift - ;; - esac - done - set -- "''${_REST[@]}" - ''; - - blueString = s: "\${BLUE}${s}\${NC}"; - greenString = s: "\${GREEN}${s}\${NC}"; - purpleString = s: "\${PURPLE}${s}\${NC}"; - redString = s: "\${RED}${s}\${NC}"; - yellowString = s: "\${YELLOW}${s}\${NC}"; - - dimBlueString = s: "\${DIM_BLUE}${s}\${NC}"; - dimGreenString = s: "\${DIM_GREEN}${s}\${NC}"; - dimPurpleString = s: "\${DIM_PURPLE}${s}\${NC}"; - dimRedString = s: "\${DIM_RED}${s}\${NC}"; - dimYellowString = s: "\${DIM_YELLOW}${s}\${NC}"; - - # Config-set paths + the shell walker shared by the `icedos configuration` - # diff / rollback / history commands: all three pair a snapshot folder - # (`.cache//`, written by rebuild.nix's snapshot_config_set) - # against the working tree, over the same file set — config.toml plus every - # *.toml, hidden .*.toml included, under each `icedos.system.extraConfigs` - # dir. Takes the NixOS `config`. - configSet = - config: - let - inherit (config.icedos) configurationLocation; - configRoot = "${configurationLocation}/.."; - workingConfig = "${configRoot}/config.toml"; - configDirsArgs = concatStringsSep " " (map escapeShellArg config.icedos.system.extraConfigs); - in - { - inherit configRoot workingConfig configDirsArgs; - cacheDir = "${configurationLocation}/.cache"; - - # Defines CONFIG_DIRS + `walk_config_set `, which - # calls `