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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
91 changes: 72 additions & 19 deletions AGENTS.md

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 "<sub>/<input>"`). 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
Expand Down
177 changes: 134 additions & 43 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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; }')
Expand All @@ -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')
Expand All @@ -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
Expand All @@ -170,75 +159,177 @@ 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 `-<name>-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 <sub>` 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[]
| select(.value | type == "string")
| .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

Expand Down
10 changes: 4 additions & 6 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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};
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading