We need a fast, reliable, pure way to build Bun-based native CLIs from TypeScript that:
- works with dirty/uncommitted changes,
- avoids copying large trees like
node_modules, - works in flakes and devenv,
- keeps dirty builds close to clean build performance.
- macOS (aarch64-darwin)
- Determinate Nix
- repo:
effect-utils
- No
--impurefor the main path. - Must support untracked changes during local iteration.
- Prefer pure evaluation and avoid large source copies.
Nix-native source filtering (filesets/cleanSourceWith/sourceByRegex/nix-gitignore) still copies into the store and is slow on macOS (80–215s in tests). Pure eval cannot see arbitrary local paths. The rsync-staged “mini workspace” approach is the only method that is pure, includes dirty/untracked files, avoids large copies, and is fast (~0.16s).
All timings are real time.
- Purpose: create a minimal “workspace” tree for a path-based flake without
node_modules. - Command (via helper):
prepare_cli_flake "$WORKSPACE_ROOT" - Result: success.
- Timing: ~0.16s (sync to
cli-flake/workspace).
- Goal: build a minimal source tree via Nix derivation (filtered +
cp -R).
nix flake prefetch --no-write-lock-file github:NixOS/nixpkgs/release-25.11
- Result (ulimit 8192): success
- Timing: 74.04s
- Default ulimit 256: failed (
Too many open files)
nix build --no-link --no-write-lock-file "path:/private/tmp/cli-workspace-experiment.tHhLQ5#cliWorkspace"
- Result: success
- Timing: 174.33s
These confirm why pure eval cannot build from dirty local paths without staging:
builtins.getFlakeon localpath:orgit+file:: fails (“unlocked flake reference”).lib.fileset.toSourceon local paths: fails (“path does not exist”).
All runs use --impure. These succeed, but are slow.
nix eval --raw --impure --expr 'let flake = builtins.getFlake "path:/Users/schickling/Code/overengineeringstudio/workspace2/effect-utils"; pkgs = import flake.inputs.nixpkgs { system = "aarch64-darwin"; }; fs = pkgs.lib.fileset; root = /Users/schickling/Code/overengineeringstudio/workspace2/effect-utils; files = fs.unions [ /Users/schickling/Code/overengineeringstudio/workspace2/effect-utils/nix /Users/schickling/Code/overengineeringstudio/workspace2/effect-utils/scripts /Users/schickling/Code/overengineeringstudio/workspace2/effect-utils/patches /Users/schickling/Code/overengineeringstudio/workspace2/effect-utils/packages /Users/schickling/Code/overengineeringstudio/workspace2/effect-utils/dotdot.json /Users/schickling/Code/overengineeringstudio/workspace2/effect-utils/dotdot.json.genie.ts /Users/schickling/Code/overengineeringstudio/workspace2/effect-utils/tsconfig.all.json /Users/schickling/Code/overengineeringstudio/workspace2/effect-utils/tsconfig.all.json.genie.ts ]; in fs.toSource { root = root; fileset = files; }'
- Result:
/nix/store/hskjqg45d7nl2qk87374nxb5bii704rz-source - Timing: 206.27s
nix eval --raw --impure --expr 'let flake = builtins.getFlake "path:/Users/schickling/Code/overengineeringstudio/workspace2/effect-utils"; pkgs = import flake.inputs.nixpkgs { system = "aarch64-darwin"; }; lib = pkgs.lib; root = /Users/schickling/Code/overengineeringstudio/workspace2/effect-utils; prefixes = [ "packages/@overeng/dotdot" ]; src = lib.sources.cleanSourceWith { src = root; filter = path: type: let rel = lib.removePrefix (toString root + "/") (toString path); isUnder = prefix: rel == prefix || lib.hasPrefix (prefix + "/") rel; include = lib.any isUnder prefixes; in if type == "directory" then rel == "" || include else include; }; in src'
- Result:
/nix/store/0ccnxa25whszw7mgbgyzdm4nqc0zwnm8-source - Timing: 80.67s
nix eval --raw --impure --expr 'let flake = builtins.getFlake "path:/Users/schickling/Code/overengineeringstudio/workspace2/effect-utils"; pkgs = import flake.inputs.nixpkgs { system = "aarch64-darwin"; }; lib = pkgs.lib; root = /Users/schickling/Code/overengineeringstudio/workspace2/effect-utils; prefixes = [ "packages/@overeng/dotdot" "packages/@overeng/utils" ]; src = lib.sources.cleanSourceWith { src = root; filter = path: type: let rel = lib.removePrefix (toString root + "/") (toString path); isUnder = prefix: rel == prefix || lib.hasPrefix (prefix + "/") rel; include = lib.any isUnder prefixes; in if type == "directory" then rel == "" || include else include; }; in src'
- Result:
/nix/store/0ccnxa25whszw7mgbgyzdm4nqc0zwnm8-source - Timing: 111.94s
nix eval --raw --impure --expr 'let flake = builtins.getFlake "path:/Users/schickling/Code/overengineeringstudio/workspace2/effect-utils"; pkgs = import flake.inputs.nixpkgs { system = "aarch64-darwin"; }; lib = pkgs.lib; root = /Users/schickling/Code/overengineeringstudio/workspace2/effect-utils; in lib.sources.sourceByRegex root [ "^packages/@overeng/dotdot(/.*)?$" "^packages/@overeng/utils(/.*)?$" ]'
- Result:
/nix/store/0ccnxa25whszw7mgbgyzdm4nqc0zwnm8-source - Timing: 84.92s
nix eval --raw --impure --expr 'let flake = builtins.getFlake "path:/Users/schickling/Code/overengineeringstudio/workspace2/effect-utils"; pkgs = import flake.inputs.nixpkgs { system = "aarch64-darwin"; }; lib = pkgs.lib; root = /Users/schickling/Code/overengineeringstudio/workspace2/effect-utils; prefixes = [ "packages/@overeng/dotdot" "packages/@overeng/utils" ]; isUnder = prefix: rel: rel == prefix || lib.hasPrefix (prefix + "/") rel; includePath = rel: lib.any (prefix: isUnder prefix rel) prefixes; filter = path: type: let rel = lib.removePrefix (toString root + "/") (toString path); in if type == "directory" then rel == "" || includePath rel else includePath rel; in builtins.path { path = root; filter = filter; }'
- Result:
/nix/store/2hx25b866251sq7v044vdd5k29w7bky9-effect-utils - Timing: 107.03s
nix eval --raw --impure --expr 'let flake = builtins.getFlake "path:/Users/schickling/Code/overengineeringstudio/workspace2/effect-utils"; pkgs = import flake.inputs.nixpkgs { system = "aarch64-darwin"; }; root = /Users/schickling/Code/overengineeringstudio/workspace2/effect-utils; ignore = "*\\n!/packages/\\n!/packages/@overeng/\\n!/packages/@overeng/dotdot/\\n!/packages/@overeng/dotdot/**\\n!/packages/@overeng/utils/\\n!/packages/@overeng/utils/**\\n"; src = pkgs.nix-gitignore.gitignoreSourcePure ignore root; in src'
- Result:
/nix/store/xpfv1srai0kry6jbi1vhlvgc07093pgk-effect-utils - Timing: 215.20s
nix build --no-link --no-write-lock-file --impure --expr 'let flake = builtins.getFlake "path:/Users/schickling/Code/overengineeringstudio/workspace2/effect-utils"; pkgs = import flake.inputs.nixpkgs { system = "aarch64-darwin"; }; lib = pkgs.lib; root = /Users/schickling/Code/overengineeringstudio/workspace2/effect-utils; mkSrc = prefixes: lib.sources.cleanSourceWith { src = root; filter = path: type: let rel = lib.removePrefix (toString root + "/") (toString path); isUnder = prefix: rel == prefix || lib.hasPrefix (prefix + "/") rel; include = lib.any isUnder prefixes; in if type == "directory" then rel == "" || include else include; }; dotdot = mkSrc [ "packages/@overeng/dotdot" ]; utils = mkSrc [ "packages/@overeng/utils" ]; in pkgs.linkFarm "cli-src" [ { name = "packages/@overeng/dotdot"; path = dotdot; } { name = "packages/@overeng/utils"; path = utils; } ]'
- Result: built
/nix/store/synnzr516nq2sjls7mhky04rm7bdlf7a-cli-src.drv - Timing: 89.30s
| Approach | Mode | Scope | Result | Real time |
|---|---|---|---|---|
| rsync staging | pure | minimal workspace | success | ~0.16s |
| cli-workspace derivation | pure | minimal workspace | success | 174.33s |
| fileset.toSource | impure | full allowed tree | success | 206.27s |
| cleanSourceWith | impure | dotdot | success | 80.67s |
| cleanSourceWith | impure | dotdot + utils | success | 111.94s |
| sourceByRegex | impure | dotdot + utils | success | 84.92s |
| builtins.path filter | impure | dotdot + utils | success | 107.03s |
| nix-gitignore allowlist | impure | dotdot + utils | success | 215.20s |
| linkFarm (filtered sources) | impure | dotdot + utils | success | 89.30s |
- Pure evaluation cannot access arbitrary local paths; staging is required for dirty changes.
- Impure access enables direct local source selection but is far too slow for iterative development on macOS.
linkFarmhelps slightly but doesn’t avoid the expensive source import step.
Given the constraints, the rsync-staged minimal workspace remains the only viable solution that is pure, includes dirty/untracked files, avoids large copies, and is fast.