Skip to content

Latest commit

 

History

History
232 lines (170 loc) · 9.78 KB

File metadata and controls

232 lines (170 loc) · 9.78 KB

dv

A Nix-managed Node.js development environment. Replaces npm/yarn/pnpm as your project's dependency layer with a deterministic, Nix-integrated workflow.

dv manages your npm dependencies, CLI tools, Node.js version, and shell environment through a single dv.kdl config file and a Nix flake. Dependencies are resolved once, stored in a content-addressed global store, and hardlinked into your project. node_modules is a reproducible artifact — not mutable state.

Quick start

Dependencies:

# Install (for now, until it's publish on npm)
git clone git@github.com:zachbutton/dv.git
cd dv
npm install
npm run build
npm link

# Initialize in an existing project (migrates package.json, lockfiles)
dv init

# Enter the dev shell
dv

# Add dependencies
use react ^19.0.0
use typescript ^5.0.0 --cli --node   # also expose as a CLI tool from node_modules
use prettier --cli                     # CLI tool from nixpkgs

# Run project tasks
build   # tasks defined in dv.kdl are available as shell commands
dev

How it works

The config: dv.kdl

All project configuration lives in a single KDL file:

package {
    name "my-app"
    version "1.0.0"
    license "MIT"
}

env {
    node "24"
}

workspaces "packages/*"

deps {
    react "^19.0.0"
    "@sveltejs/kit" "^2.16.0"
    typescript "^5.0.0"
    vite "^6.2.6"
}

cli {
    // From nixpkgs — pure Nix package, no node_modules involvement
    eslint

    // From a different nixpkgs package name
    prettier { from "prettier-plugin-tailwindcss" }

    // From node_modules — resolved through dv's npm pipeline
    vite node=#true
    tsc node="typescript"
}

hooks {
    post_cmd "refresh --lazy"
}

tasks {
    build "vite build"
    dev "vite dev"
    check "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
    test "node --test test/**/*.test.mjs"
}

deps — npm packages to install. Semver ranges, resolved and locked like any package manager.

cli — tools available on your $PATH inside the dev shell. Three modes:

  • tool true — pull the binary from nixpkgs (e.g. eslint true gets the Nix-packaged eslint)
  • tool { from "pkg" } — pull from a differently-named nixpkgs package
  • tool { node "pkg" } or tool node=#true — resolve the binary from a package in node_modules (the package must also be in deps)

tasks — shell commands exposed as top-level commands in your dev shell. build "vite build" means you just type build at the prompt.

hooks — shell hooks. post_cmd runs after every interactive command; pre_cmd runs before. The default post_cmd "refresh --lazy" is discussed below.

The Nix integration

dv generates a Nix flake structure:

flake.nix        # User-editable — just imports entry.nix
flake.lock       # User-editable — pin nixpkgs, add inputs
.dv/.internal/
    entry.nix    # Generated — multi-system devShell, CLI wrappers, tasks
    config.json  # Generated — dv.kdl parsed for Nix consumption

flake.nix is deliberately minimal — it imports entry.nix and passes through your flake inputs. You own flake.nix and flake.lock; you can add extra Nix inputs, override nixpkgs, or extend the devShell. dv regenerates entry.nix on refresh but never touches your flake files.

entry.nix sets up:

  • A devShell with your chosen Node.js version, GNU tar, and all CLI tools
  • Nix derivations for CLI wrappers (nixpkgs tools are symlinked; node_modules tools get a bash wrapper that resolves the bin entry at runtime)
  • Task wrappers as shell scripts on $PATH
  • Shell hooks (pre_cmd/post_cmd) wired into PROMPT_COMMAND and a DEBUG trap
  • Convenience functions: refresh, use, status

The refresh cycle

refresh is dv's core convergence operation. It takes the desired state (your config + lockfile) and makes reality match:

  1. Resolve — compute dependency intent from dv.kdl (and workspace package.json files), reconcile against dv.lock, fetch any missing npm metadata and tarball hashes
  2. Store — download and extract packages into the global store (~/.local/share/dv/store/), shared across all projects via hardlinks
  3. Link — hardlink store packages into .dv/.internal/modules/ (hoisted layout), then symlink root dependencies into node_modules/
  4. Provision — regenerate entry.nix and rebuild CLI wrappers

