on master help sign in
3 files changed+116−160
since splitsnapshots
unifiedsplit
README.md+34−23 c+38−35 flake.nix+44−102
README.md+34−23
@@ -5,8 +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). It runs `fish` by default (with a configured
9−prompt); 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`.
1012
1113## Prerequisites
1214
@@ -25,24 +27,31 @@ prompt); you choose what else it ships via a per-project `container.nix`.
2527
2628## Packages
2729
28−`init` builds the image from a **`container.nix`** in the current directory — a
29−`{ pkgs, nur }` function returning the package list (`nur` is
30−[NUR](https://github.com/nix-community/NUR), for packages outside nixpkgs). It
31−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.
3234
3335```nix
3436# ./container.nix
35−{ pkgs, nur }: with pkgs; [
36− coreutils
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
43−**fish** (the default command, with a configured prompt) and **Nix** itself
44−(with CA certs and `nix-command`/`flakes` enabled, single-user) are always
45−included — no need to list them.
51+Your **host login shell** (detected by `c init`, bash fallback), **coreutils**,
52+[**herdr**](https://herdr.dev) (the session `c start` launches), **Nix** itself
53+(with CA certs and `nix-command`/`flakes` enabled, single-user), and the ncurses
54+**terminfo** database are always included — no need to list them.
4655
4756Point at a different file with `-c`/`--config`:
4857
@@ -59,7 +68,7 @@ that holds a `container.nix`; the container is named after that directory.
5968# build the image, load it, and create the container (named after $PWD):
6069nix run github:ilyakooo0/nix-container -- init
6170
62−# start it and attach — drops you into fish:
71+# start it and attach — drops you into a herdr session:
6372nix run github:ilyakooo0/nix-container -- start
6473```
6574
@@ -68,7 +77,8 @@ nix run github:ilyakooo0/nix-container -- start
6877 directory at `/workspace` (the container's working directory).
6978- `--ssh` forwards your host SSH agent socket into the container (so `git` over
7079 SSH works with your keys).
71−- 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):
7282
7383 ```sh
7484 nix run github:ilyakooo0/nix-container -- init -v $HOME/data:/data
@@ -89,18 +99,19 @@ nix run github:ilyakooo0/nix-container -- init
8999## Customize further
90100
91101Fork or clone to change what isn't per-project — `config.cmd`/`env` or the image
92−name — in [`flake.nix`](./flake.nix). The flake also exposes build helpers, both
93−taking a `{ pkgs, nur }: [ ... ]` function:
102+name — in [`flake.nix`](./flake.nix). The flake also exposes build helpers:
94103
95−- `lib.<system>.mkImage` → an OCI image.
96−- `lib.<system>.copyWith` → a skopeo copy app, for building/pushing the image
97− 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):
98107
99108 ```sh
100109 drv=$(nix build --no-link --print-out-paths --impure --expr \
101− '(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')
102111 $drv/bin/copy-to docker://ghcr.io/me/img:latest
103112 ```
113+- `lib.<system>.mkImage <name> <shell>` → the underlying OCI image (`copyWith`
114+ defaults these to `nix-container` / `bash`).
104115
105116The flake uses `flake-utils` over `{aarch64,x86_64}-{darwin,linux}`; the host
106117system maps to the matching Linux target automatically (an Intel Mac builds an
c+38−35
@@ -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)
@@ -22,23 +22,23 @@ switch $argv[1]
2222 end
2323
2424 if not test -e $config
25− echo "c: package file not found: $config" >&2
26− 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
2727 exit 1
2828 end
2929
30− # Host login shell (by name): added to the image automatically and used
31− # as the default shell (what zellij panes etc. spawn), so it needn't be
32− # listed in container.nix. Falls back to bash.
30+ # Host login shell (by name): added to the image automatically and baked
31+ # in as the cmd + default $SHELL (what herdr panes etc. spawn), so it
32+ # needn't be listed in container.nix. Falls back to bash.
3333 set -l shell bash
3434 test -n "$SHELL"; and set shell (path basename $SHELL)
3535
36− # 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
3737 # temp dir, load it, then clean up.
3838 set -l tmp (mktemp -d)
3939 set -l archive $tmp/image.tar.gz
40− set -lx NIX_CONTAINER_PKGS (path resolve $config)
41− 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"
4242 set -l copyer (nix build --impure --no-link --print-out-paths --expr "$expr")
4343 set -l rc $status
4444 if test $rc -eq 0
@@ -53,38 +53,41 @@ switch $argv[1]
5353 rm -rf $tmp
5454 test $rc -eq 0; or exit $rc
5555
56− # 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).
5759 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
5875
59− # Replace any existing container of the same name. The image's cmd is
60− # /bin/fish, 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.
6179 container rm --force $name 2>/dev/null
62− # Forward the host terminal type: the runtime strips $TERM down to a
63− # bare "xterm", so pass the real value as HOST_TERM for the fish init to
64− # recover (see prompt.fish) — TUIs need the matching terminfo entry.
65− #
6680 # The image only lays down /bin and /etc, so /tmp and /run don't exist;
67− # without them anything that writes temp files (zellij, build tools, …)
81+ # without them anything that writes temp files (herdr, build tools, …)
6882 # fails with ENOENT. Mount writable tmpfs at both.
69− # SHELL=/bin/<detected> makes the host shell the default (zellij panes,
70− # etc.); it overrides the image's baked-in SHELL=/bin/fish fallback.
71− container create --name $name --ssh -it -e "HOST_TERM=$TERM" -e "SHELL=/bin/$shell" \
72− --tmpfs /tmp --tmpfs /run --memory 8g --cwd /workspace $mounts $argv $image
83+ container create --name $name --ssh -it -e "TERM=$term" \
84+ --tmpfs /tmp --tmpfs /run --memory 8g --cwd /workspace $mounts $argv \
85+ $image /bin/herdr session attach "$name-container"
7386 case start
74− # Boot the container detached (its PID1 fish keeps it alive), then attach
75− # an interactive shell with `exec -it`. `container start -ai` has no `-t`
76− # flag, so it never hands the guest a properly sized TTY: the real window
77− # size isn't delivered via the TIOCGWINSZ ioctl, so ioctl-based TUIs
78− # (crossterm/ncurses — helix, zellij, btop…) see 0×0 and render garbled,
79− # misaligned output. `exec -it` opens a real, correctly sized TTY.
80− #
81− # Launch zellij as the session. We go through fish's init first so the
82− # TERM fixup in prompt.fish runs (otherwise zellij gets the runtime's bare
83− # "xterm"), then `exec zellij` replaces fish — and if zellij fails to
84− # start, fish stays interactive as a fallback.
85− container start $name >/dev/null 2>&1; or exit 1
86− container exec -it --cwd /workspace $name \
87− /bin/fish --no-config --init-command "source /etc/fish/prompt.fish; exec zellij"
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
8891 case '*'
8992 echo "usage: c init [-c FILE] [create args] | start" >&2
9093 exit 1
flake.nix+44−102
@@ -72,71 +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− # Fish prompt config. Installed at /etc/fish/prompt.fish and loaded via
80− # `fish --no-config --init-command` (see `cmd` below), NOT from
81− # $HOME/.config/fish — so the `~/.config` mount can't shadow it. Built
82− # on the host arch (just a text file), so no Linux builder is needed.
83− fishConfig = pkgsHost.writeText "prompt.fish" ''
84− # No welcome banner.
85− set -g fish_greeting
86−
87− # Pick a TERM whose terminfo entry actually exists in the bundled
88− # database, so TUIs render with the right capabilities (otherwise
89− # they glitch on redraw). The container runtime strips the host TERM
90− # down to a bare "xterm"; `c` forwards the real value as HOST_TERM.
91− # Ghostty reports "xterm-ghostty", but ncurses ships that entry under
92− # the name "ghostty", so map it. Prefer HOST_TERM over the runtime's
93− # bare "xterm" placeholder, falling back to xterm-256color.
94− for term_candidate in $HOST_TERM $TERM xterm-256color
95− test "$term_candidate" = xterm; and continue
96− set -l entry $term_candidate
97− test "$entry" = xterm-ghostty; and set entry ghostty
98− if infocmp $entry >/dev/null 2>&1
99− set -gx TERM $entry
100− break
101− end
102− end
103− set -q COLORTERM; or set -gx COLORTERM truecolor
104−
105− # Define the prompt as a function so fish renders it before every
106− # command; at top level it would run once at startup and be ignored.
107− function fish_prompt
108− set -l nix_shell_info
109− if set -q IN_NIX_SHELL; or set -q IN_NIX_RUN
110− set nix_shell_info ' ❄️'
111− end
112−
113− set -l cwd (prompt_pwd)
114−
115− set -l bookmark_info
116− if jj root 2>/dev/null >/dev/null
117− set -l jj_bookmark (jj log -r 'heads(::@ & bookmarks())' -T 'local_bookmarks ++ "\n"' --no-graph 2>/dev/null | tail -1)
118− if test -n "$jj_bookmark"
119− set bookmark_info ' ' (set_color brmagenta) $jj_bookmark (set_color normal)
120− end
121− end
122−
123− echo -n -s (set_color $fish_color_cwd) $cwd (set_color normal) $bookmark_info ' 🏗️' $nix_shell_info '> '
124− end
125− '';
126−
127− # Lay the prompt out at the path the cmd sources.
128− fishRoot = pkgsHost.runCommand "fish-prompt" { } ''
129− mkdir -p "$out/etc/fish"
130− cp ${fishConfig} "$out/etc/fish/prompt.fish"
131− '';
132−
133− # Build the OCI image from a `{ pkgs, nur }: [ ... ]` package function.
134− # The image always runs /bin/fish; fish, its prompt config, and Nix are
135− # bundled automatically, so `container.nix` lists only your extras.
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`
84+ # lists only your extras. Nix (+ CA certs) and the ncurses terminfo
85+ # database are always shipped too. Nothing here is fish-specific: the
86+ # TERM fixup happens host-side in `c` (passed via `-e TERM` at create).
13687 mkImage =
137− packages:
88+ name: shell: packages:
13889 n2c.buildImage {
139− name = imageName;
90+ inherit name;
14091 tag = imageTag;
14192 inherit arch;
14293
@@ -144,10 +95,6 @@
14495 # buildEnv only *symlinks* paths (never runs them), so build it on
14596 # the host arch — the tree is arch-neutral and links the
14697 # (substituted) Linux store paths, sparing the Linux builder.
147− #
148− # Always shipped: fish (the cmd) + its prompt, and Nix (+ CA certs)
149− # so every container can use Nix; `initializeNixDatabase` registers
150− # the baked store.
15198 copyToRoot = pkgsHost.buildEnv {
15299 name = "root";
153100 paths =
@@ -155,24 +102,27 @@
155102 pkgs = pkgsLinux;
156103 nur = nurPkgs;
157104 }
105+ # The shell (cmd + $SHELL), when it exists in nixpkgs.
106+ ++ pkgsLinux.lib.optional (pkgsLinux ? ${shell}) pkgsLinux.${shell}
158107 ++ [
159− pkgsLinux.fish
160− fishRoot
161108 pkgsLinux.nix
162109 pkgsLinux.cacert
110+ # Basic userland (ls, cat, cp, sleep, …) so the container is
111+ # usable out of the box and tools that shell out don't fail.
112+ pkgsLinux.coreutils-full
163113 # Networking glue glibc needs: /etc/nsswitch.conf (+passwd,
164114 # group) so DNS/host lookups resolve, and /etc/protocols,
165115 # /etc/services. Without these, hostname resolution fails.
166116 pkgsLinux.fakeNss
167117 pkgsLinux.iana-etc
168− # Terminfo tools (infocmp/tput/clear/reset). The terminfo DB
169− # itself already ships via fish's ncurses dependency; this
170− # puts the lookup tools on PATH so the prompt init can pick a
171− # TERM entry that exists, and TUIs get correct capabilities.
118+ # The terminfo database (+ tput/clear/reset) so TUIs find the
119+ # entry for whatever TERM `c` passes in and render correctly.
172120 pkgsLinux.ncurses
121+ # The session `c start` always launches.
122+ pkgsLinux.herdr
173123 ];
174124 # Link all of /etc so the above files (nsswitch.conf, protocols,
175− # services, passwd, group, ssl certs, fish prompt) are present.
125+ # services, passwd, group, ssl certs) are present.
176126 pathsToLink = [
177127 "/bin"
178128 "/etc"
@@ -183,26 +133,20 @@
183133
184134 config = {
185135 # `cmd` (not `entrypoint`): the default command, *replaced* by any
186− # command passed to `container run/create`. `--no-config` skips
187− # $HOME/.config/fish (so a mounted ~/.config can't override the
188− # prompt); `--init-command` loads the bundled prompt instead.
189− cmd = [
190− "/bin/fish"
191− "--no-config"
192− "--init-command"
193− "source /etc/fish/prompt.fish"
194− ];
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" ];
195141 env = [
196142 "PATH=/bin"
197143 "HOME=/root"
198− # Default shell for tools that spawn one (zellij panes, etc.);
199− # without it they fall back to /bin/sh.
200− "SHELL=/bin/fish"
144+ # Default shell for tools that spawn one (herdr panes, etc.).
145+ "SHELL=/bin/${shell}"
201146 # Marker so shells/scripts can detect they're in here.
202147 "NIX_CONTAINER=1"
203− # Advertise truecolor so `container exec` sessions (which don't
204− # source the fish init) still get 24-bit color. TERM is fixed up
205− # in the fish prompt init, since the runtime overrides it here.
148+ # Advertise truecolor for 24-bit-aware tools; `c` passes the
149+ # resolved TERM via -e (the runtime would otherwise force xterm).
206150 "COLORTERM=truecolor"
207151 # A UTF-8 locale so tools handle multibyte/wide characters
208152 # correctly. glibc ships C.UTF-8 built in, so this needs no
@@ -220,7 +164,7 @@
220164
221165 # `nix run . -- init|start` runs the bundled `c` manager script. Its
222166 # `init` builds the image from the project's `container.nix` (via
223− # `lib.copyWith`); the script finds the flake from its own (store)
167+ # `lib.copyWithShell`); the script finds the flake from its own (store)
224168 # location, so this works from a checkout or straight from
225169 # `nix run github:…/nix-container`.
226170 cApp = pkgsHost.writeShellScriptBin "c" ''
@@ -233,24 +177,22 @@
233177 default = flake-utils.lib.mkApp { drv = cApp; };
234178 };
235179
236− # Build helpers. `c init` uses `copyWith` to honour a per-project
237− # `container.nix`:
180+ # Build helpers take a `{ pkgs, nur }: [ ... ]` packages function (the
181+ # `packages` attr of a project's `container.nix`). `c init` uses
182+ # `copyWithShell` (host shell baked in); `copyWith` is the generic form:
238183 # (builtins.getFlake "github:…/nix-container")
239− # .lib.<system>.copyWith (import ./container.nix)
184+ # .lib.<system>.copyWith (import ./container.nix).packages
240185 lib = {
241186 inherit mkImage;
242− # skopeo copy app (`skopeo copy nix:<image> "$@"`) for an image built
243− # from a `{ pkgs, nur }: [ ... ]` function.
244− copyWith = packages: (mkImage packages).copyTo;
245− # Like `copyWith`, but also adds the named shell package (when it
246− # exists in nixpkgs) so `c init` can make the host shell available
247− # without it being listed in `container.nix`.
248− copyWithShell =
249− shell: packages:
250− (mkImage (
251− { pkgs, nur }:
252− packages { inherit pkgs nur; } ++ pkgs.lib.optional (pkgs ? ${shell}) pkgs.${shell}
253− )).copyTo;
187+ # Build the image's `copyTo` script (a skopeo-copy wrapper) from a
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;
254196 };
255197 }
256198 );