TypeScript-first system configuration for NixOS, nix-darwin, and Home Manager.
Winix lets you write system configuration as typed, composable TypeScript fragments and generates a normal Nix flake that nixos-rebuild, darwin-rebuild, and Home Manager can consume.
// winix.config.ts
import {
account,
defineInputs,
feature,
home,
host,
input,
platforms,
workspace,
} from "@adrifer/winix";
const inputs = defineInputs({
nixpkgs: "nixos-unstable",
homeManager: input("github:nix-community/home-manager", {
follows: { nixpkgs: "nixpkgs" },
}),
nixosWsl: input("github:nix-community/NixOS-WSL", {
follows: { nixpkgs: "nixpkgs" },
}),
});
const wsl = feature("wsl", ({ nixos }) => {
nixos.imports("inputs.nixos-wsl.nixosModules.wsl");
nixos({ wsl: { enable: true } });
nixos.program("nix-ld");
});
const shell = feature("shell", ({ home, platforms }) =>
home.program("zsh", {
shellAliases: {
g: "lazygit",
...(platforms.nixos.isActive && {
i: "cd ~/dotfiles/winix && npx @adrifer/winix switch",
}),
...(platforms.darwin.isActive && {
i: "cd ~/dotfiles/winix && npx @adrifer/winix switch --host macbook",
}),
},
})
);
export default workspace({
inputs,
hosts: [
host("wsl-work", platforms.nixos({ stateVersion: "25.05" }), [
account.user("adrifer", () => ({
admin: true,
shell: "zsh",
stateVersion: "25.05",
wslDefault: true,
}))(),
wsl(),
shell(),
home.packages("neovim", "ripgrep", "fd"),
]),
],
});Feature and host callbacks receive an injected context
({ home, nixos, darwin, windows, platforms }); declarations made through it
register automatically, so no return is needed. The older style still works:
import the helpers as globals and return the fragments you build.
- Use TypeScript as the authoring language. Compose functions, arrays, objects, conditionals, and package-specific helpers without inventing another DSL.
- Keep Nix as the output. Winix writes
.winix/out/flake.nixand host modules; the final build still runs through Nix. - One model for NixOS, nix-darwin, and Home Manager. Share features across Linux, macOS, WSL, servers, and profiles.
- Typed helpers for common config. Use
nixos.networking(),home.program(),darwin.homebrew(),account.user(), and more. - Escape hatches when needed. Drop to
nix.expr(),nixos.raw(), orrawModule()without leaving the system.
Winix is early software. The core pipeline works end-to-end, but the public API is still evolving.
It started as a half-joke about wrapping Nix for Windows (early prototypes targeted WSL first). The name stuck because Win + Nix also reads as "winning at Nix", which felt apt for a project that's trying to make the Nix ecosystem a bit less painful to onboard into.
For what it's worth: Winix today works just as well on macOS and native NixOS as it does on WSL. The Windows-flavored origin survives in the name only.
mkdir my-winix-config
cd my-winix-config
npx @adrifer/winix@latest init
npm installWinix expects Node.js with native TypeScript stripping support for winix.config.ts files. Node 22+ is recommended.
If you prefer to set up files yourself instead of using init, add the package to your own package.json:
npm install @adrifer/winixThe init command creates a starter winix.config.ts, tsconfig.json, .gitignore, and package scripts. After npm install, the winix binary comes from your local node_modules, so commands are reproducible through package-lock.json.
You can also install it globally, but local project installs are recommended:
npm install -g @adrifer/winixFrom a Winix project:
npx @adrifer/winix check # validate winix.config.ts
npx @adrifer/winix apply # generate .winix/out/
npx @adrifer/winix apply --dry # print generated Nix without writing files
npx @adrifer/winix apply --diff # show changes against current .winix/out/
npx @adrifer/winix switch --host my-host # generate and run nixos-rebuild/darwin-rebuild
npx @adrifer/winix update # update generated flake.lock and copy it back
npx @adrifer/winix inspect # inspect host composition and fragmentsIf you add scripts to your package.json, you can use shorter commands:
{
"scripts": {
"check": "winix check",
"apply": "winix apply",
"dry": "winix apply --dry",
"switch": "winix switch"
}
}Generated output lives under:
.winix/out/
flake.nix
hosts/
<host>.nix
You can also run the generated flake manually:
sudo nixos-rebuild switch --flake path:$(pwd)/.winix/out#my-host
sudo darwin-rebuild switch --flake path:$(pwd)/.winix/out#macbookA workspace declares inputs and hosts. Each host has exactly one platform and a list of fragments.
export default workspace({
inputs: {
nixpkgs: "nixos-unstable",
homeManager: input("github:nix-community/home-manager", {
follows: { nixpkgs: "nixpkgs" },
}),
},
hosts: [
host("server", platforms.nixos({ stateVersion: "25.05" }), [
serverProfile(),
]),
host("macbook", platforms.darwin({ stateVersion: 6, homebrew: true }), [
macProfile(),
]),
],
});Features declare config and are the flexible building block. Profiles group features and take an array of entries only (no callback).
const git = feature("git", ({ home }) => {
home.program("git", {
userName: "Adrian Fernandez",
userEmail: "me@example.com",
});
});
const developer = profile("developer", [
git(),
home.packages("neovim", "lazygit", "ripgrep"),
]);A feature can also return one fragment or an array of fragments (the older style still works):
const neovim = feature("neovim", () => [
home.packages("neovim"),
home.env({ EDITOR: "nvim" }),
]);When a feature body composes other features, return them: a feature()/
profile() call yields a lazy fragment that is not auto-collected.
Fragments can ask whether another platform or feature is active.
const shell = feature("shell", ({ home, platforms }) =>
home.program("zsh", {
shellAliases: {
...(platforms.nixos.isActive && { rebuild: "sudo nixos-rebuild switch" }),
...(platforms.darwin.isActive && { rebuild: "sudo darwin-rebuild switch" }),
},
})
);account.user() wires together platform users and Home Manager users.
const adrifer = account.user("adrifer", () => ({
admin: true,
shell: "zsh",
stateVersion: "25.05",
wslDefault: true,
}));
host("wsl", platforms.nixos({ stateVersion: "25.05" }), [
adrifer(),
]);Winix includes helpers for common Nix namespaces:
nixos.imports("inputs.nixos-wsl.nixosModules.wsl")
nixos.networking({ hostName: "server", firewall: { allowedTCPPorts: [22, 443] } })
nixos.service("openssh", { settings: { PermitRootLogin: "no" } })
nixos.systemd.service("backup", { script: "echo backup" })
nixos.users({ users: { root: { shell: nix.pkg("bash") } } })
nixos.system({ stateVersion: "25.05" })
home.program("zsh", { enableCompletion: true })
home.configFiles({ nvim: home.symlink("~/dotfiles/nvim/.config/nvim") })
home.activation("ensureNpmrc", { script: "mkdir -p \"$HOME/.config/npm\"" })
darwin.defaults({ dock: { autohide: true } })
darwin.homebrew({ enable: true, casks: ["visual-studio-code"] })
darwin.launchd.agent("emacs", {
serviceConfig: { ProgramArguments: ["emacs", "--fg-daemon"] },
})Plain object fragments are still supported for options that do not have helpers yet.
Use nix.* when a value needs to be a Nix expression:
nix.pkg("zsh")
nix.bin("git", "git")
nix.str`${nix.pkg("neovim")}/bin/nvim`
nix.script`
echo "hello from activation"
`
nix.lib.mkForce(["https://cache.nixos.org/"])For prebuilt single-binary CLI releases (the azd, gh, kubectl,
1password family), use nix.binaryRelease() instead of hand-rolling
the stdenvNoCC.mkDerivation boilerplate:
home.packages(
nix.binaryRelease({
name: "azure-dev-cli",
version: "1.25.5",
binary: "azd",
urlTemplate:
"https://github.com/Azure/azure-dev/releases/download/azure-dev-cli_{version}/{file}",
platforms: {
"x86_64-linux": { file: "azd-linux-amd64.tar.gz", hash: "sha256-..." },
"aarch64-linux": { file: "azd-linux-arm64.tar.gz", hash: "sha256-..." },
"x86_64-darwin": { file: "azd-darwin-amd64.zip", hash: "sha256-..." },
"aarch64-darwin":{ file: "azd-darwin-arm64.zip", hash: "sha256-..." },
},
meta: {
description: "Azure Developer CLI",
homepage: "https://github.com/Azure/azure-dev",
license: "mit",
},
}),
);Supports per-platform {platform} URL placeholders (for vendors whose
URLs aren't just ${file}), shell completions via installShellFiles,
optional autoPatchelfHook on Linux, and a validated meta.license
(rejects SPDX-style ids like MIT/Apache-2.0 at TS-eval time so misuse
surfaces immediately, not at Nix build time). Pass nix.expr(...) for
licenses that aren't a simple pkgs.lib.licenses.<id> lookup. See
spec/proposals/binary-release.md
for the full reference.
For bigger migrations, import existing Nix modules:
rawModule("./legacy/system.nix")
rawModule.homeManager("./legacy/home.nix")
rawModule.darwin("./legacy/darwin.nix")Winix has an early native Windows backend. Declare a platforms.windows() host
and install winget/msstore packages with windows.package(...); use
windows.raw(...) for arbitrary commands. Both return a handle you can pass to
another resource's dependsOn to express ordering.
host("desktop", platforms.windows(), ({ windows }) => {
const node = windows.package("OpenJS.NodeJS");
windows.package({ source: "msstore", id: "9NKSQGP7F2NH" });
windows.raw({
executable: "npm",
arguments: ["install", "--global", "typescript"],
dependsOn: node,
});
});This generates a DSC v3 document that winget configure applies. The backend is
an MVP: only windows.package and windows.raw exist today. See
examples/windows/ and
spec/proposals/windows-backend.md.
Winix evaluates fragments in two passes:
- Collect active fragment IDs so
.isActiveworks. - Resolve lazy fragments and deep-merge the results.
- Generate a flake and one host module per host.
Objects merge recursively, arrays append, and scalar values use last-wins semantics.
Winix ships bundled option augmentations and can generate/update local option types:
winix types generate
winix types generate nixos
winix types generate home-manager
winix types generate darwinWinix is currently best suited for personal configurations and experimentation. The core workflow is functional, but expect API refinements before a stable 1.0.
Only clone this repository if you want to work on Winix itself. For normal configuration usage, install @adrifer/winix from npm as shown above.
git clone https://github.com/adrifer/winix.git
cd winix
npm install
npm run check
npm test -- --run
npm run buildMIT