# typovrak > Notes on Arch Linux, NixOS, CLI tooling and web development, written up from problems I had to solve myself. Every page and the full text of every published post, newest first. Written by typovrak. The index is at https://typovrak.tv/llms.txt. --- # Home Source: https://typovrak.tv/ Notes on Arch Linux, NixOS, CLI tooling and web development, written up from problems I had to solve myself. I'm Morgan Scholz, known as typovrak (typo + Dvorak), a developer writing about Linux and the CLI tools I use daily. I write up what I've had to work out myself: Arch and NixOS setups, terminal configuration, web development, and how the parts of a stack behave once you look closely. Contact and profiles: https://github.com/typovrak, mailto:typovrak@gmail.com. Every post is also served as markdown at its url with a .txt suffix, and a command-line client asking for a page gets the text rendering instead of the HTML. --- # Star Rune Source: https://typovrak.tv/#star-rune The open-source project I volunteer on. A typing game that teaches touch typing and chemistry through combat, for all ages. I build its open-source website, starrune.net, as a volunteer and typing enthusiast supporting the project. Figures: $15,772 raised, 145 backers, 200+ Discord. Website: https://starrune.net Source of the site I build: https://github.com/typovrak/star-rune-landing-page --- # Open source contributions Source: https://typovrak.tv/#contributions Projects I contribute to beyond my own. - [freeCodeCamp/freeCodeCamp](https://github.com/freeCodeCamp/freeCodeCamp): Open-source codebase and curriculum for learning to code. - [Racketlon17/2d-collision-simulator](https://github.com/Racketlon17/2d-collision-simulator): A JavaScript simulator for elastic and inelastic collisions. No longer maintained. --- # NixOS config Source: https://typovrak.tv/nixos My NixOS configuration, split into one module per tool. My NixOS setup is split into one module per tool, each in its own repo. The modules are grouped below by what they configure. 32 modules, 22 stars and 4 forks across the umbrella repo and every module. Each targets NixOS 26.05 unless the module says otherwise. Core config: https://github.com/typovrak/nixos. The umbrella config that ties every module below together: shell, desktop, development tools, window manager, audio, fonts and theming. Every module lives at https://github.com/typovrak/nixos-. ## CLI tools - `bat`: bat, a cat clone with syntax highlighting, Catppuccin Mocha - `htop`: htop process monitor with a per-user config - `btop`: btop resource monitor, Catppuccin Mocha green - `yazi`: Yazi file manager with themes and color schemes - `fastfetch`: Fastfetch system info with a per-user config - `neofetch`: Neofetch system info with a per-user config (NixOS 24.11) ## Shell - `bash`: Nix's Bash as /bin/bash for a consistent system shell - `zsh`: zsh with plugins, prompt, aliases and an SSH agent ## Terminal - `alacritty`: Alacritty terminal, Catppuccin Mocha green, per-user config - `ghostty`: Ghostty terminal, Catppuccin Mocha green, with custom CSS - `zellij`: Zellij terminal multiplexer ## System - `locale`: Timezone and locale, en_US base with French regional formats - `ssh`: A secured ~/.ssh, OpenSSH and the SSH service - `flatpak`: Flatpak with Flathub and OBS Studio - `nemo`: A secured per-user Nemo config directory - `projects`: A per-user ~/projects directory with secure ownership ## Theming and fonts - `gtk`: GTK 2, 3 and 4 with Catppuccin Mocha green, Papirus icons and cursors - `stylus`: Stylus userstyles (Catppuccin Mocha green) for Chromium and Firefox - `fonts`: JetBrainsMono Nerd Font and emoji, system-wide ## Window manager and desktop - `i3`: i3 window manager with Catppuccin Mocha green styling - `i3lock-color`: i3lock-color screen locker, Catppuccin Mocha green - `polybar`: Polybar status bar, Catppuccin Mocha green, with helper scripts and an OBS indicator - `lightdm`: LightDM GTK greeter with a custom icon, wallpaper and theme accents - `launchers`: Desktop application launchers and MIME type associations - `screenkey`: Screenkey keystroke display with a per-user config ## Development - `nvim`: Neovim with a Lua config, Catppuccin Mocha green theme and core plugins - `git`: Git with a per-user .gitconfig - `lazygit`: LazyGit terminal UI with a per-user config - `gh`: GitHub CLI with a per-user config ## Audio - `audio`: PipeWire audio stack with WirePlumber, ALSA and real-time scheduling - `pavucontrol`: Pavucontrol volume control with per-user settings - `cava`: CAVA console audio visualizer --- # Serving a blog post to curl: ANSI text from a static Astro site Source: https://typovrak.tv/posts/serving-a-blog-to-curl Published: 1 Oct, 2026 Tags: Astro, CLI, Web curl https://typovrak.tv/posts/ gets the post as coloured terminal text and a browser keeps the HTML. One Vercel rewrite on the user-agent, two prerendered text files per post, no function. curl https://typovrak.tv/posts/github-workflow-in-the-terminal prints the post as coloured text, 80 columns wide, with its headings, tables, callouts and code blocks intact. A browser asking for the same URL gets the HTML. The switch is one rewrite rule in the Vercel config, keyed on the user-agent, in front of two prerendered text files per post. No function runs and no JavaScript is involved. I shipped it on 2 September 2026 in one commit (https://github.com/typovrak/typovrak.tv/commit/c6493703c8c4a5251d2b837dc1cc913432a8400e) of 1,201 lines, about half of them tests. The switch is two of those lines. Everything else is the renderer, which is the part that decides whether the output is worth reading. Versions below are Astro 7.0.3 (https://github.com/withastro/astro/releases/tag/astro@7.0.3) with @astrojs/vercel 11.0.3 (https://github.com/withastro/astro/releases/tag/@astrojs/vercel@11.0.3), checked against the live site on 29 September 2026. [recording: The same two URLs in a terminal, then the html coming back for a browser user-agent.] Play it: asciinema play https://typovrak.tv/casts/curl-typovrak-tv.cast ## Table of contents - What does curl get from this site? - How does the server tell curl from a browser? - Why two text files per post? - How does the markdown become terminal text? - Why only sixteen colours? - Who else does this? - Try it ## What does curl get from this site? The post as text with colour, followed by three lines telling you where else it lives: ```console $ curl https://typovrak.tv/posts/github-workflow-in-the-terminal From issue to merge in the terminal: the git and gh workflow 1 Sep, 2026 · 5 min read · CLI, git, GitHub, Open source The issue-to-merge loop is nine commands in git and gh. The catch: the branch linked to an issue does not close it, only a Closes keyword in the PR body does. ──────────────────────────────────────────────────────────────────────────────── Filing an issue, branching, opening the pull request and merging it all run in git and gh, with no browser tab open. The whole loop is nine commands. ... ──────────────────────────────────────────────────────────────────────────────── Online: https://typovrak.tv/posts/github-workflow-in-the-terminal Plain text: https://typovrak.tv/posts/github-workflow-in-the-terminal.txt Read from the top: curl -s https://typovrak.tv/posts/github-workflow-in-the-terminal | less -R ``` [image: The end of a curled post in a terminal, a bash block above the footer and its Read from the top line] (https://typovrak.tv/img/posts/serving-a-blog-to-curl/curl-post-in-terminal-ansi.avif) That footer is there because a terminal drops you at the end of the output, so the last line is the first one you read. It hands you the command that pages the same thing from the top. The colour in it is ANSI escape codes. The terminal reads ESC[1;32m as an instruction, bold green until further notice, and eats the ESC byte instead of drawing it. Anything that is not a terminal sees only the bytes, which is why less needs -R to pass them through: without the flag you read a literal ESC[1;32m where the title should be. The root does the same for the post list: ```console $ curl https://typovrak.tv typovrak Notes on Arch Linux, NixOS, CLI tooling and web development, written up from problems I had to solve myself. Posts 1 Sep, 2026 From issue to merge in the terminal: the git and gh workflow 5 min read https://typovrak.tv/posts/github-workflow-in-the-terminal ... ``` > NOTE 11 KB of coloured text against 110 KB of HTML > > What curl gets is 11 KB, ANSI colour codes included. The same URL in a browser > is 110 KB of HTML, 9.8 times more, and none of that 110 is needed to read the > post. Without the colour the plain .txt drops to 8.5 KB, a thirteenth of the > page. ## How does the server tell curl from a browser? By the User-Agent header, matched against a closed list. The whole mechanism is one route in Vercel's Build Output config (https://vercel.com/docs/build-output-api/configuration), added after the build by scripts/terminal-routes.mjs (https://github.com/typovrak/typovrak.tv/blob/main/scripts/terminal-routes.mjs): ```js const terminalClient = [ { type: "header", key: "user-agent", value: "(curl|[Ww]get|HTTPie|xh)/.*" }, ]; const routes = [ { src: "^/$", has: terminalClient, dest: "/index.ansi.txt" }, { src: "^/posts/([^./]+)$", has: terminalClient, dest: "/posts/$1.ansi.txt" }, ]; ``` has is a condition on the request, so the route only fires when the header matches. dest is a rewrite, so the URL in the terminal stays /posts/ while the file served is /posts/.ansi.txt. The dot in [^./]+ is what keeps /posts/.txt out of the rewrite, so the plain files stay reachable by their own URLs. Four clients are on the list, curl (https://curl.se/docs/manpage.html), wget (https://www.gnu.org/software/wget/), HTTPie (https://httpie.io/) and xh (https://github.com/ducaale/xh), because their default user-agent is a bare name/version. A regex in someone else's matcher is a regex whose semantics you have not read, so I checked it against production: User-agent sent | Served | What it shows ----------------------------------|------------|--------------------------------------------------------- curl/8.12.1 | text/plain | The normal case curl/1.0 foo | text/plain | The trailing .* covers anything after the version foo curl/1.0 | text/html | The match is anchored at the start of the header CURL/8.0, WGET/1.0 | text/plain | Vercel compares case-insensitively, so [Ww] is redundant curl alone, PowerShell/7.4, empty | text/html | No slash, or not on the list, so the HTML > TIP Why the user-agent and not the Accept header > > curl sends Accept: */* and so does wget, so content negotiation has nothing to > negotiate on. The user-agent is the only header a command-line client sends > that says what it is, and a script that wants the HTML anyway overrides it in > one flag, curl -A Mozilla/5.0. [image: The same typovrak.tv URL in a browser and in a terminal, html on the left and text on the right] (https://typovrak.tv/img/posts/serving-a-blog-to-curl/same-url-in-a-browser-and-in-a-terminal.avif) ## Why two text files per post? Because escape codes are the right output for a terminal and the wrong output for a file: curl -o post.md on the coloured version writes ESC[1;32m in front of every heading. So each post is built twice, by two static endpoints (https://docs.astro.build/en/guides/endpoints/#static-file-endpoints) whose .txt filename is also what makes Vercel serve them as text/plain: URL | Palette | For ----------------------------|---------|----------------------------------------------- /posts/.ansi.txt | ansi | What the rewrite serves to a terminal /posts/.txt | plain | curl -o post.md, a pager without -R, or an LLM /index.ansi.txt, /index.txt | both | The post list, same split All four are live for the post above: the coloured one (https://typovrak.tv/posts/github-workflow-in-the-terminal.ansi.txt), the plain one (https://typovrak.tv/posts/github-workflow-in-the-terminal.txt), and the list as index.ansi.txt (https://typovrak.tv/index.ansi.txt) and index.txt (https://typovrak.tv/index.txt). Open the coloured one in a browser and the codes come back as text, a boxed ESC and a [1;32m in front of every heading, which is the whole argument for keeping the plain one. [image: The ansi text file open in a browser, each heading preceded by a boxed ESC and a literal [1;32m] (https://typovrak.tv/img/posts/serving-a-blog-to-curl/ansi-txt-opened-in-a-browser.avif) My first plan, written in a TODO before any code, was one file and a ?raw query string to switch the colour off. That cannot work on a static host, because a query string never reaches a file on disk. Two files cost nothing at build time and each one gets a stable URL, so that is what shipped. ## How does the markdown become terminal text? By walking the same tree Astro walks. Its render() hands back HTML and nothing else, and HTML has already dropped the structure a terminal needs, while parsing the markdown a second time with my own parser would work right up to the day a table or a callout parsed differently from the site. So postTree.ts (https://github.com/typovrak/typovrak.tv/blob/main/src/utils/postTree.ts) builds the processor Astro builds, and slips in a remark plugin whose only job is to hold on to the root: ```ts let tree: Node | undefined; const capture = () => (root: Node) => { tree = stripMdx(root); }; const processor = await createMarkdownProcessor({ gfm: true, smartypants: false, syntaxHighlight: false, remarkPlugins: options.mdx ? [remarkMdx, capture] : [capture], }); await processor.render(body); ``` smartypants: false is not cosmetic. With it on, a --rebase in prose becomes an en dash, and a reader pasting that command out of their terminal gets a broken command. remarkMdx is in the chain for .mdx files only: the MDX grammar reads {braces} in prose as a JavaScript expression, and one of my .md posts carries ${pkgs.prisma-engines_6} in a Nix snippet. From there terminal.ts (https://github.com/typovrak/typovrak.tv/blob/main/src/utils/terminal.ts) walks the tree with no astro:* import, which is what keeps it under vitest. The parts that took more than one attempt: - Paragraphs wrap at 80 columns and a word keeps its style across the break. Code blocks never wrap, so a long command scrolls sideways instead of being sawn in half. - Tables are padded to their widest cell, measured after stripping the escape codes, or a coloured cell throws the column out of line. - A > [!TIP] callout becomes a labelled quote under a │ bar, and the ## Table of contents heading becomes the list of headings that follow it. Two test files, 541 lines for 651 lines of renderer, and I broke each rule in turn to watch its test go red. The rewrite is the one thing they cannot reach, since only Vercel reads that config, so it gets checked on a preview deploy with a browser and curl on the same URL. ## Why only sixteen colours? Because the reader's own terminal theme should pick the shade. The palette is nine SGR codes (https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters) and nothing from the 256-colour or truecolour ranges: Element | Code | Meaning ----------------------|------|----------------- Title, h1, h2 | 1;32 | bold, green h3 and deeper, strong | 1 | bold Emphasis | 3 | italic Inline code | 36 | cyan Link | 4;32 | underline, green Metadata, URLs, rules | 2 | dim Callout label | 32 | green Warning label | 33 | yellow [image: The same index output under Catppuccin Mocha and Latte, the bold green title taking a different shade from each] (https://typovrak.tv/img/posts/serving-a-blog-to-curl/same-post-under-two-terminal-themes.avif) Code 32 leaves the shade to the theme, which is Catppuccin Mocha's on my machine and yours on yours. A truecolour value would reproduce the site's exact accent and then sit unreadable on somebody's light background. The plain palette maps those same slots to markdown instead, # for headings and fences for code, and that paid off a day later: /llms.txt (https://typovrak.tv/llms.txt) and /llms-full.txt (https://typovrak.tv/llms-full.txt) are the same renderer called with that palette, because its output was already valid markdown. ## Who else does this? wttr.in (https://wttr.in/) and cheat.sh (https://cheat.sh/), both by Igor Chubin, are where I got the idea. Checked on 29 September 2026, each answers text/plain to a curl/8.12.1 user-agent and text/html to a Firefox one on the same URL, and wttr.in goes further by painting the weather in 256 colours. rate.sx (https://rate.sx/), from the same author, switches too, but it labels the text text/html in both cases, so its Content-Type tells you nothing and the body is the only place the switch shows. [image: curl wttr.in in a terminal, ascii clouds and three days of forecast in colour-coded box-drawing tables] (https://typovrak.tv/img/posts/serving-a-blog-to-curl/curl-wttr-in-256-colours.avif) Those are services with a server behind them. A static site gets there with a rewrite rule and two extra files per page. ## Try it ```bash curl https://typovrak.tv curl https://typovrak.tv/posts/github-workflow-in-the-terminal | less -R curl -o post.md https://typovrak.tv/posts/github-workflow-in-the-terminal.txt curl -A Mozilla/5.0 https://typovrak.tv/posts/github-workflow-in-the-terminal | head -3 ``` The first two read in the terminal. The third saves the markdown without a single escape code. The fourth is how a script gets the HTML back, since the switch is keyed on nothing but the user-agent. > WARNING Every one of them needs the https:// > > Drop it and curl asks for port 80, Vercel answers a 308 to the https URL, and > Redirecting... is the whole of what you read, unless you add -L. A trailing > slash on the path 308s the same way, since the site emits none. --- # Running freeCodeCamp on NixOS: the shell.nix Prisma needs Source: https://typovrak.tv/posts/freecodecamp-on-nixos-prisma Published: 15 Sep, 2026 Tags: NixOS, Prisma, Open source Prisma ships no engine for NixOS, so pnpm i on freeCodeCamp dies in postinstall. A 16-line shell.nix fixes it, as long as the nixpkgs engine matches the Prisma version. A bot closed the PR. Prisma publishes no engine binary for NixOS, so pnpm i on freeCodeCamp (https://github.com/freeCodeCamp/freeCodeCamp) dies in the api postinstall step before you have written a single line. The fix is a shell.nix at the root of the repo that hands Prisma the engines from nixpkgs, then nix-shell before pnpm i. Everything after that behaves like any other machine. I hit this on 12 July 2026 while setting up a freeCodeCamp checkout, worked it out, and opened issue #68755 (https://github.com/freeCodeCamp/freeCodeCamp/issues/68755) with the fix, plus PR #68756 (https://github.com/freeCodeCamp/freeCodeCamp/pull/68756) to put it in their troubleshooting docs. A bot closed both three hours later. So the fix lives here instead. Every command and every version below comes from my own machine: NixOS 26.05 (Yarara) (https://nixos.org/blog/announcements/2026/nixos-2605), nixpkgs at 8f0500b9 (https://github.com/NixOS/nixpkgs/tree/8f0500b9660505dc3cb647775fe9a978a74b5283) , freeCodeCamp on Prisma 6.19.3 (https://github.com/prisma/orm/releases/tag/6.19.3). [recording: pnpm i dying on the 404, then the same install passing inside nix-shell.] Play it: asciinema play https://typovrak.tv/casts/freecodecamp-prisma-nixos.cast ## Table of contents - Why does pnpm i fail on freeCodeCamp under NixOS? - What goes in the shell.nix? - Why prisma-engines_6 and not prisma-engines? - Do you still need the shellHook? - How do you run it? - Which pnpm actually runs? - Why has Prisma not fixed this? - What happened to the issue? - Where does the fix live now? ## Why does pnpm i fail on freeCodeCamp under NixOS? api/package.json runs prisma generate as its postinstall script, and Prisma has no engine build for the linux-nixos target. Run the CLI outside a Nix shell and it says so in three stages: ```console $ node node_modules/prisma/build/index.js --version prisma:warn Prisma failed to detect the libssl/openssl version to use, and may not work as expected. Defaulting to "openssl-1.1.x". Please manually install OpenSSL and try installing Prisma again. Warning Precompiled engine files are not available for nixos, please provide the paths via environment variables, see https://pris.ly/d/custom-engines Error: Failed to fetch sha256 checksum at https://binaries.prisma.sh/all_commits/c2990dca591cba766e3b7ef5d9e8a84796e47ab7/linux-nixos/schema-engine.gz.sha256 - 404 Not Found ``` Read it backwards and the whole story is there. Prisma could not find a system OpenSSL, because on NixOS libraries do not sit in /usr/lib, they sit in the store. It then detected the platform as nixos and admitted it has no precompiled engine for it. Finally it tried to download one anyway, from a URL that has never existed, and got a 404. That commit hash, c2990dca591cba766e3b7ef5d9e8a84796e47ab7, is the engine revision pinned to Prisma 6.19.3. Keep it in mind, it is the reason the next section is fussy about versions. Nothing here is a freeCodeCamp bug. Any project with Prisma in its dependency tree fails the same way on NixOS. ## What goes in the shell.nix? The file declares four packages and exports four environment variables. Drop this at the root of the repo as shell.nix: ```nix { pkgs ? import {} }: pkgs.mkShell { buildInputs = [ pkgs.nodejs_24 pkgs.pnpm pkgs.openssl pkgs.prisma-engines_6 ]; shellHook = '' export PRISMA_QUERY_ENGINE_LIBRARY="${pkgs.prisma-engines_6}/lib/libquery_engine.node" export PRISMA_QUERY_ENGINE_BINARY="${pkgs.prisma-engines_6}/bin/query-engine" export PRISMA_SCHEMA_ENGINE_BINARY="${pkgs.prisma-engines_6}/bin/schema-engine" export PRISMA_FMT_BINARY="${pkgs.prisma-engines_6}/bin/prisma-fmt" ''; } ``` nodejs_24 and pnpm come from what freeCodeCamp asks for in its root package.json ("node": ">=24", "pnpm": ">=10"). openssl silences the libssl warning. prisma-engines_6 is the Rust side of Prisma, built by nixpkgs from source, and it is the exact thing the CLI was trying to download. Each variable points at a real file in the store, and you can go and look at them: ```console $ ls $(nix-build '' -A prisma-engines_6 --no-out-link)/{bin,lib} bin: prisma-fmt query-engine schema-engine lib: libquery_engine.node ``` Prisma documents all four in its environment variables reference (https://www.prisma.io/docs/orm/v6/reference/environment-variables-reference#prisma_query_engine_library) : Variable | What it points at | Used for ----------------------------|--------------------------|---------------------------------------------------------------------------- PRISMA_QUERY_ENGINE_LIBRARY | lib/libquery_engine.node | The Node-API engine @prisma/client loads at runtime to talk to the database PRISMA_QUERY_ENGINE_BINARY | bin/query-engine | The standalone query engine, used by the binary engine type and by Studio PRISMA_SCHEMA_ENGINE_BINARY | bin/schema-engine | Migrations, db push, introspection PRISMA_FMT_BINARY | bin/prisma-fmt | Formatting and validating schema.prisma > TIP Nix evaluates ${} inside a '' string > > In the shellHook above, ${pkgs.prisma-engines_6} is replaced by Nix with a > store path before bash ever sees the line. For a shell variable in there, > escape it as ''${HOME}. Forgetting this is the classic first Nix bug, and the > error message does not help. ## Why prisma-engines_6 and not prisma-engines? The engine version has to match the Prisma version in the project, and the unsuffixed attribute is a different major. As of nixpkgs 26.05: Attribute | Version | Built from -----------------|---------|----------------- prisma-engines | 7.8.0 | refs/tags/7.8.0 prisma-engines_6 | 6.19.3 | refs/tags/6.19.3 prisma-engines_7 | 7.8.0 | refs/tags/7.8.0 freeCodeCamp pins prisma and @prisma/client to 6.19.3 in api/package.json, so prisma-engines_6 matches it to the patch. Reach for prisma-engines instead and you get 7.8.0, whose engine hash is not the c2990dca... the CLI wants. I tried it. Prisma ignores the mismatched engine and goes back to downloading: ```console Warning Precompiled engine files are not available for nixos, please provide the paths via environment variables, see https://pris.ly/d/custom-engines Error: Failed to fetch sha256 checksum at https://binaries.prisma.sh/all_commits/c2990dca591cba766e3b7ef5d9e8a84796e47ab7/linux-nixos/libquery_engine.so.node.sha256 - 404 Not Found ``` It is the same 404 with the same hash. So the procedure is to read the prisma version in package.json and pick the nixpkgs attribute with the same major, then check on search.nixos.org (https://search.nixos.org/packages?channel=unstable&query=prisma-engines) that the minor lines up too. When no attribute lines up, that project needs a nixpkgs pin. ## Do you still need the shellHook? On nixpkgs 26.05 the shellHook is redundant. prisma-engines_6 ships a setup hook that exports all four variables, and nix-shell sources it: ```console $ cat $(nix-build '' -A prisma-engines_6 --no-out-link)/nix-support/setup-hook export PRISMA_SCHEMA_ENGINE_BINARY=".../bin/schema-engine" export PRISMA_QUERY_ENGINE_BINARY=".../bin/query-engine" export PRISMA_QUERY_ENGINE_LIBRARY=".../lib/libquery_engine.node" export PRISMA_FMT_BINARY=".../bin/prisma-fmt" ``` A shell.nix with nothing but buildInputs = [ pkgs.prisma-engines_6 ]; gives you a shell with all four already set. The hook landed on 15 June 2025 (#416928 (https://github.com/NixOS/nixpkgs/pull/416928)) and reached the 25.05 release branch the same day, so every channel newer than that carries it. A nixpkgs pinned to an older revision does not. I only checked because I had been copying that shellHook between projects for months, assuming it was required. I still ship it. Four lines is a cheap price for a file that reads the same to someone who has never met a Nix setup hook, and it keeps working against a pin from before the hook existed. ## How do you run it? Enter the shell first, install second. The order matters, because the postinstall script is what needs the engines: ```bash nix-shell pnpm i ``` Then ask Prisma, from the api directory, whether it agrees: ```console $ node node_modules/prisma/build/index.js --version prisma : 6.19.3 @prisma/client : 6.19.3 Computed binaryTarget : linux-nixos Node.js : v24.16.0 Query Engine (Node-API) : libquery-engine 0000000000000000000000000000000000000000 (at ../../../../../nix/store/...-prisma-engines_6-6.19.3/lib/libquery_engine.node, resolved by PRISMA_QUERY_ENGINE_LIBRARY) Schema Engine : schema-engine-cli 0000000000000000000000000000000000000000 (at ../../../../../nix/store/...-prisma-engines_6-6.19.3/bin/schema-engine, resolved by PRISMA_SCHEMA_ENGINE_BINARY) Default Engines Hash : c2990dca591cba766e3b7ef5d9e8a84796e47ab7 ``` resolved by PRISMA_QUERY_ENGINE_LIBRARY is the line that says it worked. The all-zero hashes are normal: nixpkgs builds from the release tag without stamping a git revision into the binary, and Prisma skips its hash check when you hand it an explicit path. The five ../ in front of the store path are Prisma printing it relative to api/, which looks odd and changes nothing. From here the rest of the contributing guide (https://contribute.freecodecamp.org/) applies unchanged. Seed the database and run pnpm develop. [image: The freeCodeCamp client served from localhost:8000 after pnpm develop, with the banner that says the API has no user session yet] (https://typovrak.tv/img/posts/freecodecamp-on-nixos-prisma/freecodecamp-pnpm-develop-running-on-nixos.avif) > WARNING Add shell.nix to your global gitignore > > shell.nix is your environment, and freeCodeCamp's .gitignore has no reason to > carry it. Put it in ~/.config/git/ignore so it never shows up in git status on > any repo you do this to. ## Which pnpm actually runs? pnpm 10.33.3, the version freeCodeCamp pins, even though Nix installed 11.9.0. The root package.json sets "packageManager": "pnpm@10.33.3", and pnpm 10 and up honour that field by fetching the pinned version and handing over to it: ```console $ which pnpm /nix/store/gfrj5gwr59daw5qi72phra770mi0xvh1-pnpm-11.9.0/bin/pnpm $ pnpm --version [WARN] The "pnpm" field in package.json is no longer read by pnpm. The following keys were ignored: "pnpm.onlyBuiltDependencies". See https://pnpm.io/settings for the new home of each setting. 10.33.3 ``` The warning comes from pnpm 11, printed before it hands over, and the version number from pnpm 10. Knowing this saves you an evening of wondering why your carefully declared pnpm is not the one producing the lockfile. It also means one part of the install reaches outside the store, which dents the purity a NixOS user came for. pnpm 11 removed the setting (https://github.com/pnpm/pnpm/releases/tag/v11.0.0) that used to switch the handover off. What remains is pnpm with current, which runs the Nix pnpm for a single command, at the cost of running a different version from every other contributor: ```console $ pnpm with current --version 11.9.0 ``` [image: which pnpm, pnpm --version and pnpm with current in one shell, the handover from 11.9.0 down to 10.33.3 and back] (https://typovrak.tv/img/posts/freecodecamp-on-nixos-prisma/pnpm-11-from-nix-store-handing-over-to-pnpm-10.avif) ## Why has Prisma not fixed this? The gap has been open for years and there is no sign of it closing. Three threads land on the same workaround: - Running prisma on NixOS (https://github.com/prisma/orm/discussions/3120), the original discussion on Prisma's repository. - Unable to use Prisma on NixOS (https://discourse.nixos.org/t/unable-to-use-prisma-on-nixos/54082) on the NixOS Discourse, which is where most people find the four variables. - prisma/orm#29150 (https://github.com/prisma/orm/issues/29150), opened 6 February 2026 and still open: Prisma 7 still tries to download Rust binaries on NixOS, and still needs a shell.nix to point at the engines. Prisma has since renamed its repository from prisma/prisma to prisma/orm, and the old issue URLs return a 404 rather than a redirect, so the links above are the new ones. [image: Issue #29150 on prisma/orm, open and still labelled unconfirmed, reporting the same libssl warning on Prisma 7.3.0] (https://typovrak.tv/img/posts/freecodecamp-on-nixos-prisma/github-prisma-orm-issue-29150-still-open.avif) The underlying reason is that Prisma distributes prebuilt engines per platform, and a "platform" for them means a libc plus an OpenSSL version at a known filesystem path. NixOS has neither at a predictable path, so it does not fit the matrix. Pointing at store paths is the only interface that design leaves open. The upside is that nixpkgs already builds those engines from source and keeps them versioned, so the work is done. It only has to be wired up per project. ## What happened to the issue? camper-chan, a bot, closed it as not planned at 18:37 UTC on 12 July 2026, three hours after I opened it. The message is a form letter: the issue is not on the roadmap, request a reopen if you disagree. Eleven seconds later the same bot closed the PR with the matching letter, saying it had been reviewed and would not be merged. Twelve seconds after that, a GitHub Action locked the PR as resolved and limited the conversation to collaborators, so the close and the lock together took twenty-three seconds. Another Action had already flagged the PR minutes after it was opened, because its linked issue was not labelled as open for contribution. The linked issue was the one I had filed six minutes earlier. [image: Issue #68755 closed as not planned, the form letter sitting right under the pnpm i that had finished inside nix-shell] (https://typovrak.tv/img/posts/freecodecamp-on-nixos-prisma/github-freecodecamp-issue-68755-closed-as-not-planned-by-camper-chan.avif) [image: Pull request #68756, one commit and sixteen added lines, closed unmerged and then locked to collaborators] (https://typovrak.tv/img/posts/freecodecamp-on-nixos-prisma/github-freecodecamp-pr-68756-closed-by-camper-chan.avif) The issue itself was never locked, so that is where I disagreed, in writing, on 16 July 2026. I laid out the three upstream threads and explained that NixOS is declarative, with a dev shell as its way of declaring development dependencies. The proposed shell.nix, I pointed out, only declares binaries the project already needs. Then I asked a direct question: does freeCodeCamp want a frictionless setup for NixOS contributors, or is NixOS too niche to support, with contributors on their own? Either answer would have been fine. A project that size has to draw a line somewhere, and "we do not support NixOS" is a legitimate place to draw it, as long as somebody says it. Nobody did. On 17 July I followed up, pinging a maintainer by name, to ask whether a human had seen the thread at all, since I could not reopen it myself to make it visible. That went unanswered too. As I write this in September 2026, the issue and the PR are both closed and nothing has moved since 12 July. [image: The reopen request of 16 July and the follow-up of 17 July, the last two comments the thread ever got] (https://typovrak.tv/img/posts/freecodecamp-on-nixos-prisma/github-freecodecamp-issue-68755-reopen-request-unanswered.avif) freeCodeCamp is a charity with maintainers stretched thin, and triage bots exist because the alternative is a backlog nobody can read. Throughput explains the outcome better than malice does. It still leaves a gap: the bot can close but cannot judge, and a first patch that disappears into that gap teaches a contributor something discouraging about how contributing goes. The timing is the part that stings. The PR with the tested fix was open six minutes after the issue, and sat there for three hours before the bot closed both. ## Where does the fix live now? The fix lives in this post, indexed and linkable, for the next person whose pnpm i explodes on a 404 for a file that was never built. A closed issue does not delete what you worked out to write it, and a page a search engine can reach does the job the troubleshooting section would have done. If you are on NixOS and something refuses to install, the answer usually has this shape. Work out what the installer wanted to download, then find the nixpkgs attribute that already builds it and point the tool at the store path. Write it down somewhere public, because the next person is going to hit the same 404. The diff is 16 lines, and it is still sitting in the closed PR (https://github.com/freeCodeCamp/freeCodeCamp/pull/68756) if freeCodeCamp ever wants it. --- # From issue to merge in the terminal: the git and gh workflow Source: https://typovrak.tv/posts/github-workflow-in-the-terminal Published: 1 Sep, 2026 Tags: CLI, git, GitHub, Open source The issue-to-merge loop is nine commands in git and gh. The catch: the branch linked to an issue does not close it, only a Closes keyword in the PR body does. Filing an issue, branching, opening the pull request and merging it all run in git and gh, with no browser tab open. The whole loop is nine commands. I kept this as a public gist (https://gist.github.com/typovrak/8034fe822905c3febbe4c3c9a84f065d) for two years and run it on every repo I touch. This post is that gist, with the flags that skip the prompts and the one line that makes the merge close the issue for you. Everything below is gh 2.96.0 (https://github.com/cli/cli/releases/tag/v2.96.0) and git 2.54.0 (https://github.com/git/git/releases/tag/v2.54.0), run from inside a repository you can push to. [recording: The whole loop in one terminal, from gh issue create to a synced main.] Play it: asciinema play https://typovrak.tv/casts/gh-issue-pr.cast ## Table of contents - The whole loop - What actually closes the issue? - Which merge strategy? - Does this work on a repo you cannot push to? ## The whole loop ```bash gh issue create # file it, note the number (say 32) gh issue develop 32 --checkout # branch linked to #32, checked out locally # write the code, then: git add . git commit -m 'feat(translation): this is an example' git push gh pr create --fill --body "Closes #32" # open the PR, keyword set gh pr merge --rebase --delete-branch # rebase onto main, close #32, drop the branch git switch main && git pull --prune # bring the merge home, tidy stale refs ``` Run gh auth login once and every command after that runs as you. gh works out which repository you mean from the git remote of the current directory, so all of this happens from inside the checkout. gh issue create prints the URL of the issue it filed, and the number on the end of that URL is the 32 every later command refers to. To pick up an issue that already exists instead, list them and read the number off the left column: ```bash gh issue list ``` Here it is on olivierlambert/calrs (https://github.com/olivierlambert/calrs), a project with rather more open issues than this site has. The #208 on the first row is the number gh issue develop wants: [image: gh issue list on the calrs repository, its 23 open issues in a table of number, title, labels and age] (https://typovrak.tv/img/posts/github-workflow-in-the-terminal/calrs-repository-gh-issue-list-cli.avif) Three flags do the work of the prompts. --checkout (https://cli.github.com/manual/gh_issue_develop) creates the linked branch and switches to it in one go, so the branch name never has to be typed. --fill (https://cli.github.com/manual/gh_pr_create) takes the PR title and body from your commits. --rebase --delete-branch (https://cli.github.com/manual/gh_pr_merge) skips the merge menu and removes the branch from both your machine and the remote. The git push in that block needs no -u. gh created the branch on the remote before checking it out, so its upstream is already set. A branch you make yourself with git switch -c has none, which is why the fork section below pushes with -u origin . One setting covers both cases: > TIP Stop typing -u > > ```bash > git config --global push.autoSetupRemote true > ``` > > Available since git 2.37, it makes the first push on a new branch a plain git > push. It is why -u never shows up in my own shell history. ## What actually closes the issue? Only a closing keyword in the PR body, merged into the default branch, closes an issue. gh issue develop links the branch to the issue, and the link shows up in the issue's Development section. That is enough to make you assume the merge closes it. It does not. GitHub documents auto-close for a closing keyword (https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue) landing on the repository's default branch, and for nothing else. So the keyword goes in the PR body: ```bash gh pr create --fill --body "Closes #32" ``` The two flags are not redundant. --fill takes the title and the body from your commits, then --body overwrites the body it just filled in and leaves the title alone, which gh documents (https://cli.github.com/manual/gh_pr_create). So the PR gets a title from your commits, exactly Closes #32 as its body, and no prompt. Drop --fill and gh has no title, so it stops to ask for one. Which title depends on how many commits the branch carries. With a single commit, --fill takes that commit's subject. With several, it takes the branch name and turns the dashes into spaces, so a branch named by gh issue develop gives 32 adding french translation, which is the title #33 (https://github.com/typovrak/typovrak.tv/pull/33) below ended up with. Pass --fill-first to use the first commit's subject either way. close, closes, closed, fix, fixes, fixed, resolve, resolves and resolved all work, each followed by # and the number, anywhere in the body. To keep the commit text in the body as well, drop --body and write the keyword into the commit message instead. > WARNING The keyword only fires on the default branch > > Closes #32 in a PR that targets a release branch or a develop branch links the > issue and leaves it open. GitHub documents auto-close for the repository's > default branch and nothing else, so a team that merges through a staging > branch closes its issues by hand no matter what the PR body says. Here is issue #32 (https://github.com/typovrak/typovrak.tv/issues/32) on this repository, closed by the merge with nobody touching the issue itself: [image: Issue #32 on typovrak.tv, closed automatically by the pull request that merged] (https://typovrak.tv/img/posts/github-workflow-in-the-terminal/github-typovrak.tv-issue-32-closed-with-cli.avif) ## Which merge strategy? Run gh pr merge with no strategy flag and it asks. The three differ only in what lands on the default branch: Strategy | Flag | What lands on main | Resulting history -------------|----------|-------------------------------------|--------------------------------- Rebase | --rebase | your commits, replayed onto main | linear, every commit kept Squash | --squash | one new commit combining the branch | linear, one commit per PR Merge commit | --merge | your commits plus a merge commit | keeps the PR as a visible bubble > TIP Default to rebase > > --rebase is the one to reach for. Your commits land on main one by one, so > every message you wrote stays in the tree and git log reads as a straight > line. Squash collapses all of that into a single commit, which is only worth > it when the branch is a pile of wip and typo commits you would rather not > keep. Take the merge commit when you specifically want the PR boundary to stay > visible. Pull request #33 (https://github.com/typovrak/typovrak.tv/pull/33) is what that looks like once landed: [image: Pull request #33 on typovrak.tv, shown as merged after a rebase onto main] (https://typovrak.tv/img/posts/github-workflow-in-the-terminal/github-typovrak.tv-pr-33-merged-with-cli.avif) And the commit history for that month (https://github.com/typovrak/typovrak.tv/commits/main/?since=2026-08-01&until=2026-09-01) is a straight line, every commit from the branch still there under its own message: [image: The typovrak.tv commit history on main after the rebase, linear with no merge commit] (https://typovrak.tv/img/posts/github-workflow-in-the-terminal/github-typovrak.tv-repository-after-rebase.avif) ## Does this work on a repo you cannot push to? Yes, with two changes. You cannot create a linked branch on a repo you lack push access to, so fork it, branch the ordinary way, and leave the merge to the maintainer: ```bash gh repo fork owner/repo --clone cd repo git switch -c fix-stale-search # work, commit, then push to your fork: git push -u origin fix-stale-search gh pr create --fill --body "Closes #123" ``` gh notices the clone is a fork and defaults the PR base to the upstream default branch, with your fork's branch as the head. Closes #123 still closes their issue when they merge it. --- # My Arch Linux setup (2024, before I switched to NixOS) Source: https://typovrak.tv/posts/arch-linux-2024-setup Published: 15 Aug, 2026 Tags: Arch Linux, GNOME, zsh, Docker, CLI The exact setup I built on a fresh 2024 Arch install: GNOME, yay, my package list, zsh with zinit and Powerlevel10k, Docker, SSH and Git. Since rebuilt as a declarative NixOS config. This is the exact setup I built on a fresh Arch install in 2024: GNOME, yay, my package list, a zsh config with zinit and Powerlevel10k, then Docker, SSH and Git. I have since rebuilt the whole thing as a declarative NixOS config (https://typovrak.tv/nixos), so read this as a time capsule, proof of how far the setup has come. Every command below is one I actually ran. I ran it on two machines: a ROG Strix G731GU, and a 500-euro 15-inch Asus that shipped with 8 GB of RAM. Windows barely codes on the Asus. Arch runs it like a much pricier laptop, within the limits of what 8 GB allows. That gap is most of why I left Windows behind. What I run today grew straight out of this. It has since moved to NixOS, picked up far more tools, swapped several for more modern and more secure ones, and gained an end-to-end Catppuccin Mocha green theme. This is where it started. ## Table of contents - Which desktop did I run? - Why yay instead of plain pacman? - Which packages do I install first? - How do I run Docker without sudo? - How do I make zsh the default shell? - Adding a Nerd Font - What is in my zsh config? - How do I enable SSH? - How do I configure Git? - Which apps do I install from Flathub? - Which GNOME tweaks do I always make? - What would I do differently? - What changed since? ## Which desktop did I run? GNOME, because it is simple, fast, and needs almost no tweaking to do what I want. I would rather spend the effort on my terminal than on the window manager. [image: Arch Linux running the GNOME desktop] (https://typovrak.tv/img/posts/arch-2024-setup/arch-linux-gnome-desktop.avif) ## Why yay instead of plain pacman? yay installs packages straight from the AUR, the community-maintained collection that makes the official repos look small. It also updates your whole system in one command, so you stop babysitting mirror lists every two weeks. Start with git and the build tools: ```bash sudo pacman -S --needed base-devel git ``` Clone yay, build it, done: ```bash git clone https://aur.archlinux.org/yay.git cd yay makepkg -si ``` Check it landed: ```bash yay --version ``` Then bring the whole system up to date: ```bash yay -u ``` One habit worth keeping: update weekly, not once a quarter. A three-month backlog is how you end up with five apps breaking at the same time. > BUG Docker sulks after a system update > > The fix is gloriously dumb: reboot and it behaves again. [image: Docker daemon error after a system update, before a reboot] (https://typovrak.tv/img/posts/arch-2024-setup/docker-daemon-error-before-reboot.webp) ## Which packages do I install first? These 19 packages go on every Linux machine I use, in one command: ```bash sudo pacman -S chromium firefox flatpak docker docker-compose zsh fzf zoxide neovim npm nodejs gdu lazygit earlyoom rpi-imager tmux filezilla gedit ``` Here is why each one earns its place: Package | Why ---------------|--------------------------------------------------------------------------------------- chromium | My main browser, for the performance. firefox | Only to test my sites and tweak network requests. flatpak | Access Flathub (https://flathub.org) apps from GNOME Software. docker | My dev and deploy tool of choice. docker-compose | Multi-container services on top of Docker. zsh | Highlighting, autocompletion and more in the terminal. fzf | Fuzzy finding for files and commands. zoxide | Jump to directories by history and frequency. neovim | My future editor, the day I finally find the courage to switch. npm | Installing Node packages and building locally. nodejs | The language I build sites and APIs with. gdu | See which folders eat the most disk. lazygit | Reading commit history, since I never touch git outside the terminal. earlyoom | Watch RAM in real time and kill a runaway before the system freezes. rpi-imager | Flash bootable cards for my Raspberry Pi. tmux | Terminal sessions, essential alongside neovim. filezilla | SFTP client, for docs aimed at non-technical people. gedit | Because vim refuses to cooperate with FileZilla's edit feature. vim > everything else. Most of the list explains itself. A few picks deserve a word, folded away so the walkthrough keeps moving. The reasoning behind a few of them docker is my single tool for install, development and production. One file pins a project to a specific Node version and puts its services on a private network, exposing only a separate external proxy network to the reverse proxy. The database sits on the private network alone, so nothing outside the VPS can reach it. That enforces good architecture for free: nothing stops a beginner from calling a database straight from React until the network does. [image: A production docker-compose file with an external proxy network and a private app network, keeping the database unreachable from outside the VPS] (https://typovrak.tv/img/posts/arch-2024-setup/docker-compose-from-real-project-in-production.avif) chromium over firefox for daily driving. Chromium is smoother and faster, and Firefox deleted its old promise never to sell your personal data (https://github.com/mozilla/bedrock/commit/d459addab846d8144b61939b7f4310eb80c5470e) . I keep Firefox for one niche move: replaying an XHR request to walk past a front-end form validation. [image: Firefox devtools editing and resending an XHR request to walk past a front-end form validation] (https://typovrak.tv/img/posts/arch-2024-setup/firefox-xhr-edit-and-resend-feature.avif) fzf is a must-have, inside Neovim and on the command line both. It is the fastest way I know to find anything on a Linux system. [image: fzf narrowing a file search on the system] (https://typovrak.tv/img/posts/arch-2024-setup/fzf-system-search.avif) lazygit puts a UI on git without leaving the terminal. git is simple enough day to day that I only reach for it to read complex commit graphs and branches. [image: lazygit showing the branch graph of the typovrak.tv repository] (https://typovrak.tv/img/posts/arch-2024-setup/lazygit-in-typovrak-tv-repository.avif) One package needs the AUR, so it goes through yay instead of pacman: ```bash yay -S nvm ``` To finish this step, reboot so Flatpak wires itself into GNOME Software: ```bash reboot ``` Rebooting from the terminal instead of the menu changes nothing, it just looks cooler. Once the machine is back, GNOME Software can install anything on Flathub (https://flathub.org) directly. [image: Flathub linked into GNOME Software] (https://typovrak.tv/img/posts/arch-2024-setup/flathub-in-gnome-software.avif) > NOTE On NixOS today > > In 2024 I barely opened Neovim. It is now my daily editor, and my whole Neovim > config (https://github.com/typovrak/nixos-nvim), every custom plugin pinned, > gets cloned on every nixos-rebuild. 100% reproducible, zero interaction during > install and build. [image: My declarative Neovim configuration running on NixOS] (https://typovrak.tv/img/posts/arch-2024-setup/declarative-nvim-configuration.avif) > NOTE On NixOS today > > Installing an app like Insomnia means adding its name > (https://github.com/typovrak/nixos/blob/main/configuration.nix#L274) to my > NixOS config and nothing else. No pacman, no yay, no clicking through GNOME > Software. [image: Installing Insomnia by adding one line to the NixOS configuration] (https://typovrak.tv/img/posts/arch-2024-setup/installing-insomnia-with-nixos-in-one-line.avif) ## How do I run Docker without sudo? Add your user to the docker group, and the daemon stops asking for root on every command. First start the service and make it survive reboots: ```bash sudo systemctl start docker.service sudo systemctl enable docker.service ``` Then add yourself to the group and refresh it in the current shell: ```bash sudo usermod -aG docker $USER newgrp docker ``` > NOTE On NixOS today > > This whole dance is one line in my config > (https://github.com/typovrak/nixos/blob/main/configuration.nix#L228): > extraGroups = [ "docker" ];, written once and applied on every rebuild. No > usermod, no newgrp. [image: Adding the docker group in one line of the NixOS configuration] (https://typovrak.tv/img/posts/arch-2024-setup/setup-docker-group-in-one-line-on-nixos.avif) Test it with the throwaway hello-world image: ```bash docker run hello-world ``` [image: docker run hello-world output] (https://typovrak.tv/img/posts/arch-2024-setup/docker-run-hello-world-output.webp) If the first line reads Hello from Docker!, you are done. ## How do I make zsh the default shell? chsh changes the login shell. Point it at zsh, which is already installed. bash is fine, zsh is better, so: ```bash chsh $USER ``` When it asks for the new shell, give it the zsh path: ```bash /usr/bin/zsh ``` Confirm it took: ```bash echo $SHELL ``` That should print /usr/bin/zsh. The shell is switched, but it is bare until you give it a config. ## Adding a Nerd Font A Nerd Font ships the extra glyphs and icons a good terminal theme needs, and Powerlevel10k will ask for one. Download one from nerdfonts.com (https://www.nerdfonts.com/font-downloads). I use JetBrains Mono for how it reads while coding, with Fira Code as my occasional favourite for a change of scenery. Unzip it, delete the LICENSE.txt and README.md inside, then drop every font file into ~/.local/share/fonts (create it if missing, and keep it flat, no subfolders). Set that font in your terminal preferences, and if there is both a Mono and a non-Mono variant, pick the non-Mono one for a better terminal render. [image: Choosing the JetBrainsMono Nerd Font in GNOME Terminal preferences] (https://typovrak.tv/img/posts/arch-2024-setup/gnome-terminal-jetbrainsmono-nerd-font.webp) This is also where I set the size. With JetBrains Mono I use 12, to spare my eyes over a long day. ## What is in my zsh config? The config wires up zinit as the plugin manager, Powerlevel10k as the prompt, and a handful of plugins for highlighting, completion and fuzzy tab. Create ~/.zshrc: ```bash vim ~/.zshrc ``` And here is mine, the one I actually ran: ```bash # init ssh agent eval "$(ssh-agent -s)" &>/dev/null # load custom ssh keys #ssh-add ~/.ssh/github &>/dev/null #ssh-add ~/.ssh/gitlab &>/dev/null source /usr/share/nvm/init-nvm.sh # Enable Powerlevel10k instant prompt. Should stay close to the top of ~/.zshrc. # Initialization code that may require console input (password prompts, [y/n] # confirmations, etc.) must go above this block; everything else may go below. if [[ -r "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh" ]]; then source "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh" fi if [[ -f "/opt/homebrew/bin/brew" ]] then # If you're using macOS, you'll want this enabled eval "$(/opt/homebrew/bin/brew shellenv)" fi # Set the directory we want to store zinit and plugins ZINIT_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}/zinit/zinit.git" # Download Zinit, if it's not there yet if [ ! -d "$ZINIT_HOME" ]; then mkdir -p "$(dirname $ZINIT_HOME)" git clone https://github.com/zdharma-continuum/zinit.git "$ZINIT_HOME" fi # Source/Load zinit source "${ZINIT_HOME}/zinit.zsh" # Add in Powerlevel10k zinit ice depth=1; zinit light romkatv/powerlevel10k # Add in zsh plugins zinit light zsh-users/zsh-syntax-highlighting zinit light zsh-users/zsh-completions zinit light zsh-users/zsh-autosuggestions zinit light Aloxaf/fzf-tab # Add in snippets zinit snippet OMZP::git zinit snippet OMZP::sudo zinit snippet OMZP::archlinux zinit snippet OMZP::aws zinit snippet OMZP::kubectl zinit snippet OMZP::kubectx zinit snippet OMZP::command-not-found # Load completions autoload -Uz compinit && compinit zinit cdreplay -q # To customize prompt, run `p10k configure` or edit ~/.p10k.zsh. [[ ! -f ~/.p10k.zsh ]] || source ~/.p10k.zsh # Keybindings bindkey -v # History HISTSIZE=5000 HISTFILE=~/.zsh_history SAVEHIST=$HISTSIZE HISTDUP=erase setopt appendhistory setopt sharehistory setopt hist_ignore_space setopt hist_ignore_all_dups setopt hist_save_no_dups setopt hist_ignore_dups setopt hist_find_no_dups # Completion styling zstyle ':completion:*' matcher-list 'm:{a-z}={A-Za-z}' zstyle ':completion:*' list-colors "${(s.:.)LS_COLORS}" zstyle ':completion:*' menu no zstyle ':fzf-tab:complete:cd:*' fzf-preview 'ls --color $realpath' zstyle ':fzf-tab:complete:__zoxide_z:*' fzf-preview 'ls --color $realpath' # Aliases alias ls='ls --color' alias c="clear" alias e="exit" alias vim="nvim" alias v="nvim" alias vi="nvim" alias view="nvim -R" alias vimdiff="nvim -d" # Shell integrations eval "$(fzf --zsh)" eval "$(zoxide init --cmd cd zsh)" ``` Reload to apply it: ```bash source ~/.zshrc ``` The GitHub and GitLab SSH lines are commented out. Uncomment them if your keys share those names, or point them at yours. The aliases are the part I miss most on any machine that is not mine: e to exit a terminal in one keystroke, c to clear, and v/vim/vi all pointing at Neovim. [image: Coloured ls output from the zsh config] (https://typovrak.tv/img/posts/arch-2024-setup/zsh-ls-colored-output.webp) The first time you open a terminal after this, Powerlevel10k walks you through building a prompt. Answer the questions to taste. The full options live in the Powerlevel10k docs (https://github.com/romkatv/powerlevel10k). [image: The finished zsh prompt with Powerlevel10k] (https://typovrak.tv/img/posts/arch-2024-setup/zsh-powerlevel10k-prompt.webp) ## How do I enable SSH? The SSH service is off by default, which blocks cloning over SSH and connecting to a VPS. Same pattern as Docker, start it and enable it: ```bash sudo systemctl start sshd sudo systemctl enable sshd ``` Create a directory for your keys and lock down its permissions: ```bash mkdir ~/.ssh chmod 700 ~/.ssh cd ~/.ssh ``` If you drop keys in, fix their permissions or SSH will treat them as public and refuse them. Private keys need 600, public keys 644: ```bash chmod 600 github gitlab chmod 644 github.pub gitlab.pub ``` ## How do I configure Git? Set your name, email and default branch, so new repos start on main rather than master: ```bash git config --global user.name "First LAST" git config --global user.email you@example.com git config --global init.defaultBranch main ``` Those three commands are the same as writing a ~/.gitconfig by hand. It is an INI file (https://en.wikipedia.org/wiki/INI_file), one of the dozens of config formats you will meet, and you can read yours back with: ```bash cat ~/.gitconfig ``` ## Which apps do I install from Flathub? The GUI apps that pacman and yay cannot give me come from Flathub (https://flathub.org), through GNOME Software now that Flatpak is wired in. My usual list: - Slack (https://flathub.org/apps/com.slack.Slack), for work chat. - Obsidian (https://flathub.org/apps/md.obsidian.Obsidian), for personal notes. - OBS (https://flathub.org/apps/com.obsproject.Studio), for recording with a webcam. - VLC (https://flathub.org/apps/org.videolan.VLC), because GNOME Videos still trips over half my files. - Extension Manager (https://flathub.org/apps/com.mattjakeman.ExtensionManager), to tweak GNOME. ## Which GNOME tweaks do I always make? Three small ones, and I set all three on every machine. First, volume above 100%. GNOME caps output at 100% by default, and this one setting unlocks a 150% boost that a quiet laptop needs: ```bash gsettings set org.gnome.desktop.sound allow-volume-above-100-percent 'true' ``` [image: GNOME volume boosted to 150 percent] (https://typovrak.tv/img/posts/arch-2024-setup/gnome-volume-over-100-percent.webp) Second, the minimize button. GNOME ships without one, so I turn it back on in GNOME Tweaks. Small thing, non-negotiable for me. [image: Minimize and maximize buttons enabled in GNOME Tweaks] (https://typovrak.tv/img/posts/arch-2024-setup/gnome-tweaks-minimize-maximize-buttons.webp) Third, three extensions from Extension Manager: - Just Perfection, to reshape the GNOME interface. I mostly toggle the battery percentage and the keyboard-layout indicator, since I switch layouts all day. - Quick Settings Audio Panel, to set the volume per app from the top-right menu. - Tray Icons: Reloaded, to show background apps like Discord or Slack in the tray. [image: My GNOME Shell extensions] (https://typovrak.tv/img/posts/arch-2024-setup/gnome-shell-extensions-list.webp) ## What would I do differently? Three calls I would change with two years of hindsight. GNOME was the right way to start, but I would go straight to i3 now. Everything runs from the keyboard: every app, window, workspace and command. One screen with i3 in the hands does the work of two, three, sometimes four screens under GNOME, and the whole application layout bends to how you actually work. zoxide is a small quality-of-life win. cd typovrak.tv jumps to ~/projects/typovrak.tv from anywhere in the tree, and it is the kind of thing you should live with for a week to feel it. I dropped it anyway. It made me forget my own paths, which is backwards, and as a touch typist I lose almost no time typing a path in full. [image: zoxide jumping straight to a project directory from anywhere in the tree] (https://typovrak.tv/img/posts/arch-2024-setup/zoxide-in-action.avif#w400) FileZilla and gedit only survive for production work and the odd one-off. Given the choice, it is SSH and Vim every time. tmux is here for testing. I prefer zellij, the Rust terminal multiplexer, but it carries more than my daily work needs, so I never made the switch. My zellij config and theme (https://github.com/typovrak/nixos-zellij) are on GitHub if you want them. The setup adapts with or without NixOS. [image: zellij split into three panes with the Catppuccin Mocha green theme] (https://typovrak.tv/img/posts/arch-2024-setup/zellij-with-3-pane-and-catppuccin-mocha-green-theme.avif) ## What changed since? Everything above is imperative: run commands, click through GNOME, hope the next machine ends up the same. It worked, but it lived in my head and in screenshots like these, not in a file. Setting Arch up by hand took me about two hours of installing and clicking, and it never ran unattended. The same result on NixOS takes five minutes: I write the config once, start the build, and go make a coffee while the machine installs and configures everything with zero interaction. I never pulled that off on Arch, and it is the single biggest reason I moved. The setup is now a declarative NixOS config (https://typovrak.tv/nixos), one module per tool, so a fresh machine rebuilds to the same state from source. It has also grown well past this list, traded several tools for more modern and more secure ones, and picked up an end-to-end Catppuccin Mocha green theme. The 2024 list you just read is the foundation the rest was built on. If you are on Arch today, this still gets you a clean, developer-ready system. When you get tired of doing it by hand, you know where to look next.