@@ -0,0 +1,211 @@
1+{
2+ description = "A Nix-built OCI image for Apple's `container` CLI";
3+
4+ inputs = {
5+ nixpkgs.url = "github:NixOS/nixpkgs";
6+ flake-utils.url = "github:numtide/flake-utils";
7+ nur.url = "github:nix-community/NUR";
8+ nix2container = {
9+ url = "github:nlewo/nix2container";
10+ inputs.nixpkgs.follows = "nixpkgs";
11+ };
12+ };
13+
14+ outputs =
15+ {
16+ self,
17+ nixpkgs,
18+ flake-utils,
19+ nur,
20+ nix2container,
21+ }:
22+ # Iterate over the systems you might run `nix run` from. The image
23+ # *contents* are always Linux; the system here only decides where the
24+ # `copyTo`/skopeo step executes (e.g. your Mac).
25+ flake-utils.lib.eachSystem
26+ [
27+ "aarch64-darwin"
28+ "x86_64-darwin"
29+ "aarch64-linux"
30+ "x86_64-linux"
31+ ]
32+ (
33+ system:
34+ let
35+ # Map the host system to the Linux system the container targets, so
36+ # `nix run` on a Mac still produces a Linux image.
37+ linuxSystem =
38+ {
39+ aarch64-darwin = "aarch64-linux";
40+ x86_64-darwin = "x86_64-linux";
41+ aarch64-linux = "aarch64-linux";
42+ x86_64-linux = "x86_64-linux";
43+ }
44+ .${system};
45+
46+ arch =
47+ {
48+ aarch64-linux = "arm64";
49+ x86_64-linux = "amd64";
50+ }
51+ .${linuxSystem};
52+
53+ pkgsLinux = import nixpkgs {
54+ system = linuxSystem;
55+ config.allowUnfree = true;
56+ };
57+
58+ # NUR for the container's Linux arch. Pass BOTH `pkgs` and `nurpkgs`
59+ # explicitly: otherwise the unset one defaults to `import <nixpkgs>`,
60+ # which fails in pure flake eval. `pkgs` builds the repo packages, so
61+ # give it `allowUnfree` (charmbracelet.crush is unfree); `nurpkgs` is
62+ # only NUR's own machinery and can stay the plain set.
63+ nurPkgs = import nur {
64+ nurpkgs = pkgsLinux;
65+ pkgs = pkgsLinux;
66+ };
67+
68+ # nix2container instantiated for the host system, so `copyTo` and its
69+ # skopeo run natively here while contents come from `pkgsLinux`.
70+ n2c = nix2container.packages.${system}.nix2container;
71+
72+ # Host package set, for the wrapper app below.
73+ pkgsHost = nixpkgs.legacyPackages.${system};
74+
75+ # skopeo writes the OCI archive's ref.name from the *destination*
76+ # string, not from the image config — so the wrapper app must spell
77+ # these out, else the archive loads as `untagged`.
78+ imageName = "nixos-container";
79+ imageTag = "latest";
80+
81+ # Fish prompt config, copied from smol-nix's mkSmol.nix. Installed at
82+ # /root/.config/fish/config.fish (fish reads $HOME/.config/fish).
83+ fishConfig = pkgsLinux.writeText "config.fish" ''
84+ # No welcome banner.
85+ set -g fish_greeting
86+
87+ # Define the prompt as a function so fish renders it before every
88+ # command; at top level it would run once at startup and be ignored.
89+ function fish_prompt
90+ set -l nix_shell_info
91+ if set -q IN_NIX_SHELL; or set -q IN_NIX_RUN
92+ set nix_shell_info ' ❄️'
93+ end
94+
95+ set -l cwd (prompt_pwd)
96+
97+ set -l bookmark_info
98+ if jj root 2>/dev/null >/dev/null
99+ set -l jj_bookmark (jj log -r 'heads(::@ & bookmarks())' -T 'local_bookmarks ++ "\n"' --no-graph 2>/dev/null | tail -1)
100+ if test -n "$jj_bookmark"
101+ set bookmark_info ' ' (set_color brmagenta) $jj_bookmark (set_color normal)
102+ end
103+ end
104+
105+ echo -n -s (set_color $fish_color_cwd) $cwd (set_color normal) $bookmark_info ' 🏗️' $nix_shell_info '> '
106+ end
107+ '';
108+
109+ # Lay the config out at the path fish reads.
110+ fishRoot = pkgsLinux.runCommand "fish-config-root" { } ''
111+ mkdir -p "$out/root/.config/fish"
112+ cp ${fishConfig} "$out/root/.config/fish/config.fish"
113+ '';
114+
115+ image = n2c.buildImage {
116+ name = imageName;
117+ tag = imageTag;
118+ inherit arch;
119+
120+ # A plain, single-process root filesystem. No NixOS, no systemd.
121+ copyToRoot = pkgsLinux.buildEnv {
122+ name = "root";
123+ paths = with pkgsLinux; [
124+ bashInteractive
125+ fish
126+ fishRoot
127+ openssh
128+ coreutils-full
129+ btop
130+ jujutsu
131+ zellij
132+ micro
133+ helix
134+ nurPkgs.repos.charmbracelet.crush
135+
136+ # LLM tools
137+ git
138+ ripgrep
139+ fd
140+ jq
141+ curl
142+ wget
143+ gnused
144+ gnugrep
145+ gawk
146+ findutils
147+ gnutar
148+ gzip
149+ unzip
150+ less
151+ which
152+ tree
153+ nodejs
154+ python3
155+
156+ # rust
157+ rustc
158+ cargo
159+ rustfmt
160+ clippy
161+
162+ # C toolchain — rustc links final binaries through `cc`/`ld`.
163+ gcc # provides cc/gcc; pulls in binutils (ld) via its closure
164+ ];
165+ pathsToLink = [
166+ "/bin"
167+ "/root"
168+ ];
169+ };
170+
171+ initializeNixDatabase = true;
172+
173+ config = {
174+ # `cmd` (not `entrypoint`): it's the default command and is
175+ # *replaced* by `container run … -- <cmd>`. An entrypoint would be
176+ # prepended instead, so `run … -- /bin/sh` would become
177+ # `/bin/bash /bin/sh` and fail with "cannot execute binary file".
178+ cmd = [ "/bin/fish" ];
179+ env = [
180+ "PATH=/bin"
181+ # fish reads its prompt from $HOME/.config/fish/config.fish.
182+ "HOME=/root"
183+ ];
184+ };
185+ };
186+
187+ # `nix run . -- <path>` writes an OCI archive tagged
188+ # <imageName>:<imageTag>. The raw `image.copyTo` is a passthrough
189+ # (`skopeo copy nix:<image> "$@"`), so an `oci-archive:<path>` with no
190+ # ref loads as `untagged`; this bakes the ref into the destination.
191+ copyToOCI = pkgsHost.writeShellApplication {
192+ name = "copy-to-oci";
193+ text = ''
194+ out="''${1:-image.tar}"
195+ exec ${image.copyTo}/bin/copy-to "oci-archive:$out:${imageName}:${imageTag}"
196+ '';
197+ };
198+ in
199+ {
200+ apps = {
201+ # nix run . -- <path> → OCI archive tagged <name>:<tag>,
202+ # loadable with `container image load -i <path>`.
203+ default = flake-utils.lib.mkApp { drv = copyToOCI; };
204+
205+ # Generic skopeo passthrough for any other destination, e.g.
206+ # nix run .#copyTo -- docker://ghcr.io/me/img:latest
207+ copyTo = flake-utils.lib.mkApp { drv = image.copyTo; };
208+ };
209+ }
210+ );
211+}