Skip to main content

qcode/base/
os.rs

1//! The operating systems a profile's image can be built on: Debian, which every image was built on
2//! before the choice existed and which stays the default, Arch, Ubuntu LTS, and Alpine.
3//!
4//! Each system has a base image of its own, described by a Containerfile of its own, and every
5//! one of them ends with the same user, the same variables and the same directories, because
6//! everything above an image — the mounts, [`super::paths`], a profile's recipe — reads those and
7//! nothing else. What differs is the package manager, the names of the packages, and what a
8//! system cannot carry or run; that is written down here, measured rather than assumed, so the
9//! wizard can say it before anything is built.
10//!
11//! Only a profile's own containers start from the image of its system. A workspace's shell, the
12//! built-in apps that open its files, a sound being played, a backup, and every other helper
13//! container keep starting from the Debian image, [`crate::engine::names::BASE_IMAGE`], whatever
14//! the profiles of the workspace run on: a workspace holds profiles of several systems at once,
15//! and a file has to open the same way in all of them.
16
17use crate::profile::HarnessKind;
18
19/// A system a profile's image is built on.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum Os {
22    /// Debian 13, from the Node project's own Debian image. The default, and what a profile file
23    /// that names no system is.
24    #[default]
25    Debian,
26    /// Arch Linux, rolling.
27    Arch,
28    /// Ubuntu 24.04 LTS.
29    Ubuntu,
30    /// Alpine, on musl rather than glibc; offered, and said to be not recommended.
31    Alpine,
32}
33
34/// Why a harness is not offered on a system: what was measured when it was tried there.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Refusal {
37    /// The harness installs, and the terminal library it opens every shell command through is a
38    /// glibc build that takes the whole program down on musl. Gemini CLI on Alpine: its
39    /// `@lydell/node-pty-linux-x64` has no musl build, and loading it into Alpine's Node ended the
40    /// process with a segmentation fault (measured 2026-09-22 with 0.60.0). Qwen Code, Gemini CLI's
41    /// fork, carries the same library: on Alpine it started, and the first shell command typed into
42    /// it ended the program (measured 2026-09-23 with 0.24.4; loading the library alone faulted).
43    TerminalLibrary,
44    /// The application is a glibc program and the system has no glibc loader, so it does not
45    /// start at all. Antigravity IDE on Alpine: its program asks for `/lib64/ld-linux-x86-64.so.2`,
46    /// which Alpine does not have, and the shell answers `not found` (measured 2026-09-22 with
47    /// 2.5.5).
48    GlibcProgram,
49    /// The application opens a window, and that window has been measured on Debian only: the
50    /// packages it needs, the image's size, and that the window really comes up. On another
51    /// system none of that has been seen, so QCode does not build it there.
52    WindowOnDebianOnly,
53}
54
55/// A program the built-in apps or the image's tools use that a system's image does not carry.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Gap {
58    /// Alpine packages no docx2txt, so a Word document cannot be read as text inside the image.
59    Docx2txt,
60    /// Arch's sox depends on ffmpeg, which is more than half of the image on its own; the image
61    /// carries no sox, so there is no `play` or `soxi` inside it.
62    Sox,
63    /// Ubuntu 24.04 packages no opus format for sox, so sox inside the image reads no `.opus`.
64    SoxOpus,
65}
66
67impl Gap {
68    /// The programs of [`super::apps::PROGRAMS`] this gap takes away, by the name each is looked
69    /// up by, so the live test asks an image for exactly what it promises and no more.
70    #[must_use]
71    pub fn programs(self) -> &'static [&'static str] {
72        match self {
73            Self::Docx2txt => &["docx2txt"],
74            Self::Sox => &["play", "soxi"],
75            Self::SoxOpus => &[],
76        }
77    }
78}
79
80impl Os {
81    /// Every system, in the order the wizard offers them: the default first, the one said to be
82    /// not recommended last.
83    pub const ALL: [Self; 4] = [Self::Debian, Self::Arch, Self::Ubuntu, Self::Alpine];
84
85    /// How the system is written in definition files.
86    #[must_use]
87    pub fn id(self) -> &'static str {
88        match self {
89            Self::Debian => "debian",
90            Self::Arch => "arch",
91            Self::Ubuntu => "ubuntu",
92            Self::Alpine => "alpine",
93        }
94    }
95
96    /// The system written as `id`, if there is one.
97    #[must_use]
98    pub fn parse(id: &str) -> Option<Self> {
99        Self::ALL.into_iter().find(|os| os.id() == id)
100    }
101
102    /// The system's own name and release, which is the same in every language.
103    #[must_use]
104    pub fn display_name(self) -> &'static str {
105        match self {
106            Self::Debian => "Debian 13",
107            Self::Arch => "Arch Linux",
108            Self::Ubuntu => "Ubuntu 24.04 LTS",
109            Self::Alpine => "Alpine 3.24",
110        }
111    }
112
113    /// Whether QCode recommends the system. Alpine is offered for other work and said not to be:
114    /// of the five harnesses, two do not run on it at all (see [`Os::refuses`]).
115    #[must_use]
116    pub fn recommended(self) -> bool {
117        self != Self::Alpine
118    }
119
120    /// The name of the system's base image. Debian's is the name every image had before there
121    /// was a choice, so the images already on a machine stay the ones it finds.
122    #[must_use]
123    pub fn image(self) -> &'static str {
124        match self {
125            Self::Debian => crate::engine::names::BASE_IMAGE,
126            Self::Arch => "qcode/base-arch",
127            Self::Ubuntu => "qcode/base-ubuntu",
128            Self::Alpine => "qcode/base-alpine",
129        }
130    }
131
132    /// The Containerfile the system's base image is built from, carried inside the binary.
133    #[must_use]
134    pub fn containerfile(self) -> &'static str {
135        match self {
136            Self::Debian => super::CONTAINERFILE,
137            Self::Arch => include_str!("../../assets/containerfiles/base-arch.Containerfile"),
138            Self::Ubuntu => include_str!("../../assets/containerfiles/base-ubuntu.Containerfile"),
139            Self::Alpine => include_str!("../../assets/containerfiles/base-alpine.Containerfile"),
140        }
141    }
142
143    /// How large the system's base image is, in megabytes as podman reports them, so the wizard
144    /// can say what is being chosen before it is built.
145    ///
146    /// Measured on 2026-09-22 with podman on images built from the Containerfiles as they are
147    /// now; a change to a Containerfile is a reason to measure again (`podman image inspect
148    /// --format '{{.Size}}'`, and the live test in `live.rs` prints it).
149    #[must_use]
150    pub fn image_mb(self) -> u64 {
151        match self {
152            Self::Debian => 517,
153            Self::Arch => 809,
154            Self::Ubuntu => 508,
155            Self::Alpine => 312,
156        }
157    }
158
159    /// What the system's image does not carry that the Debian image does.
160    #[must_use]
161    pub fn gaps(self) -> &'static [Gap] {
162        match self {
163            Self::Debian => &[],
164            Self::Arch => &[Gap::Sox],
165            Self::Ubuntu => &[Gap::SoxOpus],
166            Self::Alpine => &[Gap::Docx2txt],
167        }
168    }
169
170    /// Why `harness` is not offered on this system, or `None` where it is.
171    ///
172    /// Each answer was measured on the system's own base image, by installing the harness the
173    /// way its recipe does and running it; the variants of [`Refusal`] say what was seen. Where a
174    /// harness is offered here, it was seen to install and start there: on Alpine, Claude Code
175    /// and opencode install their own musl builds and Codex's program is a static musl build; Kimi
176    /// Code CLI is JavaScript and runs its shell commands without a terminal library, and a command
177    /// typed into it ran on all four systems (measured 2026-09-23 with 2.1.0), as one typed into
178    /// Qwen Code did on Debian, Arch and Ubuntu.
179    #[must_use]
180    pub fn refuses(self, harness: HarnessKind) -> Option<Refusal> {
181        match (self, harness) {
182            (Self::Alpine, HarnessKind::GeminiCli | HarnessKind::QwenCode) => Some(Refusal::TerminalLibrary),
183            (Self::Alpine, HarnessKind::AntigravityIde) => Some(Refusal::GlibcProgram),
184            (Self::Arch | Self::Ubuntu, HarnessKind::AntigravityIde) => Some(Refusal::WindowOnDebianOnly),
185            _ => None,
186        }
187    }
188
189    /// The shell command that installs `packages` from the system's own repositories and leaves
190    /// no package lists or downloads behind in the layer. It runs as root.
191    ///
192    /// Arch installs with `-Syu`: it supports no partial upgrade, so installing from a freshly
193    /// read package list means upgrading to it.
194    #[must_use]
195    pub fn install(self, packages: &[&str]) -> String {
196        let packages = packages.join(" ");
197        match self {
198            Self::Debian | Self::Ubuntu => format!(
199                "apt-get update \\\n && apt-get install --yes --no-install-recommends {packages} \\\n \
200                 && rm -rf /var/lib/apt/lists/*"
201            ),
202            Self::Arch => format!(
203                "pacman -Syu --noconfirm --needed {packages} \\\n \
204                 && rm -rf /var/cache/pacman/pkg/* /var/lib/pacman/sync/*"
205            ),
206            Self::Alpine => format!("apk add --no-cache {packages}"),
207        }
208    }
209
210    /// The packages graphify needs from the system: Python 3.10 or later, and pipx to install it
211    /// with, under each system's own names.
212    #[must_use]
213    pub fn python(self) -> &'static [&'static str] {
214        match self {
215            Self::Debian | Self::Ubuntu | Self::Alpine => &["python3", "pipx"],
216            Self::Arch => &["python", "python-pipx"],
217        }
218    }
219
220    /// The packages a Python environment needs from the system: the interpreter, and where the system
221    /// keeps `ensurepip` out of the interpreter itself, the package that brings pip into it. All
222    /// four were measured on their own base image on 2026-09-29: Debian and Ubuntu ship
223    /// `python3-venv` apart from the interpreter, while Arch's and Alpine's `python` and `python3`
224    /// carry it. `pipx` is not among them: this is for an environment of our own making, which
225    /// brings its own pip.
226    #[must_use]
227    pub fn python_venv(self) -> &'static [&'static str] {
228        match self {
229            Self::Debian | Self::Ubuntu => &["python3", "python3-venv"],
230            Self::Arch => &["python"],
231            Self::Alpine => &["python3"],
232        }
233    }
234
235    /// The packages building the Quvyta apps needs from the system, under Quvyta development: a C
236    /// compiler and the tools around it, pkg-config, OpenSSL's headers, git, ssh, curl, jq, the
237    /// process tools (`ps`, `pgrep`) and bash, under each system's own names. Arch's `base-devel`
238    /// carries pkg-config (`pkgconf`) itself.
239    #[must_use]
240    pub fn build_tools(self) -> &'static [&'static str] {
241        match self {
242            Self::Debian | Self::Ubuntu => &[
243                "build-essential",
244                "pkg-config",
245                "libssl-dev",
246                "git",
247                "openssh-client",
248                "curl",
249                "ca-certificates",
250                "jq",
251                "procps",
252                "bash",
253            ],
254            Self::Arch => &["base-devel", "openssl", "git", "openssh", "curl", "jq", "procps-ng", "bash"],
255            Self::Alpine => {
256                &["build-base", "pkgconf", "openssl-dev", "git", "openssh-client", "curl", "jq", "procps-ng", "bash"]
257            }
258        }
259    }
260
261    /// Chromium's package on this system, or `None` where the system has none that runs in a
262    /// container: Ubuntu 24.04's `chromium-browser` is only a way to the snap, and snaps do not
263    /// run inside a container.
264    #[must_use]
265    pub fn chromium(self) -> Option<&'static [&'static str]> {
266        match self {
267            Self::Debian | Self::Arch | Self::Alpine => Some(&["chromium"]),
268            Self::Ubuntu => None,
269        }
270    }
271
272    /// How pipx is told to install `package` outside the home directory, into `home`, with its
273    /// commands in `/usr/local/bin`.
274    ///
275    /// Ubuntu 24.04 packages pipx 1.4.3, which has no `--global` (it came with 1.5, and 1.4.3
276    /// answers `unrecognized arguments: --global`), so there the location is given the older way,
277    /// through `PIPX_HOME` and `PIPX_BIN_DIR`; it lands in the same places.
278    #[must_use]
279    pub fn pipx(self, home: &str, package: &str) -> String {
280        match self {
281            Self::Ubuntu => format!("PIPX_HOME={home} PIPX_BIN_DIR=/usr/local/bin pipx install {package}"),
282            Self::Debian | Self::Arch | Self::Alpine => {
283                format!("PIPX_GLOBAL_HOME={home} PIPX_GLOBAL_BIN_DIR=/usr/local/bin pipx install --global {package}")
284            }
285        }
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::{Gap, Os, Refusal};
292    use crate::base::apps::PROGRAMS;
293    use crate::engine::names::BASE_IMAGE;
294    use crate::profile::HarnessKind;
295
296    #[test]
297    fn every_system_names_the_build_tools_under_its_own_names_and_ubuntu_has_no_chromium() {
298        // Written out, so a tool dropped from a list is a failing test rather than a smaller image.
299        assert_eq!(
300            Os::Debian.build_tools(),
301            [
302                "build-essential",
303                "pkg-config",
304                "libssl-dev",
305                "git",
306                "openssh-client",
307                "curl",
308                "ca-certificates",
309                "jq",
310                "procps",
311                "bash"
312            ]
313        );
314        assert_eq!(Os::Ubuntu.build_tools(), Os::Debian.build_tools());
315        assert_eq!(
316            Os::Arch.build_tools(),
317            ["base-devel", "openssl", "git", "openssh", "curl", "jq", "procps-ng", "bash"]
318        );
319        assert_eq!(
320            Os::Alpine.build_tools(),
321            ["build-base", "pkgconf", "openssl-dev", "git", "openssh-client", "curl", "jq", "procps-ng", "bash"]
322        );
323        for os in [Os::Debian, Os::Arch, Os::Alpine] {
324            assert_eq!(os.chromium(), Some(["chromium"].as_slice()), "{os:?}");
325        }
326        assert_eq!(Os::Ubuntu.chromium(), None, "a snap does not run in a container");
327    }
328
329    #[test]
330    fn every_system_reads_back_as_itself_and_debian_is_the_default() {
331        for os in Os::ALL {
332            assert_eq!(Os::parse(os.id()), Some(os));
333        }
334        assert_eq!(Os::default(), Os::Debian);
335        assert_eq!(Os::ALL[0], Os::Debian, "the default is offered first");
336        assert_eq!(Os::parse("gentoo"), None);
337    }
338
339    #[test]
340    fn debian_keeps_the_image_name_every_image_had_before_the_choice() {
341        // A machine that already has `qcode/base` must find it under the same name, or every
342        // profile made before this release would build a second copy of it.
343        assert_eq!(Os::Debian.image(), BASE_IMAGE);
344        assert_eq!(Os::Debian.image(), "qcode/base");
345        assert_eq!(Os::Debian.containerfile(), crate::base::CONTAINERFILE);
346        let names: Vec<&str> = Os::ALL.map(Os::image).to_vec();
347        for (index, name) in names.iter().enumerate() {
348            assert!(name.starts_with("qcode/base") && name.chars().all(|c| c.is_ascii_lowercase() || "/-".contains(c)));
349            assert!(!names[..index].contains(name), "{name} is the image of two systems");
350        }
351    }
352
353    #[test]
354    fn every_system_starts_from_its_own_distribution_and_brings_node() {
355        let from = |os: Os| {
356            os.containerfile().lines().filter(|line| line.starts_with("FROM ")).map(str::to_owned).collect::<Vec<_>>()
357        };
358        assert_eq!(from(Os::Debian), ["FROM docker.io/library/node:24-trixie-slim"]);
359        assert_eq!(from(Os::Arch), ["FROM docker.io/library/archlinux:latest"]);
360        assert_eq!(from(Os::Ubuntu), ["FROM docker.io/library/ubuntu:24.04"]);
361        assert_eq!(from(Os::Alpine), ["FROM docker.io/library/node:24-alpine"]);
362        // Ubuntu's own Node is 18 and cannot run Claude Code; the Node project's build is copied
363        // in from the same image the Debian base starts from.
364        assert!(
365            Os::Ubuntu
366                .containerfile()
367                .contains("COPY --from=docker.io/library/node:24-trixie-slim /usr/local/bin/node")
368        );
369        assert!(!Os::Ubuntu.containerfile().contains(" nodejs"), "Ubuntu's own Node is never installed");
370        // Arch's own npm 12 runs no install script it was not told to, and Claude Code puts its
371        // program in place in one; the npm bundled with Node 24 is taken instead, as on Ubuntu.
372        assert!(Os::Arch.containerfile().contains("--needed nodejs-lts-krypton ca-certificates"));
373        assert!(
374            Os::Arch
375                .containerfile()
376                .contains("COPY --from=docker.io/library/node:24-trixie-slim /usr/local/lib/node_modules/npm ")
377        );
378    }
379
380    #[test]
381    fn every_system_ends_with_the_same_user_variables_and_directories() {
382        // Everything above the base image reads these and nothing else, so a system whose image
383        // drifted from Debian's here would lose a login or a mount without a word.
384        let tail = |text: &'static str| -> Vec<&'static str> {
385            text.lines()
386                .filter(|line| !line.starts_with('#'))
387                .filter(|line| {
388                    line.starts_with("ENV ")
389                        || line.starts_with("WORKDIR ")
390                        || line.starts_with("USER ")
391                        || line.contains("useradd ")
392                        || line.contains("qcode-open-home")
393                        || line.contains("mkdir -p /usr/local/npm")
394                        || line.contains("chmod 0777")
395                })
396                .map(str::trim)
397                .collect()
398        };
399        let debian = tail(Os::Debian.containerfile());
400        assert!(debian.len() >= 12, "{debian:?}");
401        for os in Os::ALL {
402            assert_eq!(tail(os.containerfile()), debian, "{os:?}");
403        }
404    }
405
406    #[test]
407    fn a_system_installs_with_its_own_package_manager() {
408        assert!(Os::Debian.install(&["python3"]).starts_with("apt-get update"));
409        assert!(Os::Ubuntu.install(&["python3"]).starts_with("apt-get update"));
410        assert!(Os::Arch.install(&["python"]).starts_with("pacman -Syu --noconfirm --needed python"));
411        assert_eq!(Os::Alpine.install(&["python3", "pipx"]), "apk add --no-cache python3 pipx");
412        for os in Os::ALL {
413            assert!(os.containerfile().contains(os.install(&[]).split(' ').next().unwrap_or_default()), "{os:?}");
414        }
415    }
416
417    #[test]
418    fn every_system_carries_an_environment_of_its_own_where_the_interpreter_alone_is_not_enough() {
419        // Written out, so a package dropped from a list is a failing test rather than a build that
420        // cannot make the environment: without `ensurepip` the environment has no pip in it.
421        assert_eq!(Os::Debian.python_venv(), ["python3", "python3-venv"]);
422        assert_eq!(Os::Ubuntu.python_venv(), Os::Debian.python_venv());
423        assert_eq!(Os::Arch.python_venv(), ["python"]);
424        assert_eq!(Os::Alpine.python_venv(), ["python3"]);
425        for os in Os::ALL {
426            assert!(os.python_venv().contains(&os.python()[0]), "{os:?}: it is the interpreter's own name");
427            assert!(!os.python_venv().contains(&"pipx"), "{os:?}: nothing here installs with pipx");
428            assert!(os.install(os.python_venv()).contains(&os.python_venv().join(" ")), "{os:?}");
429        }
430    }
431
432    #[test]
433    fn ubuntus_pipx_is_told_where_to_install_without_the_option_it_does_not_have() {
434        assert!(!Os::Ubuntu.pipx("/opt/pipx", "graphifyy").contains("--global"));
435        assert!(Os::Ubuntu.pipx("/opt/pipx", "graphifyy").contains("PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin"));
436        for os in [Os::Debian, Os::Arch, Os::Alpine] {
437            assert!(os.pipx("/opt/pipx", "graphifyy").contains("pipx install --global graphifyy"), "{os:?}");
438        }
439    }
440
441    #[test]
442    fn what_a_system_lacks_is_named_and_leaves_its_program_out_of_the_image() {
443        for os in Os::ALL {
444            for gap in os.gaps() {
445                for program in gap.programs() {
446                    assert!(
447                        PROGRAMS.iter().any(|command| command.iter().any(|word| word.contains(program))),
448                        "{program} is a program the built-in apps know"
449                    );
450                }
451            }
452        }
453        assert!(!Os::Alpine.containerfile().contains("docx2txt odt2txt"), "Alpine packages no docx2txt");
454        assert!(!Os::Arch.containerfile().lines().any(|line| line.starts_with("    ") && line.contains(" sox")));
455        assert_eq!(Os::Debian.gaps(), []);
456        assert_eq!(Os::Alpine.gaps(), [Gap::Docx2txt]);
457    }
458
459    #[test]
460    fn alpine_refuses_the_three_harnesses_that_do_not_run_on_musl_and_nothing_else() {
461        let refused: Vec<HarnessKind> =
462            HarnessKind::ALL.into_iter().filter(|harness| Os::Alpine.refuses(*harness).is_some()).collect();
463        assert_eq!(refused, [HarnessKind::GeminiCli, HarnessKind::QwenCode, HarnessKind::AntigravityIde]);
464        assert_eq!(Os::Alpine.refuses(HarnessKind::GeminiCli), Some(Refusal::TerminalLibrary));
465        assert_eq!(Os::Alpine.refuses(HarnessKind::QwenCode), Some(Refusal::TerminalLibrary));
466        assert_eq!(Os::Alpine.refuses(HarnessKind::KimiCode), None);
467        assert!(!Os::Alpine.recommended());
468        for os in [Os::Debian, Os::Arch, Os::Ubuntu] {
469            assert!(os.recommended(), "{os:?}");
470            for harness in HarnessKind::TERMINAL {
471                assert_eq!(os.refuses(harness), None, "{os:?} {harness:?}");
472            }
473        }
474        for harness in HarnessKind::ALL {
475            assert_eq!(Os::Debian.refuses(harness), None, "Debian runs everything: {harness:?}");
476        }
477    }
478}