sini/two-phase-flake
{ "createdAt": "2026-05-14T00:52:19Z", "defaultBranch": "main", "description": "Self-bootstrapping Nix flake: thin eval collects inputs from a module tree, bootstrap materializes them into flake.nix", "fullName": "sini/two-phase-flake", "homepage": null, "language": "Nix", "name": "two-phase-flake", "pushedAt": "2026-05-14T10:25:05Z", "stargazersCount": 4, "topics": [], "updatedAt": "2026-05-22T09:10:57Z", "url": "https://github.com/sini/two-phase-flake"}two-phase-flake
Section titled “two-phase-flake”Module-driven flake input resolution with a real, overridable flake interface.
Why not npins / nixlock / with-inputs?
Section titled “Why not npins / nixlock / with-inputs?”These tools bypass the flake input system — they fetch sources at eval time from a side-channel lock file. You lose nix flake lock, follows, --override-input, nix flake metadata, and downstream input deduplication.
Why not just flake-file’s write-flake?
Section titled “Why not just flake-file’s write-flake?”flake-file generates a real flake.nix from module declarations. But evaluation is blocked until you run the generation step — you can’t nix eval until the inputs exist in flake.nix. The generation step is a prerequisite, not an optimization.
What this does differently
Section titled “What this does differently”Two-phase-flake combines both approaches with a hybrid resolver:
- Always evaluable — a pure userspace flake evaluator (
eval-flake.nix) resolves inputs frominputs.lockviabuiltins.fetchTree, walking the dependency graph the same way Nix’s flake evaluator does. No bootstrap prerequisite. - Real flake interface —
nix run .#bootstrapmaterializes aflake.nixwith all inputs declared as real flake inputs. After this,follows,--override-input, andnix flake metadataall work. - Override wins — the resolver prefers real flake inputs over the lock. Downstream overrides take effect automatically.
resolve.nix ┌──────────────────────────────────┐ │ for each declared input: │flakeInputs ──────▶ present? → use it (real) │──▶ allInputs │ absent? → evalFlake (from lock) │inputs.lock ──────▶ │ └──────────────────────────────────┘eval-flake.nix — pure userspace flake evaluator
Section titled “eval-flake.nix — pure userspace flake evaluator”The eval-flake.nix library replicates what builtins.getFlake does, purely:
builtins.fetchTreewith locked narHash (deterministic, no--impure)- Read
flake.lockto get the dependency graph - Recursively resolve inputs by walking the lock graph
import flake.nixand call.outputswith resolved inputs
This means fetched flakes get their full dependency tree resolved — not just a flat fetchTree, but proper recursive evaluation with follows and sub-inputs.
# Evaluate any flake purely, given its source tree(import ./eval-flake.nix).evalFlake (builtins.fetchTree { type = "github"; owner = "vic"; repo = "flake-file"; rev = "04ca28cf..."; narHash = "sha256-...";})# => { flakeModules = ...; lib = ...; templates = ...; }Lifecycle
Section titled “Lifecycle”# 1. Start from seed — everything works immediately via evalFlakecp seed-flake.nix flake.nixnix eval .#packages.x86_64-linux.hello.name # works
# 2. Bootstrap — promote to real flake inputs for the external interfacenix run .#bootstrapnix flake lock
# 3. Now downstream consumers can follows/override your inputs# inputs.two-phase.inputs.home-manager.follows = "home-manager";Adding an input
Section titled “Adding an input”- Declare it in any module:
{ lib, ... }:{ flake-file.inputs.disko = { url = "github:nix-community/disko"; inputs.nixpkgs.follows = "nixpkgs"; };}- Lock it for evalFlake fallback, then optionally bootstrap for real flake inputs:
nix run .#update-lock # writes inputs.locknix run .#bootstrap # materializes flake.nix (optional — eval works without this)nix flake lock # pins real inputsProject structure
Section titled “Project structure”├── flake.nix # Materialized (generated) or seed├── seed-flake.nix # Minimal seed — copy to flake.nix to start fresh├── outputs-expr.nix # Shared outputs logic (used by both seed and generated)├── eval-flake.nix # Pure userspace flake evaluator library├── resolve.nix # Input resolver: real flake input or evalFlake fallback├── inputs.lock # Locked narHash/rev for evalFlake resolution└── modules/ ├── inputs.nix # Input declarations via flake-file.inputs └── outputs.nix # Flake outputsHow the resolver works
Section titled “How the resolver works”resolve.nix takes the set of real flake inputs and the lock file, then for each declared input:
- Real flake input available? Use it.
--override-inputandfollowsfrom downstream take effect. - Lock entry available? Fetch the source tree with
builtins.fetchTree, then:- If the fetched flake has its own
flake.lock: useevalFlakefor full recursive resolution, with follows patched to point at seed inputs. - Otherwise: simple eval — call
.outputswith follows resolved directly.
- If the fetched flake has its own
- Neither? Input is unavailable (outputs referencing it fail lazily).
Design properties
Section titled “Design properties”- Always evaluable — seed flake works via evalFlake, no bootstrap needed
- Real flake interface — after bootstrap, inputs are real with full ecosystem support
- Override wins — resolver prefers flake inputs, so
--override-inputandfollowswork - Pure recursive resolution — evalFlake walks the full dependency graph, no
--impure - Module tree is source of truth — flake-file.inputs declarations drive everything
- Progressive — start with seed (evalFlake), bootstrap when you need the external interface
Requirements
Section titled “Requirements”- Nix with flakes enabled
- Seed inputs:
nixpkgs,import-tree,flake-file
License
Section titled “License”MIT