on 686fc14 help sign in
3 files changed+109−183
since splitsnapshots
unifiedsplit
README.md+31−47 c+13−40 flake.nix+65−96
README.md+31−47
@@ -5,10 +5,8 @@ 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 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`.
8+[`flake.nix`](./flake.nix). `init` runs your host login shell (`$SHELL`); you
9+choose what else it ships via a per-project `container.nix`.
1210
1311## Prerequisites
1412
@@ -16,42 +14,32 @@ per-project `container.nix`.
1614- Apple's [`container`](https://github.com/apple/container) CLI installed, with
1715 services started: `container system start` (first run downloads a Linux
1816 kernel).
19−- A **Linux builder** *only if you use packages that aren't in the binary
20− cache*. The image is assembled entirely on macOS — the root filesystem is just
21− a host-built symlink tree over the **substituted** Linux store paths — so a
22− fully-cached package set needs no builder. Compiling a Linux package that
23− `cache.nixos.org` doesn't have does, though: use a [remote builder][builders]
24− or, on Apple Silicon, the `nix-darwin` `linux-builder`.
17+- A **Linux builder** — the image *contents* are Linux binaries, which can't be
18+ built on macOS. Use a [remote builder][builders] or, on Apple Silicon, the
19+ `nix-darwin` `linux-builder`. With one configured, `nix` dispatches Linux
20+ builds to it automatically (most packages also come prebuilt from the binary
21+ cache, so the builder is only needed for the uncached bits).
2522
2623[builders]: https://nix.dev/manual/nix/latest/advanced-topics/distributed-builds
2724
2825## Packages
2926
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.
27+`init` builds the image from a **`container.nix`** in the current directory — a
28+`{ pkgs, nur }` function returning the package list (`nur` is
29+[NUR](https://github.com/nix-community/NUR), for packages outside nixpkgs). It
30+is **required**; there is no default set.
3431
3532```nix
3633# ./container.nix
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−}
34+{ pkgs, nur }: with pkgs; [
35+ coreutils
36+ git
37+ go
38+ nur.repos.charmbracelet.crush
39+]
4940```
5041
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.
42+Your host shell (`$SHELL`) is added and run automatically — no need to list it.
5543
5644Point at a different file with `-c`/`--config`:
5745
@@ -68,17 +56,17 @@ that holds a `container.nix`; the container is named after that directory.
6856# build the image, load it, and create the container (named after $PWD):
6957nix run github:ilyakooo0/nix-container -- init
7058
71−# start it and attach — drops you into a herdr session:
59+# start it and attach — drops you into your shell:
7260nix run github:ilyakooo0/nix-container -- start
7361```
7462
7563- `init` builds the OCI archive in a temp dir, loads it into `container`,
7664 deletes the temp file, then (re)creates the container. It mounts the current
77− directory at `/workspace` (the container's working directory).
65+ directory at `/workspace` (the container's working directory) and your
66+ `~/.config` at `/root/.config`.
7867- `--ssh` forwards your host SSH agent socket into the container (so `git` over
7968 SSH works with your keys).
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):
69+- Add more mounts by passing them through (mounts can only be set at creation):
8270
8371 ```sh
8472 nix run github:ilyakooo0/nix-container -- init -v $HOME/data:/data
@@ -99,19 +87,18 @@ nix run github:ilyakooo0/nix-container -- init
9987## Customize further
10088
10189Fork or clone to change what isn't per-project — `config.cmd`/`env` or the image
102−name — in [`flake.nix`](./flake.nix). The flake also exposes build helpers:
90+name — in [`flake.nix`](./flake.nix). The flake also exposes build helpers, both
91+taking a `{ pkgs, nur }: [ ... ]` function:
10392
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):
93+- `lib.<system>.mkImage` → an OCI image.
94+- `lib.<system>.copyWith` → a skopeo copy app, for building/pushing the image
95+ anywhere skopeo can write (e.g. a registry):
10796
10897 ```sh
10998 drv=$(nix build --no-link --print-out-paths --impure --expr \
110− '(builtins.getFlake "github:ilyakooo0/nix-container").lib.${builtins.currentSystem}.copyWith (import ./container.nix).packages')
99+ '(builtins.getFlake "github:ilyakooo0/nix-container").lib.${builtins.currentSystem}.copyWith (import ./container.nix)')
111100 $drv/bin/copy-to docker://ghcr.io/me/img:latest
112101 ```
113−- `lib.<system>.mkImage <name> <shell>` → the underlying OCI image (`copyWith`
114− defaults these to `nix-container` / `bash`).
115102
116103The flake uses `flake-utils` over `{aarch64,x86_64}-{darwin,linux}`; the host
117104system maps to the matching Linux target automatically (an Intel Mac builds an
@@ -119,12 +106,9 @@ system maps to the matching Linux target automatically (an Intel Mac builds an
119106
120107## How it works
121108
122−- **Contents** are Linux store paths — downloaded prebuilt from the binary cache
123− (only built on a Linux builder if not cached).
124−- **Assembly** runs entirely on your Mac: `copyToRoot` is a *host-arch* `buildEnv`
125− symlink tree over those Linux paths (symlinks are arch-neutral), and
126− `nix2container` + `skopeo` package it — so for a cached set, `init` produces a
127− loadable Linux image with no Linux build at all.
109+- **Contents** are built from `pkgsLinux` → the Linux builder.
110+- **Assembly** (`nix2container` + the `skopeo` copy) runs on your Mac, so a
111+ single `init` produces a loadable archive.
128112- Apple's `container` runs each container in its own lightweight Linux VM and
129113 consumes standard OCI images — no `--privileged` or systemd needed.
130114
c+13−40
@@ -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−# (config from FILE, default ./container.nix)
4+# (packages from FILE, default ./container.nix)
55# c start start it and attach
66
77set -l name (path basename $PWD)
@@ -22,23 +22,22 @@ switch $argv[1]
2222 end
2323
2424 if not test -e $config
25− echo "c: config file not found: $config" >&2
26− echo " create one ('{ packages = { pkgs, nur }: [ ... ]; mounts = [ ]; }') or pass -c FILE" >&2
25+ echo "c: package file not found: $config" >&2
26+ echo " create one ('{ pkgs, nur }: [ ... ]') or pass -c FILE" >&2
2727 exit 1
2828 end
2929
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.
33− set -l shell bash
30+ # Host login shell (by name): run as the container cmd and added to the
31+ # image automatically, so it needn't be listed in container.nix.
32+ set -l shell fish
3433 test -n "$SHELL"; and set shell (path basename $SHELL)
3534
36− # Build an image with the config's `packages` (plus the shell) into a
35+ # Build an image with the packages from $config (plus the shell) into a
3736 # temp dir, load it, then clean up.
3837 set -l tmp (mktemp -d)
3938 set -l archive $tmp/image.tar.gz
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"
39+ set -lx NIX_CONTAINER_PKGS (path resolve $config)
40+ set -l expr "(builtins.getFlake \"$flake\").lib.\${builtins.currentSystem}.copyWithShell \"$shell\" (import (/. + builtins.getEnv \"NIX_CONTAINER_PKGS\"))"
4241 set -l copyer (nix build --impure --no-link --print-out-paths --expr "$expr")
4342 set -l rc $status
4443 if test $rc -eq 0
@@ -53,40 +52,14 @@ switch $argv[1]
5352 rm -rf $tmp
5453 test $rc -eq 0; or exit $rc
5554
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).
55+ # Mount the project at /workspace (the default cwd) and host ~/.config.
5956 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
57+ test -d $HOME/.config; and set -a mounts -v "$HOME/.config:/root/.config"
7558
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.
59+ # Replace any existing container of the same name.
7960 container rm --force $name 2>/dev/null
80− # The image only lays down /bin and /etc, so /tmp and /run don't exist;
81− # without them anything that writes temp files (herdr, build tools, …)
82− # fails with ENOENT. Mount writable tmpfs at both.
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"
61+ container create --name $name --ssh -it --cwd /workspace $mounts $argv $image /bin/$shell
8662 case start
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.
9063 container start -ai $name
9164 case '*'
9265 echo "usage: c init [-c FILE] [create args] | start" >&2
flake.nix+65−96
@@ -72,99 +72,66 @@
7272 # Host package set, for the wrapper app below.
7373 pkgsHost = nixpkgs.legacyPackages.${system};
7474
75− # Default image name (for `copyWith`); `c init` passes a per-directory
76− # name (`nix-container-<dir>`) to `copyWithShell` instead.
75+ # Name/tag baked into the image's OCI config.
7776 imageName = "nix-container";
7877 imageTag = "latest";
7978
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).
79+ # Build the OCI image from a `{ pkgs, nur }: [ ... ]` package function.
80+ # The default `cmd` is /bin/fish; `c init` runs your host login shell
81+ # instead and adds it automatically (see `copyWithShell`). The warning
82+ # below only catches an image left with no shell at all.
8783 mkImage =
88− name: shell: packages:
89− n2c.buildImage {
90− inherit name;
91− tag = imageTag;
92− inherit arch;
93−
94− # A plain, single-process root filesystem. No NixOS, no systemd.
95− # buildEnv only *symlinks* paths (never runs them), so build it on
96− # the host arch — the tree is arch-neutral and links the
97− # (substituted) Linux store paths, sparing the Linux builder.
98− copyToRoot = pkgsHost.buildEnv {
99− name = "root";
100− paths =
101− packages {
102− pkgs = pkgsLinux;
103− nur = nurPkgs;
104− }
105− # The shell (cmd + $SHELL), when it exists in nixpkgs.
106− ++ pkgsLinux.lib.optional (pkgsLinux ? ${shell}) pkgsLinux.${shell}
107− ++ [
108− pkgsLinux.nix
109− 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
113− # Networking glue glibc needs: /etc/nsswitch.conf (+passwd,
114− # group) so DNS/host lookups resolve, and /etc/protocols,
115− # /etc/services. Without these, hostname resolution fails.
116− pkgsLinux.fakeNss
117− pkgsLinux.iana-etc
118− # The terminfo database (+ tput/clear/reset) so TUIs find the
119− # entry for whatever TERM `c` passes in and render correctly.
120− pkgsLinux.ncurses
121− # The session `c start` always launches.
122− pkgsLinux.herdr
123− ];
124− # Link all of /etc so the above files (nsswitch.conf, protocols,
125− # services, passwd, group, ssl certs) are present.
126− pathsToLink = [
127− "/bin"
128− "/etc"
129− ];
84+ packages:
85+ let
86+ pkgList = packages {
87+ pkgs = pkgsLinux;
88+ nur = nurPkgs;
13089 };
90+ hasShell = builtins.any (
91+ p:
92+ builtins.elem (p.pname or "") [
93+ "fish"
94+ "bash"
95+ "zsh"
96+ ]
97+ ) pkgList;
98+ in
99+ pkgsLinux.lib.warnIf (!hasShell)
100+ "nix-container: the package set has no shell (fish/bash/zsh) — the container's command may not exist (`c init` runs your host shell)."
101+ (
102+ n2c.buildImage {
103+ name = imageName;
104+ tag = imageTag;
105+ inherit arch;
131106
132− initializeNixDatabase = true;
107+ # A plain, single-process root filesystem. No NixOS, no systemd.
108+ copyToRoot = pkgsLinux.buildEnv {
109+ name = "root";
110+ paths = pkgList;
111+ pathsToLink = [ "/bin" ];
112+ };
133113
134− config = {
135− # `cmd` (not `entrypoint`): the default command, *replaced* by any
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" ];
141− env = [
142− "PATH=/bin"
143− "HOME=/root"
144− # Default shell for tools that spawn one (herdr panes, etc.).
145− "SHELL=/bin/${shell}"
146− # Marker so shells/scripts can detect they're in here.
147− "NIX_CONTAINER=1"
148− # Advertise truecolor for 24-bit-aware tools; `c` passes the
149− # resolved TERM via -e (the runtime would otherwise force xterm).
150− "COLORTERM=truecolor"
151− # A UTF-8 locale so tools handle multibyte/wide characters
152− # correctly. glibc ships C.UTF-8 built in, so this needs no
153− # locale-archive.
154− "LANG=C.UTF-8"
155− # CA trust for every TLS client (curl/git/…) and Nix.
156− "SSL_CERT_FILE=/etc/ssl/certs/ca-bundle.crt"
157− "NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-bundle.crt"
158− # Make the bundled Nix usable: flake commands on, and
159− # single-user (no daemon/nixbld).
160− "NIX_CONFIG=experimental-features = nix-command flakes\nbuild-users-group =\nsandbox = false"
161− ];
162− };
163− };
114+ initializeNixDatabase = true;
115+
116+ config = {
117+ # `cmd` (not `entrypoint`): it's the default command and is
118+ # *replaced* by `container run … -- <cmd>`. An entrypoint would
119+ # be prepended instead, so `run … -- /bin/sh` would become
120+ # `/bin/bash /bin/sh` and fail with "cannot execute binary file".
121+ cmd = [ "/bin/fish" ];
122+ env = [
123+ "PATH=/bin"
124+ "HOME=/root"
125+ # Marker so shells/scripts can detect they're in here.
126+ "NIX_CONTAINER=1"
127+ ];
128+ };
129+ }
130+ );
164131
165132 # `nix run . -- init|start` runs the bundled `c` manager script. Its
166133 # `init` builds the image from the project's `container.nix` (via
167− # `lib.copyWithShell`); the script finds the flake from its own (store)
134+ # `lib.copyWith`); the script finds the flake from its own (store)
168135 # location, so this works from a checkout or straight from
169136 # `nix run github:…/nix-container`.
170137 cApp = pkgsHost.writeShellScriptBin "c" ''
@@ -177,22 +144,24 @@
177144 default = flake-utils.lib.mkApp { drv = cApp; };
178145 };
179146
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:
147+ # Build helpers. `c init` uses `copyWith` to honour a per-project
148+ # `container.nix`:
183149 # (builtins.getFlake "github:…/nix-container")
184− # .lib.<system>.copyWith (import ./container.nix).packages
150+ # .lib.<system>.copyWith (import ./container.nix)
185151 lib = {
186152 inherit mkImage;
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;
153+ # skopeo copy app (`skopeo copy nix:<image> "$@"`) for an image built
154+ # from a `{ pkgs, nur }: [ ... ]` function.
155+ copyWith = packages: (mkImage packages).copyTo;
156+ # Like `copyWith`, but also adds the named shell package (when it
157+ # exists in nixpkgs) so `c init` can run the host shell without it
158+ # being listed in `container.nix`.
159+ copyWithShell =
160+ shell: packages:
161+ (mkImage (
162+ { pkgs, nur }:
163+ packages { inherit pkgs nur; } ++ pkgs.lib.optional (pkgs ? ${shell}) pkgs.${shell}
164+ )).copyTo;
196165 };
197166 }
198167 );