Refresh has several modes:

  • refresh --lazy — only run if dv.kdl, dv.lock, flake.nix, or flake.lock have changed since the last successful refresh (checked via SHA-256 hashes in .dv/.internal/state.json)
  • refresh --force — always run the full pipeline
  • refresh --lock-only — resolve and write the lockfile without installing
  • refresh --install-only — install from the existing lockfile without re-resolving
  • refresh --nix-only — only regenerate Nix artifacts
  • refresh --check — check for drift without writing anything

The default hook: automatic refresh

The default dv.kdl includes:

hooks {
    post_cmd "refresh --lazy"
}

This means after every command you run in the dev shell, dv checks whether dv.kdl or dv.lock have changed. If they have (e.g. you or a teammate edited the config, or you pulled new changes), node_modules automatically converges to the correct state before your next command runs. If nothing changed, the check is a fast no-op (hash comparison, no I/O).

The experience: you never manually run an install command. Edit dv.kdl, and your next shell prompt picks it up. Pull from git with lockfile changes, and the refresh happens transparently. node_modules behaves like a build artifact that stays in sync with your declarative config.

node_modules as a snapshotted artifact

In Nix fashion, node_modules is not mutable state you interact with directly. It's a derived artifact produced from dv.kdl and dv.lock:

  • Packages are stored in a global content-addressed store (~/.local/share/dv/store/)
  • They're hardlinked into .dv/.internal/modules/ in a hoisted layout
  • Top-level node_modules/ entries are symlinks into the internal modules directory
  • The entire structure is rebuilt deterministically from the lockfile
  • A FileTracker hashes dv.kdl, dv.lock, flake.nix, and flake.lock to detect when a refresh is needed

You don't npm install. You declare what you want; dv produces the artifact.

Peer dependencies

Peer dependencies are handled manually. dv shows peer warnings during refresh and provides an interactive resolution wizard:

use --peers react    # interactive wizard for resolving peer deps of react
status --peers       # show current peer warnings

This is a deliberate design choice — automatic peer resolution leads to surprising transitive installations. dv surfaces the information and lets you decide.

Workspaces

dv supports multi-package workspaces via a glob pattern:

workspaces "packages/*"

Workspace packages are discovered by scanning for package.json files matching the glob. They get symlinked into node_modules/ by their package name, pointing at the workspace directory. Dependencies from workspace package.json files are merged into the resolution graph alongside dv.kdl deps.

Architecture

dv uses an actor-based event-driven architecture built on a custom EventService — a broadcast action bus with concurrent dispatch:

  • 30+ actors handle discrete responsibilities (CLI parsing, config loading, npm resolution, store hydration, Nix provisioning, output rendering, etc.)
  • Single-owner invariant: each action kind is accepted by exactly one actor, enforced at runtime
  • Provider model: actors declare data they can provide and dependencies they need, enabling pull-based coordination without centralized orchestration
  • Concurrent execution: configurable parallelism (default 64 total tasks, 24 per-actor) for operations like store hydration and module linking

This architecture enables the refresh pipeline to naturally parallelize — packages download concurrently, link concurrently, and the Nix provision step runs in parallel with npm resolution.

Project layout

dv.kdl                     # Project config
dv.lock                    # Lockfile (JSON, version 1)
flake.nix                  # Your Nix flake (imports entry.nix)
flake.lock                 # Your flake inputs lock
node_modules/              # Symlinks to .dv/.internal/modules/
.dv/
  .internal/
    entry.nix              # Generated multi-system flake outputs
    config.json            # dv.kdl parsed for Nix
    state.json             # File hashes for lazy refresh
    modules/               # Hardlinked packages (hoisted layout)
    bin/                   # CLI wrapper scripts
    cache/                 # npm registry metadata cache

Commands

Command Description
dv init Initialize dv in a project, optionally migrating from package.json / existing lockfiles
dv open Enter the Nix dev shell
dv - <cmd> Run a command in the dev shell without entering it
use <pkg> [range] Add a dependency (inside dev shell)
use <pkg> --cli Add a CLI tool from nixpkgs
use <pkg> --cli --node Add a CLI tool resolved from node_modules
use --peers <pkg> Interactive peer dependency wizard
use --visual <pkg> Preview dependency impact without writing
refresh Converge node_modules and Nix artifacts
status Show project dependency info
dv config set <path> <value> Set user config (e.g. concurrency knobs)

Roadmap

  • Monorepo support — first-class multi-project monorepo workflows
  • npm module store garbage collection — reclaim space from unused packages in the global store
  • postinstall support — via isolated Nix derivations with controlled native dependencies (no supply chain attacks from arbitrary postinstall scripts)