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.

6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
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
224 changes: 209 additions & 15 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ if [ "$export_search_index" == "1" ]; then
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
# configs/*.toml — see lib/config/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.
Expand Down Expand Up @@ -170,52 +170,243 @@ 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"
# Genflake bakes the locked rev into patched `_source` urls so the realised
# patched tree matches the lock — but only when nothing is being updated. The
# repo bake (`_resolveFlakeRevision`, gated on ICEDOS_UPDATE) and the sub-flake
# bake (`_resolveFlakeRevisionNested`, gated on ICEDOS_UPDATE_MODULE_INPUTS) are
# cleared by SEPARATE flags: while --update-repos (resp. --update-repos-inputs)
# runs, the baked rev would pin the very input that flag is about to bump
# (`nix flake update "<sub>/<i>_source"` on a ?rev=/rev-pinned url is a no-op).
# --update-all sets both.
update_flag="$update_repos"
update_module_inputs_flag="$update_repos_inputs"

# Generate flake — one genflake eval producing the state flake and the
# sub-flake export together (evaluating the file per-attr would redo the whole
# config load + module resolution, and re-run patched-input store builds).
flake_final=$(ICEDOS_UPDATE="$update_flag" ICEDOS_UPDATE_MODULE_INPUTS="$update_module_inputs_flag" \
ICEDOS_STAGE="genflake" nix eval --json $refresh $trace \
--file "$ICEDOS_ROOT/lib/genflake.nix" \
--apply 'g: g.flakeFinal')
jq -r . <<< "$flake_final" >"$ICEDOS_STATE_DIR/$FLAKE"
nixfmt "$ICEDOS_STATE_DIR/$FLAKE"
# The sub-flake flake.nix texts live ONLY as content-addressed store paths
# (`/nix/store/<hash>-<name>/flake.nix`) embedded in the generated flake's root
# inputs — no materialized `subflakes/` dir, no `subflakes.json` export. A
# changed sub-flake (decl added/edited, patch edited) changes its store path,
# so the plain `nix flake lock` below re-locks that one root node (preserving
# every unchanged nested pin). `flake.lock` is the single source of truth for
# which inputs belong to which sub-flake: the loops below derive sub-flake
# roots and their declared inputs from it.

# Run every lock step against a DETACHED copy of the state flake, not the
# tracked tree: nix treats `.state` as a git flake, and a git flake refuses
# to lock/refresh an untracked `path:` input ("not tracked by Git ... git add
# ..."). In a plain directory outside any git work tree path inputs lock with
# no tracking requirement; the resulting `flake.lock` (which inlines every
# sub-flake's nested inputs) is copied back into `.state` once the lock steps
# below are done. The build itself already runs from a detached dir
# ($ICEDOS_BUILD_DIR), so a non-git state never leaks tracking requirements
# into the final `nh` call either.
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 root NAMES from a state lock: content-addressed `path:` root inputs
# whose store path ends with their own input name (genflake emits them as
# `builtins.path { name = subFlakeName; }` → `/nix/store/<hash>-<name>`),
# excluding the framework's own icedos-config (store name is literally
# "icedos-config", so the suffix test would match it) and icedos-state (store
# name is "icedos", already rejected by the suffix test — kept explicit).
# Node keys are resolved via `nodes.root.inputs` — nix appends `_2`/`_3` to
# colliding node names, so a root input's key is not guaranteed to equal its
# name. A PATCHED repository is also a store-path `path:` root input, but its
# path ends `-patched` (not `-$k`), so the suffix test keeps it out — it is a
# repo, refreshed by the repo loops, and its own declared inputs are NOT
# module-declared inputs. Single source of truth for both the plain-build
# refresh and the --update-repos-inputs bump.
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))
| select($k != "icedos-config" and $k != "icedos-state")
| $k
' "$1" 2>/dev/null
}

