on master help sign in
3 files changed+93−83
since splitsnapshots
unifiedsplit
README.md+31−21 c+35−39 flake.nix+27−23
README.md+31−21
@@ -5,9 +5,10 @@ A Nix flake that builds an **OCI image** (via
55Apple's [`container`](https://github.com/apple/container) CLI on macOS.
66
77It's a plain, single-process image — no NixOS, no systemd — defined in
8−[`flake.nix`](./flake.nix). `c init` detects your host login shell, bundles it,
9−and runs it by default; `c start` drops you into a [`zellij`](https://zellij.dev)
10−session. You choose what else it ships via a per-project `container.nix`.
8+[`flake.nix`](./flake.nix). `c start` drops you into a
9+[`herdr`](https://herdr.dev) session whose panes use your host login shell
10+(detected and bundled by `c init`). You choose what else it ships via a
11+per-project `container.nix`.
1112
1213## Prerequisites
1314
@@ -26,22 +27,29 @@ session. You choose what else it ships via a per-project `container.nix`.
2627
2728## Packages
2829
29−`init` builds the image from a **`container.nix`** in the current directory — a
30−`{ pkgs, nur }` function returning the package list (`nur` is
31−[NUR](https://github.com/nix-community/NUR), for packages outside nixpkgs). It
32−is **required**; there is no default set.
30+`init` builds the image from a **`container.nix`** in the current directory — an
31+attrset with a `packages` function (`{ pkgs, nur }` returning the package list;
32+`nur` is [NUR](https://github.com/nix-community/NUR), for packages outside
33+nixpkgs) and an optional `mounts` list. It is **required**; there is no default.
3334
3435```nix
3536# ./container.nix
36−{ pkgs, nur }: with pkgs; [
37− git
38− go
39− nur.repos.charmbracelet.crush
40−]
37+{
38+ packages = { pkgs, nur }: with pkgs; [
39+ git
40+ go
41+ nur.repos.charmbracelet.crush
42+ ];
43+ # Extra "host:container" bind mounts, on top of $PWD → /workspace. A leading
44+ # ~/ in the host path expands to $HOME. Optional.
45+ mounts = [
46+ "~/data:/data"
47+ ];
48+}
4149```
4250
4351Your **host login shell** (detected by `c init`, bash fallback), **coreutils**,
44−[**zellij**](https://zellij.dev) (the session `c start` launches), **Nix** itself
52+[**herdr**](https://herdr.dev) (the session `c start` launches), **Nix** itself
4553(with CA certs and `nix-command`/`flakes` enabled, single-user), and the ncurses
4654**terminfo** database are always included — no need to list them.
4755
@@ -60,7 +68,7 @@ that holds a `container.nix`; the container is named after that directory.
6068# build the image, load it, and create the container (named after $PWD):
6169nix run github:ilyakooo0/nix-container -- init
6270
63−# start it and attach — drops you into a zellij session:
71+# start it and attach — drops you into a herdr session:
6472nix run github:ilyakooo0/nix-container -- start
6573```
6674
@@ -69,7 +77,8 @@ nix run github:ilyakooo0/nix-container -- start
6977 directory at `/workspace` (the container's working directory).
7078- `--ssh` forwards your host SSH agent socket into the container (so `git` over
7179 SSH works with your keys).
72−- Add more mounts by passing them through (mounts can only be set at creation):
80+- Add more mounts persistently via `mounts` in `container.nix` (above), or
81+ ad-hoc by passing them through to `create` (mounts can only be set at creation):
7382
7483 ```sh
7584 nix run github:ilyakooo0/nix-container -- init -v $HOME/data:/data
@@ -90,18 +99,19 @@ nix run github:ilyakooo0/nix-container -- init
9099## Customize further
91100
92101Fork or clone to change what isn't per-project — `config.cmd`/`env` or the image
93−name — in [`flake.nix`](./flake.nix). The flake also exposes build helpers, both
94−taking a `{ pkgs, nur }: [ ... ]` function:
102+name — in [`flake.nix`](./flake.nix). The flake also exposes build helpers:
95103
96−- `lib.<system>.mkImage` → an OCI image.
97−- `lib.<system>.copyWith` → a skopeo copy app, for building/pushing the image
98− anywhere skopeo can write (e.g. a registry):
104+- `lib.<system>.copyWith` → a skopeo copy app for a `{ pkgs, nur }: [ ... ]`
105+ function, for building/pushing the image anywhere skopeo can write (e.g. a
106+ registry):
99107
100108 ```sh
101109 drv=$(nix build --no-link --print-out-paths --impure --expr \
102− '(builtins.getFlake "github:ilyakooo0/nix-container").lib.${builtins.currentSystem}.copyWith (import ./container.nix)')
110+ '(builtins.getFlake "github:ilyakooo0/nix-container").lib.${builtins.currentSystem}.copyWith (import ./container.nix).packages')
103111 $drv/bin/copy-to docker://ghcr.io/me/img:latest
104112 ```
113+- `lib.<system>.mkImage <name> <shell>` → the underlying OCI image (`copyWith`
114+ defaults these to `nix-container` / `bash`).
105115
106116The flake uses `flake-utils` over `{aarch64,x86_64}-{darwin,linux}`; the host
107117system maps to the matching Linux target automatically (an Intel Mac builds an
c+35−39
@@ -1,7 +1,7 @@
11#!/usr/bin/env fish
22# Manage a container named after the current directory.
33# c init [-c FILE] [extra create args] build + load the image, then (re)create it
4−# (packages from FILE, default ./container.nix)
4+# (config from FILE, default ./container.nix)
55# c start start it and attach
66
77set -l name (path basename $PWD)
@@ -10,18 +10,6 @@ set -l flake (path dirname (path resolve (status filename)))
1010# Per-directory image ref this script loads and runs (archive tag + `create`).
1111set -l image "nix-container-$name:latest"
1212
13−# Terminal type to pass into the container via `-e TERM` (both create and exec):
14−# the runtime hands processes a bare "xterm", so we set the host's real terminal
15−# instead. Map it to an entry the bundled ncurses ships — Ghostty reports
16−# "xterm-ghostty", which ncurses ships as "ghostty" — falling back to
17−# xterm-256color for an empty or bare-"xterm" value.
18−set -l term xterm-256color
19−if test "$TERM" = xterm-ghostty
20− set term ghostty
21−else if test -n "$TERM"; and test "$TERM" != xterm
22− set term $TERM
23−end
24−
2513switch $argv[1]
2614 case init
2715 # -c/--config overrides the default ./container.nix; leftover args (e.g.
@@ -34,23 +22,23 @@ switch $argv[1]
3422 end
3523
3624 if not test -e $config
37− echo "c: package file not found: $config" >&2
38− echo " create one ('{ pkgs, nur }: [ ... ]') or pass -c FILE" >&2
25+ echo "c: config file not found: $config" >&2
26+ echo " create one ('{ packages = { pkgs, nur }: [ ... ]; mounts = [ ]; }') or pass -c FILE" >&2
3927 exit 1
4028 end
4129
4230 # Host login shell (by name): added to the image automatically and baked
43− # in as the cmd + default $SHELL (what zellij panes etc. spawn), so it
31+ # in as the cmd + default $SHELL (what herdr panes etc. spawn), so it
4432 # needn't be listed in container.nix. Falls back to bash.
4533 set -l shell bash
4634 test -n "$SHELL"; and set shell (path basename $SHELL)
4735
48− # Build an image with the packages from $config (plus the shell) into a
36+ # Build an image with the config's `packages` (plus the shell) into a
4937 # temp dir, load it, then clean up.
5038 set -l tmp (mktemp -d)
5139 set -l archive $tmp/image.tar.gz
52− set -lx NIX_CONTAINER_PKGS (path resolve $config)
53− set -l expr "(builtins.getFlake \"$flake\").lib.\${builtins.currentSystem}.copyWithShell \"$shell\" (import (/. + builtins.getEnv \"NIX_CONTAINER_PKGS\"))"
40+ set -lx NIX_CONTAINER_CONFIG (path resolve $config)
41+ set -l expr "(builtins.getFlake \"$flake\").lib.\${builtins.currentSystem}.copyWithShell \"nix-container-$name\" \"$shell\" (import (/. + builtins.getEnv \"NIX_CONTAINER_CONFIG\")).packages"
5442 set -l copyer (nix build --impure --no-link --print-out-paths --expr "$expr")
5543 set -l rc $status
5644 if test $rc -eq 0
@@ -65,33 +53,41 @@ switch $argv[1]
6553 rm -rf $tmp
6654 test $rc -eq 0; or exit $rc
6755
68− # Mount the project at /workspace (the default cwd).
56+ # Mount the project at /workspace (the default cwd), plus any extra
57+ # mounts from the config (`mounts = [ "host:container" … ]`). A leading
58+ # ~/ in the host path expands to $HOME (nix leaves it literal).
6959 set -l mounts -v "$PWD:/workspace"
60+ for m in (nix eval --impure --raw --expr "builtins.concatStringsSep \"\n\" ((import (/. + builtins.getEnv \"NIX_CONTAINER_CONFIG\")).mounts or [])")
61+ set -a mounts -v (string replace -r '^~/' "$HOME/" -- $m)
62+ end
63+
64+ # TERM to bake into the container env (`-e TERM`): the runtime hands the
65+ # `start -ai` session a bare "xterm", so set the host's real terminal,
66+ # mapped to an entry the bundled ncurses ships — Ghostty reports
67+ # "xterm-ghostty", which ncurses ships as "ghostty" — falling back to
68+ # xterm-256color for an empty or bare-"xterm" value.
69+ set -l term xterm-256color
70+ if test "$TERM" = xterm-ghostty
71+ set term ghostty
72+ else if test -n "$TERM"; and test "$TERM" != xterm
73+ set term $TERM
74+ end
7075
71− # Replace any existing container of the same name. The image's cmd is the
72− # detected shell (holds the container open), so no command is passed.
76+ # Replace any existing container of the same name, then create it running
77+ # herdr in a session named after the project (`<dir>-container`). This
78+ # overrides the image's default plain-herdr cmd; `c start` attaches to it.
7379 container rm --force $name 2>/dev/null
74− # -e TERM: the runtime would otherwise force a bare "xterm" (see above).
75− #
7680 # The image only lays down /bin and /etc, so /tmp and /run don't exist;
77− # without them anything that writes temp files (zellij, build tools, …)
81+ # without them anything that writes temp files (herdr, build tools, …)
7882 # fails with ENOENT. Mount writable tmpfs at both.
7983 container create --name $name --ssh -it -e "TERM=$term" \
80− --tmpfs /tmp --tmpfs /run --memory 8g --cwd /workspace $mounts $argv $image
84+ --tmpfs /tmp --tmpfs /run --memory 8g --cwd /workspace $mounts $argv \
85+ $image /bin/herdr session attach "$name-container"
8186 case start
82− # Boot the container detached (its PID1 shell keeps it alive), then attach
83− # the session with `exec -it`. `container start -ai` has no `-t` flag, so
84− # it never hands the guest a properly sized TTY: the real window size
85− # isn't delivered via the TIOCGWINSZ ioctl, so ioctl-based TUIs
86− # (crossterm/ncurses — helix, zellij, btop…) see 0×0 and render garbled,
87− # misaligned output. `exec -it` opens a real, correctly sized TTY, and
88− # `-e TERM` gives it the right terminal type.
89− #
90− # When the session exits, stop the container so it doesn't linger (the
91− # PID1 shell would otherwise keep it running); `c start` boots it again.
92− container start $name >/dev/null 2>&1; or exit 1
93− container exec -it -e "TERM=$term" --cwd /workspace $name zellij
94− container stop $name >/dev/null 2>&1
87+ # Start the container and attach to its cmd (herdr). TERM was baked into
88+ # the container env at create time (`-e TERM`), which survives this path.
89+ # Exiting herdr (PID1) stops the container; `c start` boots it again.
90+ container start -ai $name
9591 case '*'
9692 echo "usage: c init [-c FILE] [create args] | start" >&2
9793 exit 1
flake.nix+27−23
@@ -72,21 +72,22 @@
7272 # Host package set, for the wrapper app below.
7373 pkgsHost = nixpkgs.legacyPackages.${system};
7474
75− # Name/tag baked into the image's OCI config.
75+ # Default image name (for `copyWith`); `c init` passes a per-directory
76+ # name (`nix-container-<dir>`) to `copyWithShell` instead.
7677 imageName = "nix-container";
7778 imageTag = "latest";
7879
79− # Build the OCI image from a `{ pkgs, nur }: [ ... ]` package function,
80− # for the named shell. The shell is the image's `cmd` (a PID1 that just
81− # holds the container open — `c start` execs the real session into it)
82− # and the default `$SHELL`; it's added automatically, so `container.nix`
80+ # Build the OCI image (named `name`) from a `{ pkgs, nur }: [ ... ]`
81+ # package function, for the named shell. The image's `cmd` is herdr
82+ # (the session `c start` attaches to); the shell is the default `$SHELL`
83+ # (what herdr panes spawn) and is added automatically, so `container.nix`
8384 # lists only your extras. Nix (+ CA certs) and the ncurses terminfo
8485 # database are always shipped too. Nothing here is fish-specific: the
85− # TERM fixup now happens host-side in `c` (passed via `-e TERM`).
86+ # TERM fixup happens host-side in `c` (passed via `-e TERM` at create).
8687 mkImage =
87− shell: packages:
88+ name: shell: packages:
8889 n2c.buildImage {
89− name = imageName;
90+ inherit name;
9091 tag = imageTag;
9192 inherit arch;
9293
@@ -118,7 +119,7 @@
118119 # entry for whatever TERM `c` passes in and render correctly.
119120 pkgsLinux.ncurses
120121 # The session `c start` always launches.
121− pkgsLinux.zellij
122+ pkgsLinux.herdr
122123 ];
123124 # Link all of /etc so the above files (nsswitch.conf, protocols,
124125 # services, passwd, group, ssl certs) are present.
@@ -132,14 +133,15 @@
132133
133134 config = {
134135 # `cmd` (not `entrypoint`): the default command, *replaced* by any
135− # command passed to `container run/create`. It's the detected
136− # shell — as PID1 it just holds the container open; `c start`
137− # execs the real interactive session (zellij) into it.
138− cmd = [ "/bin/${shell}" ];
136+ # command passed to `container run/create`. Plain herdr here;
137+ # `c init` overrides it with `herdr session attach <dir>-container` so
138+ # the session is named per project. `c start` attaches via
139+ # `container start -ai`; exiting herdr (PID1) stops the container.
140+ cmd = [ "/bin/herdr" ];
139141 env = [
140142 "PATH=/bin"
141143 "HOME=/root"
142− # Default shell for tools that spawn one (zellij panes, etc.).
144+ # Default shell for tools that spawn one (herdr panes, etc.).
143145 "SHELL=/bin/${shell}"
144146 # Marker so shells/scripts can detect they're in here.
145147 "NIX_CONTAINER=1"
@@ -175,20 +177,22 @@
175177 default = flake-utils.lib.mkApp { drv = cApp; };
176178 };
177179
178− # Build helpers honour a per-project `container.nix`. `c init` uses
180+ # Build helpers take a `{ pkgs, nur }: [ ... ]` packages function (the
181+ # `packages` attr of a project's `container.nix`). `c init` uses
179182 # `copyWithShell` (host shell baked in); `copyWith` is the generic form:
180183 # (builtins.getFlake "github:…/nix-container")
181− # .lib.<system>.copyWith (import ./container.nix)
184+ # .lib.<system>.copyWith (import ./container.nix).packages
182185 lib = {
183186 inherit mkImage;
184187 # Build the image's `copyTo` script (a skopeo-copy wrapper) from a
185− # `{ pkgs, nur }: [ ... ]` function. Defaults the shell (cmd + $SHELL)
186− # to bash; use `copyWithShell` to pick another.
187− copyWith = packages: (mkImage "bash" packages).copyTo;
188− # Like `copyWith`, but for the named shell — `c init` passes the host
189− # login shell, which is added to the image and used as cmd + $SHELL
190− # without being listed in `container.nix`.
191− copyWithShell = shell: packages: (mkImage shell packages).copyTo;
188+ # `{ pkgs, nur }: [ ... ]` function, named `imageName`. Defaults the
189+ # shell (cmd + $SHELL) to bash; use `copyWithShell` to pick another.
190+ copyWith = packages: (mkImage imageName "bash" packages).copyTo;
191+ # Like `copyWith`, but for the given image `name` and shell — `c init`
192+ # passes `nix-container-<dir>` and the host login shell, which is added
193+ # to the image and used as cmd + $SHELL without being listed in
194+ # `container.nix`.
195+ copyWithShell = name: shell: packages: (mkImage name shell packages).copyTo;
192196 };
193197 }
194198 );