on master help sign in
Make container shell-agnostic: detect host shell, resolve TERM host-side, include coreutils, stop on exit
commitd1b5b46d22a4ab8696b003bd13c97ad422379e6f authoriko <mail@iko.soy> dateSun 28 Jun 2026, 05:29 (3 months ago) parentdd7925a
3 files changed+68−123
README.md+8−6 c+29−22 flake.nix+31−95
README.md+8−6
@@ -5,8 +5,9 @@ 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 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`.
1011
1112## Prerequisites
1213
@@ -40,9 +41,10 @@ is **required**; there is no default set.
4041]
4142```
4243
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.
44+Your **host login shell** (detected by `c init`, bash fallback), **coreutils**,
45+**Nix** itself (with CA certs and `nix-command`/`flakes` enabled, single-user),
46+and the ncurses **terminfo** database are always included — no need to list them.
47+`c start` launches [`zellij`](https://zellij.dev), so include it in `container.nix`.
4648
4749Point at a different file with `-c`/`--config`:
4850
@@ -59,7 +61,7 @@ that holds a `container.nix`; the container is named after that directory.
5961# build the image, load it, and create the container (named after $PWD):
6062nix run github:ilyakooo0/nix-container -- init
6163
62−# start it and attach — drops you into fish:
64+# start it and attach — drops you into a zellij session:
6365nix run github:ilyakooo0/nix-container -- start
6466```
6567
c+29−22
@@ -10,6 +10,18 @@ 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+
1325switch $argv[1]
1426 case init
1527 # -c/--config overrides the default ./container.nix; leftover args (e.g.
@@ -27,9 +39,9 @@ switch $argv[1]
2739 exit 1
2840 end
2941
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.
42+ # 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
44+ # needn't be listed in container.nix. Falls back to bash.
3345 set -l shell bash
3446 test -n "$SHELL"; and set shell (path basename $SHELL)
3547
@@ -56,35 +68,30 @@ switch $argv[1]
5668 # Mount the project at /workspace (the default cwd).
5769 set -l mounts -v "$PWD:/workspace"
5870
59− # Replace any existing container of the same name. The image's cmd is
60− # /bin/fish, so no command is passed.
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.
6173 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.
74+ # -e TERM: the runtime would otherwise force a bare "xterm" (see above).
6575 #
6676 # The image only lays down /bin and /etc, so /tmp and /run don't exist;
6777 # without them anything that writes temp files (zellij, build tools, …)
6878 # 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" \
79+ container create --name $name --ssh -it -e "TERM=$term" \
7280 --tmpfs /tmp --tmpfs /run --memory 8g --cwd /workspace $mounts $argv $image
7381 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
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
7886 # (crossterm/ncurses — helix, zellij, btop…) see 0×0 and render garbled,
79− # misaligned output. `exec -it` opens a real, correctly sized TTY.
87+ # misaligned output. `exec -it` opens a real, correctly sized TTY, and
88+ # `-e TERM` gives it the right terminal type.
8089 #
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.
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.
8592 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"
93+ container exec -it -e "TERM=$term" --cwd /workspace $name zellij
94+ container stop $name >/dev/null 2>&1
8895 case '*'
8996 echo "usage: c init [-c FILE] [create args] | start" >&2
9097 exit 1
flake.nix+31−95
@@ -76,65 +76,15 @@
7676 imageName = "nix-container";
7777 imageTag = "latest";
7878
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.
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`
83+ # lists only your extras. Nix (+ CA certs) and the ncurses terminfo
84+ # database are always shipped too. Nothing here is fish-specific: the
85+ # TERM fixup now happens host-side in `c` (passed via `-e TERM`).
13686 mkImage =
137− packages:
87+ shell: packages:
13888 n2c.buildImage {
13989 name = imageName;
14090 tag = imageTag;
@@ -144,10 +94,6 @@
14494 # buildEnv only *symlinks* paths (never runs them), so build it on
14595 # the host arch — the tree is arch-neutral and links the
14696 # (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.
15197 copyToRoot = pkgsHost.buildEnv {
15298 name = "root";
15399 paths =
@@ -155,24 +101,25 @@
155101 pkgs = pkgsLinux;
156102 nur = nurPkgs;
157103 }
104+ # The shell (cmd + $SHELL), when it exists in nixpkgs.
105+ ++ pkgsLinux.lib.optional (pkgsLinux ? ${shell}) pkgsLinux.${shell}
158106 ++ [
159− pkgsLinux.fish
160− fishRoot
161107 pkgsLinux.nix
162108 pkgsLinux.cacert
109+ # Basic userland (ls, cat, cp, sleep, …) so the container is
110+ # usable out of the box and tools that shell out don't fail.
111+ pkgsLinux.coreutils-full
163112 # Networking glue glibc needs: /etc/nsswitch.conf (+passwd,
164113 # group) so DNS/host lookups resolve, and /etc/protocols,
165114 # /etc/services. Without these, hostname resolution fails.
166115 pkgsLinux.fakeNss
167116 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.
117+ # The terminfo database (+ tput/clear/reset) so TUIs find the
118+ # entry for whatever TERM `c` passes in and render correctly.
172119 pkgsLinux.ncurses
173120 ];
174121 # Link all of /etc so the above files (nsswitch.conf, protocols,
175− # services, passwd, group, ssl certs, fish prompt) are present.
122+ # services, passwd, group, ssl certs) are present.
176123 pathsToLink = [
177124 "/bin"
178125 "/etc"
@@ -183,26 +130,19 @@
183130
184131 config = {
185132 # `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− ];
133+ # command passed to `container run/create`. It's the detected
134+ # shell — as PID1 it just holds the container open; `c start`
135+ # execs the real interactive session (zellij) into it.
136+ cmd = [ "/bin/${shell}" ];
195137 env = [
196138 "PATH=/bin"
197139 "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"
140+ # Default shell for tools that spawn one (zellij panes, etc.).
141+ "SHELL=/bin/${shell}"
201142 # Marker so shells/scripts can detect they're in here.
202143 "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.
144+ # Advertise truecolor for 24-bit-aware tools; `c` passes the
145+ # resolved TERM via -e (the runtime would otherwise force xterm).
206146 "COLORTERM=truecolor"
207147 # A UTF-8 locale so tools handle multibyte/wide characters
208148 # correctly. glibc ships C.UTF-8 built in, so this needs no
@@ -240,17 +180,13 @@
240180 lib = {
241181 inherit mkImage;
242182 # 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;
183+ # from a `{ pkgs, nur }: [ ... ]` function. Defaults the shell (cmd +
184+ # $SHELL) to bash; use `copyWithShell` to pick another.
185+ copyWith = packages: (mkImage "bash" packages).copyTo;
186+ # Like `copyWith`, but for the named shell — `c init` passes the host
187+ # login shell, which is added to the image and used as cmd + $SHELL
188+ # without being listed in `container.nix`.
189+ copyWithShell = shell: packages: (mkImage shell packages).copyTo;
254190 };
255191 }
256192 );