(
set -e
cd "$ICEDOS_STATE_DIR"
cd "$lock_dir"

# Create or refresh the lock. Sub-flake roots are content-addressed store
# paths: a changed sub-flake (module author added/edited an input, or edited
# patches) materializes a new `/nix/store/<hash>-<name>` and this plain lock
# recompute re-locks that single root node — pulling in any new nested input
# — while PRESERVING every unchanged nested pin (`nix flake update <sub>`
# would re-resolve the whole subtree to latest; a plain lock only updates
# nodes whose url changed). On a first build there is no lock file and this
# step creates it. When nothing changed it is a fast no-op.
nix flake lock

if [ ! -f flake.lock ] || [ -n "$update_core$update_nixpkgs$update_repos$update_repos_inputs" ]; then
# `nix flake prefetch-inputs` is only a store-warming optimization (the lock
# steps below fetch the same inputs on demand). It requires a lock file, so
# it runs after the plain lock above. Sub-flake roots are absolute store
# paths, so they prefetch fine.
if [ -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
# Refresh every non-store `type: "path"` ROOT 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. Store-path
# root inputs are content-addressed — sub-flake roots, the masked
# icedos-config, and icedos-state (`path:${...}` interpolation copies the
# source at genflake): their path changes with their content, so the plain
# `nix flake lock` above re-locks them exactly when the content changed, and
# refreshing one here would `nix flake update` a sub-flake root and
# re-resolve its whole nested subtree to latest, silently unpinning every
# module-declared input on an ordinary rebuild. The node key for a root
# input name is resolved via `nodes.root.inputs` — nix appends `_2`/`_3` to
# colliding node names, so the input name is not guaranteed to be its key.
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 guarantee for module-declared local `path:` inputs, which live NESTED
# inside sub-flake roots: the root-input loop above cannot see them, and a
# plain `nix flake lock` does not re-copy a local dir whose url string is
# unchanged — the nested node keeps its stale narHash until an explicit
# update. Walk every sub-flake root's string-valued inputs and refresh the
# non-store `path:` ones so local module-input edits land on every build —
# restoring the pre-refactor all-nodes loop's behaviour. github / git nested
# inputs stay pinned; store-path nested inputs (patched trees, `_source`
# bakes) are content-addressed and the plain lock already re-locks them on
# change.
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"
# Repo inputs (icedos-<repo>) and sub-flake roots are both `icedos-`
# prefixed root inputs. Update the repos, then bump each module-declared
# input nested inside its sub-flake (`<sub>/<input>`); a path-typed
# (pinned) entry relocks trivially. Sub-flake roots come from
# `subflakes_from_lock` (same helper the plain-build refresh uses): the
# store-path `path:` root inputs whose store path ENDS with their own
# input name, excluding icedos-config/icedos-state and patched repos —
# everything derived from the lock, the single source of truth now that
# subflakes.json is gone. A sub's declared inputs are its lock node's
# `inputs` entries with a STRING value (a direct node reference); array
# values are `follows` — the sub rewires ambient slots (nixpkgs,
# home-manager, ...) to the parent's own inputs, and bumping one would
# re-resolve the followed chain and silently unpin e.g. 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
# An associative-array membership test, not a `printf | grep` pipeline:
# under `set -o pipefail`, grep exiting on its first match SIGPIPEs printf
# (status 141) and turns the `!` into a false positive that updates anyway.
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

# Convergence pass after a module-input (or --update-all) bump. The genflake
# above ran with the sub-flake bake suppressed (`ICEDOS_UPDATE_MODULE_INPUTS`)
# so `nix flake update "<sub>/<i>_source"` could actually move the pin — which
# means a PATCHED input's sub-flake embeds the realised tree built at genflake
# time (pre-bump rev), while the update loop just bumped the `_source` node in
# the lock. Without a re-genflake the system built right after the update would
# evaluate the pre-update patched tree while the lock records the new rev (one-
# build lag). Re-run genflake WITH the bakes re-enabled now that the lock holds
# the fresh revs: the patched `_source` urls bake the new rev, the patched
# trees are re-realised from it, the sub-flake texts change, their store paths
# flip, and the plain `nix flake lock` below re-locks exactly those roots —
# preserving every other pin. For unpatched inputs the sub-flake texts are
# verbatim decls, so this pass is a no-op apart from one extra eval.
if [ "$update_repos_inputs" == "1" ] || [ "$update_all" == "1" ]; then
# Copy the freshly-updated lock into `.state` FIRST so the re-genflake bakes
# the new revs (genflake reads the lock from ICEDOS_STATE_DIR).
sync_lock
flake_final=$(ICEDOS_UPDATE="" ICEDOS_UPDATE_MODULE_INPUTS="" \
ICEDOS_STAGE="genflake" nix eval --json $trace \
--file "$ICEDOS_ROOT/lib/genflake.nix" \
--apply 'g: g.flakeFinal')
jq -r . <<< "$flake_final" >"$ICEDOS_STATE_DIR/$FLAKE"
nixfmt "$ICEDOS_STATE_DIR/$FLAKE"
# Re-sync the state flake into the detached dir and re-lock the changed
# sub-flake roots (new content-addressed store paths) with the same plain
# `nix flake lock` — unchanged nodes keep their pins.
rsync -a --exclude=".cache" "$ICEDOS_STATE_DIR/" "$lock_dir/"
(
set -e
cd "$lock_dir"
nix flake lock
)
fi

# Persist the locks written in the detached copy (only the state `flake.lock`
# changes — nested sub-flake inputs are inlined there, so no per-sub-flake
# lock files need copying). A failed lock step above exits via the trap
# WITHOUT syncing, leaving `.state` on its previous lock; the next run's plain
# lock step re-attempts any re-lock.
sync_lock

# 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.
Expand All @@ -225,9 +416,12 @@ 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
Expand Down
6 changes: 3 additions & 3 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,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 +116,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 +135,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