OrgとNixによる文芸的設定集
Table of Contents
- 1. 概要
- 2. 設計思想
- 3. 設定
- 3.1. Flake
- 3.1.1. 入力
- 3.1.1.1. コア
- 3.1.1.2. Flakeインフラストラクチャー
- 3.1.1.3. 推移的依存関係
- 3.1.1.4. システム設定
- 3.1.1.5. インフラストラクチャー
- 3.1.1.6. 開発ツール
- 3.1.1.7. デスクトップ & テーマ
- 3.1.1.8. アプリケーション
- 3.1.1.8.1. brew-nix
- 3.1.1.8.2. edgepkgs
- 3.1.1.8.3. emacs-overlay
- 3.1.1.8.4. felis
- 3.1.1.8.5. firefox-addons
- 3.1.1.8.6. hermes-agent
- 3.1.1.8.7. mcp-servers
- 3.1.1.8.8. nix-mac-app-identity
- 3.1.1.8.9. org-clickup
- 3.1.1.8.10. nur-packages
- 3.1.1.8.11. paneru
- 3.1.1.8.12. pouncer
- 3.1.1.8.13. simple-wol-manager
- 3.1.1.8.14. vicinae
- 3.1.1.8.15. vicinae-extensions
- 3.1.2. Nixの設定
- 3.1.3. ホスト
- 3.1.4. 出力
- 3.1.5. Per-system modules
- 3.1.1. 入力
- 3.2. Overlay
- 3.3. モジュール
- 3.3.1. 概要
- 3.3.2. Nix
- 3.3.3. ネットワーク
- 3.3.4. File Synchronization
- 3.3.5. シェル
- 3.3.6. バージョン管理
- 3.3.7. Virtual Machines
- 3.3.8. Continuous Integration
- 3.3.9. Devices
- 3.3.10. Authentication
- 3.3.11. ブラウザー
- 3.3.12. エディター
- 3.3.13. Speech to Text
- 3.3.14. Launcher
- 3.3.15. Terminal
- 3.3.16. Darwin
- 3.4. スクリプト
- 3.1. Flake
- 4. 開発
1. 概要#
このリポジトリはNixとOrg modeを使って複数マシンのシステム設定を管理しています。すべての設定は文芸的プログラムとして書かれており、Org文書の中で各決定の理由を説明する文章がNixコードブロックを囲み、そこからタングルによって実際の設定ファイルが生成されます。
1.1. ドキュメント#
設定ドキュメントの全文は以下で公開しています:
1.2. Nix#
Nixは純粋関数型のパッケージマネージャー兼ビルドシステムです。このリポジトリでは以下のNixエコシステムツールを使用しています:
- Flakesによる再現可能な依存関係管理
- NixOSによる宣言的なLinuxシステム設定
- nix-darwinによる宣言的なmacOSシステム設定
- home-managerによるユーザー環境管理
- nix-on-droidによるAndroid (Termux) 環境
1.3. マシン#
| 名前 | プラットフォーム | デバイス | 用途 |
|---|---|---|---|
| kilimanjaro | NixOS (x86_64) | i5-12400F / RTX 3080 | メインデスクトップ |
| tarangire | NixOS (x86_64) | Ryzen 9 9950X | ビルドサーバー |
| manyara | NixOS (x86_64) | Beelink Mini S12 | ホームサーバー |
| arusha | NixOS (x86_64) | WSL2 | WSL環境 |
| serengeti | NixOS (aarch64) | OCI A1 Flex | ビルドサーバー |
| katavi | macOS (aarch64) | M1 MacBook Air | メインラップトップ |
| work | macOS (aarch64) | M4 MacBook Pro | 仕事用ラップトップ |
| mikumi | macOS (aarch64) | M1 Mac mini | ビルドサーバー |
| android | nix-on-droid | Galaxy S24 FE | スマートフォン |
2. 設計思想#
2.1. 文芸的設定#
Nixは宣言的なシステムです。 Nixで書かれたコードを読めばシステムがどうあるべきかわかりますし、 Nix自身がその状態にするために自動的に処理してくれます。 しかしそのコードからもビルドシステムからも、なぜその設定になっているのか、 なぜ他の選択肢を取らなかったのか知ることはできません。
なぜzshやbashではなくfishを選んだのか? どうしてこのサービスはデスクトップ環境でのみ使われているのか? この古いバージョンのパッケージを使い続けてるのはなぜなのか? コードは決定事項は語りますが、その背景となる事由には口をつぐみます。 もしこの決定の理由がわからなければ将来の変更に 意図したトレードオフが失われたり既に検討し却下したアプローチを 再度採用したりしてしまう危険性が伴うことになるでしょう。
This repository uses literate programming to preserve intent. Configuration lives in Org mode documents where prose surrounds code.
For most settings, one or two sentences—what it enables and the visible effect—is enough. Reserve the full problem-and-alternatives form for non-obvious trade-offs: architectural choices, package pins, temporary workarounds.
3. 設定#
3.1. Flake#
このflakeはmacOS、Linux、Androidの複数プラットフォームにまたがるマシンのNixOS、nix-darwin、home-manager設定を管理しています。flake-partsによるモジュール構成を採用し、開発、コードフォーマット、pre-commitフックのためのツールも含んでいます。
{
description = "dotfiles";
# Core
# Flake Infrastructure
# Transitive Dependencies
# System Configuration
# Infrastructure
# Development Tools
# Desktop & Theming
# Applications
inputs = {
<<nixpkgs>>
<<nixpkgs-stable>>
<<nixpkgs-cuda>>
<<flake-parts>>
<<flake-utils>>
<<darwin>>
<<home-manager>>
<<nix-mac-app-identity>>
<<nixos-wsl>>
<<nix-on-droid>>
<<disko>>
<<impermanence>>
<<lanzaboote>>
<<nixos-facter-modules>>
<<comin>>
<<hydra>>
<<microvm>>
<<niks3>>
<<nixvirt>>
<<sops-nix>>
<<git-hooks>>
<<treefmt-nix>>
<<nix-colors>>
<<nix-wallpaper>>
<<brew-nix>>
<<edgepkgs>>
<<emacs-overlay>>
<<felis>>
<<firefox-addons>>
<<hermes-agent>>
<<mcp-servers>>
<<org-clickup>>
<<nur-packages>>
<<paneru>>
<<pouncer>>
<<simple-wol-manager>>
<<vicinae>>
<<vicinae-extensions>>
};
nixConfig = {
<<nix-config>>
};
outputs =
{ self, flake-parts, ... }@inputs:
flake-parts.lib.mkFlake { inherit inputs; } {
<<outputs>>
};
}
3.1.1. 入力#
外部flake依存関係です。
依存関係グラフの肥大化による評価時間の増加を抑えるため、ほぼすべてのflakeで同一のnixpkgsや共通のinputをfollowするように設定しています。
3.1.1.1. コア#
3.1.1.1.1. nixpkgs#
https://github.com/NixOS/nixpkgs
Nixパッケージコレクション & NixOS
メインのパッケージセットです。チャンネル更新を高速にするため、=nixos-unstable= ではなく=nixos-unstable-small= を使用しています。=-small= バリアントは重要度の低い一部のCIテストを省略するため、コアパッケージの安定性を維持しつつ新しいパッケージバージョンをより早く取得できます。
チャンネルの選び方については https://nix.dev/concepts/faq.html#which-channel-branch-should-i-use を参照してください。
チャンネルの状態は https://status.nixos.org/ で確認できます。
nixpkgsのような大規模リポジトリでのファイル展開をわずかに高速化するため、=github:= の代わりに=git+https://= と
shallow=1 を使用しています。
nixpkgs.url = "git+https://github.com/nixos/nixpkgs?shallow=1&ref=nixos-unstable-small";
3.1.1.1.2. nixpkgs-stable#
unstableでビルド失敗やリグレッションが発生した際に安定版パッケージを提供します。主にunstableで壊れているパッケージに対して=stable= overlay(overlays/configuration.org参照)で使用されています。
nixpkgs-stable.url = "git+https://github.com/nixos/nixpkgs?shallow=1&ref=nixos-26.05";
3.1.1.1.3. nixpkgs-cuda#
kilimanjaroは=cudaSupport=付きでビルドしており、そのビルド結果をキャッシュしているのはcache.nixos-cuda.orgだけで、対象は=nixos-unstable-cuda=の先端リビジョンに限られます。このブランチは=nixos-unstable-small=より遅れるため、=cuda= overlay(overlays/configuration.org参照)が=ollama=と=handy=をこのinputから取得し、システムの残りは主となるinputに追従します。
nixpkgs-cuda.url = "git+https://github.com/nixos-cuda/nixpkgs?shallow=1&ref=nixos-unstable-cuda";
3.1.1.2. Flakeインフラストラクチャー#
3.1.1.2.1. flake-parts#
https://github.com/hercules-ci/flake-parts
モジュールシステムによるNix Flakeの簡素化
flake出力を整理するためのフレームワークです。flakeにモジュールシステムを提供し、関心の分離によって複雑な設定の保守性を高めます。
flake-parts = {
url = "github:hercules-ci/flake-parts";
inputs.nixpkgs-lib.follows = "nixpkgs";
};
3.1.1.3. 推移的依存関係#
3.1.1.3.1. flake-utils#
https://github.com/numtide/flake-utils
純粋なNix flakeユーティリティ関数
一般的なflakeユーティリティです。flake-utilsに依存するinput間でバージョンを統一するため、=follows= 経由の推移的な利用のみです。
flake-utils.url = "github:numtide/flake-utils";
3.1.1.4. システム設定#
3.1.1.4.1. darwin#
https://github.com/nix-darwin/nix-darwin
Nixを使ったmacOSの管理
nix-darwinはmacOSにNixOSスタイルのシステム設定を提供します。macOSのシステム設定、launchdサービス、Homebrewを宣言的に管理するために欠かせません。
darwin = {
url = "github:nix-darwin/nix-darwin";
inputs.nixpkgs.follows = "nixpkgs";
};
3.1.1.4.2. home-manager#
https://github.com/nix-community/home-manager
Nixを使ったユーザー環境の管理
ユーザー環境管理ツールです。dotfiles、ユーザーサービス、ユーザーごとのパッケージを宣言的に管理します。このリポジトリにおけるユーザーレベル設定の中核です。
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
3.1.1.4.3. nixos-wsl#
https://github.com/nix-community/nixos-wsl
WSL上のNixOS
Windows Subsystem for Linux上のNixOSです。WSL2内でNixOSの体験を提供し、Linux開発環境が必要なWindowsマシンで活用できます。
nixos-wsl = {
url = "github:nix-community/nixos-wsl";
inputs.flake-compat.follows = "";
inputs.nixpkgs.follows = "nixpkgs";
};
3.1.1.4.4. nix-on-droid#
https://github.com/nix-community/nix-on-droid
AndroidデバイスのためのNix対応環境
Termuxを介したAndroid向けNix環境です。モバイルデバイスでも同じ宣言的な設定アプローチを利用できます。
nix-on-droid = {
url = "github:nix-community/nix-on-droid";
inputs.home-manager.follows = "home-manager";
inputs.nix-formatter-pack.follows = "";
inputs.nixpkgs-docs.follows = "nixpkgs";
inputs.nixpkgs-for-bootstrap.follows = "nixpkgs";
inputs.nixpkgs.follows = "nixpkgs";
inputs.nmd.follows = "";
};
3.1.1.4.5. disko#
https://github.com/nix-community/disko
Nixを使った宣言的なディスクパーティショニングとフォーマット
再現可能なNixOSインストールのために使用します。パーティションレイアウト、ファイルシステムの作成、暗号化のセットアップを自動化します。
disko = {
url = "github:nix-community/disko";
inputs.nixpkgs.follows = "nixpkgs";
};
3.1.1.4.6. impermanence#
https://github.com/nix-community/impermanence
一時的なルートストレージを持つシステムで永続的な状態を管理するためのモジュール
一時的なルートファイルシステムを持つシステムでステートフルなパスを管理します。btrfsスナップショットと組み合わせて、明示的に宣言された状態のみが再起動後も保持されるようにします。
impermanence.url = "github:nix-community/impermanence";
3.1.1.4.7. lanzaboote#
https://github.com/nix-community/lanzaboote
NixOS向けSecure Boot
カスタムキーでブートコンポーネントに署名し、NixOSマシンでSecure Bootを有効にします。
lanzaboote = {
url = "github:nix-community/lanzaboote";
inputs.nixpkgs.follows = "nixpkgs";
inputs.pre-commit.follows = "git-hooks";
};
3.1.1.4.8. nixos-facter-modules#
https://github.com/numtide/nixos-facter-modules
nixos-facterと組み合わせて使用するNixOSモジュール群
NixOS向けのハードウェア検出ツールです。検出されたハードウェアに基づいてハードウェア設定を自動生成し、初期システムセットアップを簡素化します。
nixos-facter-modules.url = "github:numtide/nixos-facter-modules";
3.1.1.5. インフラストラクチャー#
3.1.1.5.1. comin#
https://github.com/nlewo/comin
NixOSマシンのためのGitOps
リポジトリへのプッシュ時に設定変更を自動デプロイし、手動で nixos-rebuild switch
を実行せずにサーバーの継続的デプロイメントを実現します。
comin = {
url = "github:nlewo/comin";
inputs.nixpkgs.follows = "nixpkgs";
};
3.1.1.5.2. hydra#
https://github.com/NixOS/hydra
Hydra's Rust queue runner and builder are developed in the Hydra repository, while nixpkgs only exposes the legacy service module.
Hydra links against a Nix it builds with Nix's own packaging, and upstream only tests that build against its pinned stable nixpkgs. I keep that pin instead of following ours, so a toolchain bump in unstable cannot break the build before Hydra catches up.
hydra = {
url = "github:NixOS/hydra";
inputs.treefmt-nix.follows = "treefmt-nix";
};
3.1.1.5.3. microvm#
https://github.com/astro/microvm.nix
NixOS modules for declaring & running virtual machines from within your system Flake configuration
Lightweight per-VM isolation suitable for running somewhat untrusted code. Used to sandbox third-party agent code (hermes-agent) so crashes, runaway tool invocations, or compromise of the agent runtime cannot reach the host's service stack. A microvm is preferred over a plain systemd-nspawn container because the qemu/firecracker boundary blocks kernel-level escapes that container namespacing alone does not.
microvm = {
url = "github:astro/microvm.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
3.1.1.5.4. sops-nix#
https://github.com/Mic92/sops-nix
sopsベースのNixOS向けアトミックなシークレットプロビジョニング
Mozilla SOPSを使ったシークレット管理です。リポジトリ内でシークレットを暗号化し、アクティベーション時にageキーで復号します。
sops-nix = {
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
3.1.1.5.5. niks3#
https://github.com/Mic92/niks3
S3-backed Nix binary cache with garbage collection
niks3 is a self-hosted, S3-backed Nix binary cache that resolves the upload limits I ran into with the alternatives: it hands clients presigned S3 URLs so NARs upload straight to R2, and the cloudflared tunnel only carries small API calls. Cachix's free 5 GB was not enough for my dotfiles closures, so builds kept re-running instead of being fetched from the cache. attic worked, but behind a free cloudflared tunnel every upload was proxied through the tunnel and hit Cloudflare's 100 MiB request-body cap on large derivations.
niks3 = {
url = "github:Mic92/niks3";
inputs.nixpkgs.follows = "nixpkgs";
inputs.treefmt-nix.follows = "treefmt-nix";
};
3.1.1.5.6. nixvirt#
https://github.com/AshleyYakeley/NixVirt
Declarative definitions for libvirt
NixVirt renders libvirt domain XML from Nix and reconciles the definition on activation, so a guest whose disk image this flake cannot build is still described by it. microvm.nix covers the other case, where the guest is a NixOS system built here.
I take the flake from FlakeHub rather than GitHub because upstream warns that the master branch is frequently broken. The wildcard resolves to the latest release.
nixvirt = {
url = "https://flakehub.com/f/AshleyYakeley/NixVirt/*.tar.gz";
inputs.nixpkgs.follows = "nixpkgs";
};
3.1.1.6. 開発ツール#
3.1.1.6.1. git-hooks#
https://github.com/cachix/git-hooks.nix
pre-commit.comのGitフックとNixのシームレスな統合
pre-commitフックをNix derivationとして提供します。グローバルなツールのインストールを必要とせず、すべての開発環境で一貫したコード品質チェックを実行できます。
このflakeに影響しないinputはロックファイルの肥大化を防ぐために削除しています。
git-hooks = {
url = "github:cachix/git-hooks.nix";
inputs.flake-compat.follows = "";
inputs.nixpkgs.follows = "nixpkgs";
};
3.1.1.6.2. treefmt-nix#
https://github.com/numtide/treefmt-nix
treefmt-nixの設定
Unified code formatter configuration. Runs multiple formatters (oxfmt, nixfmt, shfmt, etc.) through a single interface, ensuring consistent formatting across the repository.
treefmt-nix = {
url = "github:numtide/treefmt-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
3.1.1.7. デスクトップ & テーマ#
3.1.1.7.1. nix-colors#
https://github.com/misterio77/nix-colors
Nixでのテーマ設定を素晴らしくするモジュールとスキーム
Nix向けBase16カラースキームフレームワークです。単一のカラースキーム定義により、アプリケーション間で一貫したテーマを提供します。
nix-colors = {
url = "github:misterio77/nix-colors";
inputs.nixpkgs-lib.follows = "nixpkgs";
};
3.1.1.7.2. nix-wallpaper#
https://github.com/natsukium/nix-wallpaper
Nixシステム向けのカスタマイズ可能な壁紙
Nixロゴの壁紙を生成します。追加のロゴバリエーションをサポートするカスタムブランチ(=custom-logo=)を使用しています。
nix-wallpaper = {
url = "github:natsukium/nix-wallpaper/custom-logo";
inputs.flake-utils.follows = "flake-utils";
inputs.nixpkgs.follows = "nixpkgs";
inputs.pre-commit-hooks.follows = "git-hooks";
};
3.1.1.8. アプリケーション#
3.1.1.8.1. brew-nix#
https://github.com/BatteredBunny/brew-nix
HomebrewのすべてのmacOS caskを自動的にパッケージ化する実験的なNix式
Homebrew caskをdarwin向けNixパッケージとして提供します。nixpkgsにないプロプライエタリなmacOSアプリケーションに便利です。
brew-api inputはデータソース(HomebrewのAPIからのJSON
APIダンプ)にすぎないため、non-flakeとしてマークされています。
brew-api = {
url = "github:BatteredBunny/brew-api";
flake = false;
};
brew-nix = {
url = "github:BatteredBunny/brew-nix";
inputs.brew-api.follows = "brew-api";
inputs.nix-darwin.follows = "darwin";
inputs.nixpkgs.follows = "nixpkgs";
};
3.1.1.8.2. edgepkgs#
https://github.com/natsukium/edgepkgs
最新パッケージのための個人リポジトリです。nixpkgsにまだないパッケージ、修正が必要なパッケージ、アップストリームに受け入れられないパッケージ(ニッチなソフトウェアや実験的なソフトウェアなど)を含みます。
edgepkgs = {
url = "github:natsukium/edgepkgs";
inputs.nixpkgs.follows = "nixpkgs";
};
3.1.1.8.3. emacs-overlay#
https://github.com/nix-community/emacs-overlay
最新版Emacs overlay
ネイティブコンパイルやpure GTKバリアントを含む最新のEmacsビルドを提供します。nixpkgsよりも頻繁に更新されるMELPAパッケージも含みます。主にorgファイルを解析して依存パッケージを自動設定するユーティリティのために使用しています。
emacs-overlay = {
url = "github:nix-community/emacs-overlay";
inputs.nixpkgs-stable.follows = "nixpkgs-stable";
inputs.nixpkgs.follows = "nixpkgs";
};
3.1.1.8.4. felis#
https://git.natsukium.com/felis-terminal/felis
A terminal for your toolkit. Not an environment.
felis = {
url = "git+https://git.natsukium.com/felis-terminal/felis";
inputs.nixpkgs.follows = "nixpkgs";
inputs.flake-parts.follows = "flake-parts";
};
3.1.1.8.5. firefox-addons#
https://gitlab.com/rycee/nur-expressions
Nix User Repositoryへの組み込みに適したNix式
Nix向けにパッケージ化されたFirefox/ブラウザ拡張機能です。home-managerを通じた宣言的なブラウザ拡張機能管理が可能になります。
firefox-addons = {
url = "gitlab:rycee/nur-expressions?dir=pkgs/firefox-addons";
inputs.nixpkgs.follows = "nixpkgs";
};
3.1.1.8.6. hermes-agent#
https://github.com/NousResearch/hermes-agent
The self-improving AI agent built by Nous Research
Provides the upstream NixOS module (nixosModules.default) and packaged
Python build of hermes-agent.
URLは自分のforkを指しています。upstreamは=package.json=、=pyproject.toml=、=uv.lock=を=builtins.path=によるコピーから読んでおり、そのコピーは書き込み可能なstoreでしか実体化しないため、CIが実行する=nix flake check –no-build=は=path '…-hermes-python-source' is not valid=で失敗します。PR #71228がマージされたらupstreamに戻します。
hermes-agent = {
url = "github:natsukium/hermes-agent/fix/nix-read-only-eval";
inputs.flake-parts.follows = "flake-parts";
inputs.nixpkgs.follows = "nixpkgs";
inputs.home-manager.follows = "";
};
3.1.1.8.7. mcp-servers#
https://github.com/natsukium/mcp-servers-nix
すぐに使えるパッケージ付きのModel Control Protocol (MCP) サーバー向けNixベース設定フレームワーク
Nix向けの設定フレームワークとパッケージ化されたMCPサーバーの両方を提供します。Claude Codeやその他のMCP互換AIアシスタントで使用します。設定の詳細はMCP Serversを参照してください。
mcp-servers = {
url = "github:natsukium/mcp-servers-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
3.1.1.8.8. nix-mac-app-identity#
https://github.com/natsukium/nix-mac-app-identity
Preserve macOS TCC permissions across Nix rebuilds with stable code signing identities
Nixpkgs apps are ad-hoc signed, so macOS tracks TCC permissions by binary hash and revokes them on every rebuild. Re-signs bundles and wraps binaries with stable code signing identities so granted permissions persist across updates.
nix-mac-app-identity = {
url = "github:natsukium/nix-mac-app-identity";
inputs.nixpkgs.follows = "nixpkgs";
};
3.1.1.8.9. org-clickup#
https://git.natsukium.com/natsukium/org-clickup
Emacs package for bidirectional ClickUp ↔ org-mode sync.
org-clickup = {
url = "git+https://git.natsukium.com/natsukium/org-clickup";
flake = false;
};
3.1.1.8.10. nur-packages#
https://github.com/natsukium/nur-packages
個人のNUR (Nix User Repository) です。nixpkgsにはニッチすぎるものやカスタマイズが必要な個人的にメンテナンスしているパッケージを含みます。
nur-packages = {
url = "github:natsukium/nur-packages";
inputs.nixpkgs.follows = "nixpkgs";
};
3.1.1.8.11. paneru#
https://github.com/karinushka/paneru
A sliding, tiling window manager for MacOS.
Paneru brings the Niri-style scrollable tiling workflow used on Linux machines to macOS. Among the window managers tried for this purpose, it has run the most stably in day-to-day use, which is why it was chosen.
paneru = {
url = "github:karinushka/paneru";
inputs.flake-parts.follows = "flake-parts";
inputs.nixpkgs.follows = "nixpkgs";
inputs.nix-darwin.follows = "darwin";
};
3.1.1.8.12. pouncer#
https://git.natsukium.com/felis-terminal/pouncer
A standalone, terminal-agnostic hint picker for scrollback.
pouncer = {
url = "git+https://git.natsukium.com/felis-terminal/pouncer";
inputs.nixpkgs.follows = "nixpkgs";
inputs.flake-parts.follows = "flake-parts";
};
3.1.1.8.13. simple-wol-manager#
https://git.natsukium.com/natsukium/simple-wol-manager
Wake-on-LAN (WoL) デバイスを管理するWebアプリケーション
個人的なWake-on-LAN管理ツール。WoLパケットの送信とデバイス構成の管理のためのWebインターフェースを提供します。
simple-wol-manager = {
url = "git+https://git.natsukium.com/natsukium/simple-wol-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
3.1.1.8.14. vicinae#
https://github.com/vicinaehq/vicinae
A focused, keyboard-driven launcher for getting things done.
Vicinae is a Raycast-style native launcher.
vicinae = {
url = "github:vicinaehq/vicinae";
inputs.nixpkgs.follows = "nixpkgs";
};
3.1.1.8.15. vicinae-extensions#
https://github.com/vicinaehq/extensions
Extensions for the Vicinae launcher
The repository behind the Vicinae extension store. Each extension is exposed as a flake package, so I install store extensions declaratively instead of through the in-app store.
vicinae-extensions = {
url = "github:vicinaehq/extensions";
inputs.nixpkgs.follows = "nixpkgs";
inputs.vicinae.follows = "vicinae";
inputs.flake-compat.follows = "";
};
3.1.2. Nixの設定#
このflakeで使用するNixの設定です。これらの設定はすべての管理対象マシンで既に設定済みですが、ここに記述することで初期セットアップの助けになり、他の人がこのflakeを利用する際にも役立ちます。
各設定の詳細なドキュメントは https://nix.dev/manual/nix/latest/command-ref/conf-file.html を参照してください。
注意: flake.nix
はコードの再利用を妨げるNix言語の制限されたサブセットを使用しています(https://github.com/NixOS/nix/issues/4945
参照)。以下の値は現在このセクションにハードコードされています。マシン設定がOrg
modeに移行されれば、nowebリファレンスによりflakeとマシン固有の設定の両方でこれらの値を共有できるようになります。
3.1.2.1. バイナリキャッシュ#
このflakeをビルドするためのバイナリキャッシュ(substituter)設定です。必須ではありませんが、これらのキャッシュを設定するとソースからコンパイルする代わりにビルド済みバイナリをダウンロードするため、ビルド時間を大幅に短縮できます。
substituters と trusted-public-keys の代わりに=extra-substituters= と
extra-trusted-public-keys
を使うことで、このflakeのキャッシュ設定がユーザーの既存設定を置き換えるのではなく追加的になるようにしています。これによりユーザーが
nix.conf やシステム設定で既に設定しているキャッシュが尊重されます。
extra-substituters = [
"https://nix-cache.natsukium.com"
"https://natsukium.cachix.org"
"https://cache.nixos-cuda.org"
];
extra-trusted-public-keys = [
"niks3-1:SoIFTPtiPoCW3/OzUkIBKlLG5znMZfbihlr11XAOles="
"natsukium.cachix.org-1:STD7ru7/5+KJX21m2yuDlgV6PnZP/v5VZWAJ8DZdMlI="
"cache.nixos-cuda.org:74DUi4Ye579gUqzH4ziL9IyiJBlDpMRn9MBN8oNan9M="
];
3.1.2.1.1. cache.nixos-cuda.org#
CUDA関連パッケージのバイナリキャッシュです。以前はnix-community名前空間で配布されていましたが、2025年11月以降はCUDAチーム自身の基盤から配布されています。ビルド状況は https://hydra.nixos-cuda.org/project/nixos-cuda で確認できます。
nixos-cudaチームはこのキャッシュを開発用途のみのものとしています。代替として、CUDAパッケージにFloxのバイナリキャッシュも利用できます。2025年9月時点で、FloxはNVIDIAと提携して再配布権を取得しています。https://discourse.nixos.org/t/nix-flox-nvidia-opening-up-cuda-redistribution-on-nix/69189 を参照してください。
3.1.2.1.2. natsukium.cachix.org#
このflakeの出力を含む個人バイナリキャッシュです。公式の cache.nixos.org にないパッケージはGitHub
Actionsでビルドされ、ここにプッシュされます。
3.1.3. ホスト#
An overview of the managed machines. Each host's full configuration lives in
its own hosts/<platform>/<name>/ directory, picked up by the loader in
modules/hosts.nix.
3.1.3.1. katavi#
メインラップトップ(M1 MacBook Air)。
3.1.3.2. mikumi#
ビルドサーバー(M1 Mac mini)。
3.1.3.3. work#
仕事用ラップトップ(M4 MacBook Pro)。
3.1.3.4. kilimanjaro#
メインデスクトップ(Intel Core i5-12400F)。
Wake-on-LAN (WoL) は以下のBIOS設定で有効化されています:=Advanced > APM Configuration > Power On By PCI-E > Enabled=
3.1.3.4.1. Windows CI guest#
kilimanjaro is the host with the cores to spare, so it runs the Windows guest. It hands the guest the token the Linux runner here already uses, and the installation ISO off the NTFS partition it shares with the dual boot. Installing Windows is the one-time procedure. I have not run it end to end yet.
3.1.3.5. arusha#
WSL(kilimanjaroとデュアルブート)。
3.1.3.5.1. セットアップ#
arushaのセットアップにはWindows側とWSL側の両方の手順が必要です。手順はNixOS-WSLのクイックスタートガイドに従います。
- WSLのインストール
--no-distributionを指定すると、デフォルトのUbuntuディストリビューションのインストールを回避できます。NixOSをインポートした後に削除することになるためです。powershellwsl --install --no-distribution - NixOS-WSLのインポート
最新のNixOS-WSLリリースイメージをダウンロードしてインポートします。
powershell| Select-Object -ExpandProperty assets ` | | Where-Object { $_.name -eq "nixos.wsl" } ` | | ForEach-Object { Invoke-WebRequest -Uri $_.browser_download_url -OutFile $_.name } | Invoke-RestMethod -Uri https://api.github.com/repos/nix-community/nixos-wsl/releases/latest `powershell./nixos.wsl - 設定の適用
NixOS-WSLの初回起動後、このflakeの設定をGitHubから直接適用します。初回実行時にローカルへのクローンは不要です。
powershellwsl -d NixOSbashsudo nixos-rebuild switch --flake github:natsukium/dotfiles#arusha - Windows側のCLIツール
Windows 24H2には
sudoコマンドが組み込まれているため、別途UACプロンプトを表示せずにwingetをインラインで昇格できます。
3.1.3.6. manyara#
Intel N100搭載のミニPCで、軽量なホームサーバーとして使用しています。https://www.bee-link.com/products/beelink-mini-s12-pro-n100
停電後に自動的に電源が入るよう、以下のBIOS設定がされています:=Chipset > PCH-IO Configuration > State After G3 > S0 State=
3.1.3.7. serengeti#
ビルドサーバー(OCI A1 Flex)。
3.1.3.8. tarangire#
ビルドサーバー(Ryzen 9 9950X)。
Wake-on-LAN (WoL) は以下のBIOS設定で有効化されています:=Advanced > APM Configuration > Power On By PCI-E > Enabled=
3.1.3.9. android#
スマートフォン(Pixel 7a)。
3.1.4. 出力#
Each per-system concern lives in its own modules/per-system/*.nix file,
documented in [[Per-system modules]] below. The outputs body wires them
via imports; flake-parts merges perSystem contributions across all
modules.
systems = [
"x86_64-linux"
"aarch64-linux"
"aarch64-darwin"
];
imports = [
./modules
];
flake = {
overlays = import ./overlays { inherit inputs; };
};
3.1.5. Per-system modules#
Per-concern flake-parts modules that each contribute to
perSystem. Splitting them keeps flake.nix small and lets each concern
carry its own documentation. flake-parts merges contributions across all
imported modules, so each file only needs to define what it owns.
The shared pattern is:
{ ... }: # or { self, inputs, ... }: when the module needs them
{
perSystem =
{ ... }: # the usual perSystem args (pkgs, self', config, system, ...)
{
<the-attribute-this-module-owns> = { ... };
};
}
3.1.5.1. Nixpkgs instance#
Constructs the per-system pkgs with overlays applied, exposed to all other
perSystem consumers via _module.args.pkgs.
allowUnfree is enabled because several desktop hosts pull in unfree
packages (NVIDIA drivers on kilimanjaro, 1password, etc.) and gating
each call-site with an override would only add noise.
{ self, inputs, ... }:
{
perSystem =
{ system, ... }:
{
_module.args.pkgs = import inputs.nixpkgs {
inherit system;
config.allowUnfree = true;
overlays = [ inputs.nur-packages.overlays.default ] ++ builtins.attrValues self.overlays;
};
};
}
3.1.5.2. Packages#
Per-system packages exposed as flake outputs.
The html package builds the published documentation
(). The build itself lives in
scripts/build-html.sh rather than in this
derivation, so that a local preview does not have to go through Nix; the
derivation only runs that script. The styling is my own
(assets/org-html.css), inlined into each
page by scripts/org-to-html.el, so a page
loads nothing external.
{ ... }:
{
perSystem =
{
pkgs,
lib,
...
}:
{
packages = {
fastfetch = pkgs.callPackage ../../pkgs/fastfetch { };
html =
with pkgs;
stdenvNoCC.mkDerivation {
name = "dotfiles";
src = lib.cleanSource ../..;
nativeBuildInputs = [
(emacs.pkgs.withPackages (epkgs: [
epkgs.htmlize
epkgs.nix-ts-mode
(epkgs.treesit-grammars.with-grammars (g: [ g.tree-sitter-nix ]))
]))
gettext
po4a
];
buildPhase = ''
runHook preBuild
patchShebangs scripts
scripts/build-html.sh --output build
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
cp -r build/. $out/
runHook postInstall
'';
};
};
};
}
3.2. Overlay#
3.2.1. 概要#
このファイルでは、壊れたパッケージの修正、特定バージョンのピン留め、ローカルな回避策の追加のためのnixpkgs overlayを定義しています。各overlayはパッケージセットを変更し、システムのビルドを妨げたり実行時の問題を引き起こす課題に対処します。
overlayの仕組み(=final: prev:= パターン、合成順序など)の詳細は nixpkgs overlayドキュメントを参照してください。
overlayは目的と想定される存続期間に基づいて5つのカテゴリに分類されています:
- stable: unstableで壊れており、修正すると大量のリビルドが発生するか、 ローカルでパッチするには複雑すぎる場合にnixpkgs-stableから取得するパッケージ。
- cuda: CUDAチャンネルから取得するパッケージ。CUDAのビルドがキャッシュされている 唯一のリビジョン。
- temporary-fix: 別のnixpkgsバージョンを必要としないローカルオーバーライド (例: テストの無効化)。アップストリームで修正されたら削除します。
- pre-release: nixpkgsに入る前にテストするためのアルファ版、ベータ版、プレリリースパッケージ。
- patches: アップストリームへの貢献に適さない回避策(例: ロケール固有の修正、 ローカルツールのshim)。恒久的に残る想定です。
{ inputs }:
{
<<stable>>
<<cuda>>
<<temporary-fix>>
<<pre-release>>
<<patches>>
}
3.2.2. stable#
unstableで壊れている場合にnixpkgs-stableからパッケージを取得します。アップストリームの修正が大量のリビルドを引き起こす場合や、ローカルでパッチするには複雑すぎる場合が該当します。
stable = final: prev: {
};
3.2.3. cuda#
cache.nixos-cuda.org only covers the head
of nixos-unstable-cuda, so kilimanjaro takes its CUDA packages from
nixpkgs-cuda. Naming ollama and Handy's onnxruntime is enough:
openvino, opencv and cudnn-frontend are only reachable through them.
CUDAのHydraは素のnixpkgsをビルドしているため、このpackage setも素のままimportしています。ローカルのoverlayがそのクロージャに手を入れるとstore pathが変わり、キャッシュから外れてしまいます。ガードにより、=cudaSupport=を設定していないホストにはこのピン留めが適用されません。
Handy is the only consumer that needs onnxruntime on the GPU. Firefox and
Firefox ESR also take onnxruntime as a build input, and Zotero builds on
Firefox ESR. A CUDA onnxruntime anywhere else would drag those applications
off cache.nixos.org and rebuild them from source. So the host-wide
onnxruntime stays the plain build, and only Handy gets the CUDA one.
Calibre is swapped whole instead. Its Python environment reaches torch
through piper-tts, and the host-wide cudaSupport turns that torch into a
CUDA build as well.
cuda =
final: prev:
prev.lib.optionalAttrs (prev.config.cudaSupport or false) (
let
pkgsWithCuda = import inputs.nixpkgs-cuda {
inherit (prev.stdenv.hostPlatform) system;
config = {
cudaSupport = true;
allowUnfree = true;
};
};
pkgs = import inputs.nixpkgs {
inherit (prev.stdenv.hostPlatform) system;
config.allowUnfree = true;
};
in
{
inherit (pkgsWithCuda) ollama;
inherit (pkgs) onnxruntime;
handy = prev.handy.override {
inherit (pkgsWithCuda) onnxruntime;
};
calibre = pkgs.calibre;
}
);
3.2.4. temporary-fix#
ビルドはできるがテストが失敗したり軽微な問題があるパッケージのローカルオーバーライドです。=stable= overlayとは異なり、別のnixpkgsブランチからパッケージを取得する必要はありません。=doCheck= などの属性をオーバーライドするか、既存パッケージにパッチを当てるだけで十分です。アップストリームで問題が修正されたらこれらのオーバーライドを削除します。
temporary-fix = final: prev: {
<<afew>>
<<toybox>>
};
3.2.4.1. afew#
afew now builds with __structuredAttrs, and two of its attributes break
under it. Each makeWrapperArgs element reaches makeWrapper as a single
argument. The nixpkgs expression packs the whole --prefix PATH triple into
one string, and makeWrapper rejects it, so I split it into separate
elements. sphinxHook still reads sphinxBuilders as a plain variable, which
yields only the first element of the list. The man page is then never built
and the man output goes missing. A space-separated string works with both
the current hook and its fix in staging. Drop this once
NixOS/nixpkgs#575801 and
NixOS/nixpkgs#573479 reach
nixos-unstable.
afew = prev.afew.overridePythonAttrs {
sphinxBuilders = "html man";
makeWrapperArgs = [
"--prefix"
"PATH"
":"
"${final.notmuch}/bin"
];
};
3.2.4.2. toybox#
toybox 0.8.15 implements readahead with posix_fadvise, which macOS
lacks, so the darwin build fails to compile. extraConfig cannot switch the
applet off because the package prepends it to the defconfig, which then
wins. I disable it in .config right before the build instead. Drop this
once toybox guards the applet on macOS.
toybox = prev.toybox.overrideAttrs (
old:
prev.lib.optionalAttrs prev.stdenv.hostPlatform.isDarwin {
preBuild = (old.preBuild or "") + ''
sed -i 's/^CONFIG_READAHEAD=y$/# CONFIG_READAHEAD is not set/' .config
'';
}
);
3.2.5. pre-release#
nixpkgsに入る前のアルファ版、ベータ版、プレリリース版パッケージをテストするためのoverlayです。リリース候補、ナイトリービルド、アップストリームのレビュー待ちパッケージの評価に便利です。nixpkgsで利用可能になったら、このoverlayから削除します。
pre-release = final: prev: { };
3.2.6. patches#
アップストリームへの貢献に適さない回避策です。
これらのパッチは、アップストリームが受け入れないであろう問題に対処します。この設定固有のもの(例: ロケール設定)、意図された動作を迂回するもの、または非標準的な方法で問題を解決するものが該当します。=temporary-fix= とは異なり、恒久的に残る想定です。
patches = final: prev: {
<<gh-dash>>
<<command-line-tools-shim>>
};
3.2.6.1. gh-dash#
LANG=ja_JP.UTF-8
が設定されているとプレビューペインが正しく描画されません。gh-dashの端末幅計算が特定のUTF-8文字(特にCJK文字や一部の絵文字)の表示幅を誤ってカウントすることが原因です。これによりテキストの折り返しと配置が崩れます。
LANG=C.UTF-8
を設定するとUTF-8エンコーディングのサポートを維持しつつASCI互換の幅計算が強制され、描画の問題が修正されます。=writeShellApplication=
を使って、実際のバイナリを呼び出す前にこの環境変数を設定するラッパーを作成しています。
アップストリームでは dlvhdr/gh-dash#316で報告済みです。
gh-dash =
(final.writeShellApplication {
name = "gh-dash";
text = ''
LANG=C.UTF-8 ${final.lib.getExe prev.gh-dash} "$@"
'';
}).overrideAttrs
{ pname = "gh-dash"; };
3.2.6.2. mkShim#
macOS Command Line Toolsのスタブ実装を提供するshimユーティリティです。
Xcode Command Line Toolsがインストールされていないdarwinシステムでは、=cc= や python3
などのコマンドを実行するとインストールを促す煩わしいシステムポップアップが表示されます。これらのshimはそのような呼び出しをインターセプトし、Nixが提供するツールに委譲するか適切な終了コードを返すことで、ポップアップを抑制し不要なビルド失敗を防ぎます。
実装の詳細とshim化されたコマンドの一覧は pkgs/mkShimを参照してください。
inherit (final.callPackage ../pkgs/mkShim { }) mkShim commandLineToolsShim;
3.3. モジュール#
3.3.1. 概要#
このファイルでは、標準のNixOS、nix-darwin、およびhome-managerモジュールシステムを拡張するカスタムモジュールを定義しています。各モジュールは特定のユースケースに対応するか、アップストリームにない独自のデフォルトを提供します。
モジュールはモジュールシステムのターゲットではなく、機能ドメイン(例:シェル、ネットワーク、バージョン管理)ごとに整理されています。あるドメインにシステムレベルとユーザーレベルの両方の設定が含まれる場合、サブヘッダーで明示的に区別しています。
3.3.2. Nix#
Nixパッケージマネージャーとnixpkgsの設定です。これらのモジュールはNixOSとnix-darwinで共有され、すべてのマシンで一貫したNixの動作を提供します。
3.3.2.1. コア設定#
flake、ガベージコレクション、バイナリキャッシュ、サンドボックス設定を含むNixデーモンのコア設定です。
Registered under both NixOS and nix-darwin, since Nix daemon behavior is
shared across platforms; the systemModule binding lets both classes reuse
the exact same module rather than duplicating it. The user-level counterpart
() is a distinct
flake.modules.homeManager.nix entry in this same file, since XDG base
directories and the global gitignore belong to the home-manager scope, not
the system scope.
{ ... }:
let
systemModule =
{
config,
lib,
pkgs,
...
}:
let
cfg = config.my.nix;
in
{
<<nix-options>>
<<nix-config>>
};
in
{
flake.modules.nixos.nix = systemModule;
flake.modules.darwin.nix = systemModule;
<<nix-home-manager>>
}
3.3.2.1.1. オプション#
options.my.nix = {
enable = lib.mkEnableOption "Nix configuration";
enableFlakes = lib.mkOption {
default = true;
example = false;
description = "Whether to enable flakes.";
type = lib.types.bool;
};
};
3.3.2.1.2. 設定#
config = lib.mkIf cfg.enable (
lib.mkMerge [
<<nix-flakes>>
{
<<nix-store-optimisation>>
<<nix-warn-dirty>>
<<nix-substituters>>
<<nix-sandbox>>
<<nix-trusted-users>>
<<nix-gc>>
<<nix-extra-options>>
}
]
);
- Flakes
FlakeはNixプロジェクト管理のデファクトスタンダードであり、このリポジトリ全体で使用されています。
チャンネルは無効化されています。チャンネルはマシン間で再現が困難な可変状態(=/nix/var/nix/profiles/per-user/*/channels=)を導入するためです。ただし、=nix-shell= などのレガシーコマンドは壊れません。NixOSとnix-darwinはデフォルトでflakeのinputから=NIX_PATH= を自動設定するため(
nixpkgs.flake.setNixPath参照)、このdotfilesリポジトリで管理されるすべてのマシンで<nixpkgs>の参照が引き続き機能します。<<nix-flakes>>(lib.mkIf cfg.enableFlakes { nix = { settings.experimental-features = [ "flakes" "nix-command" ]; channel.enable = false; }; }) - ストアの最適化
2つの補完的な重複排除メカニズムが有効化されています:
nix.optimise.automaticは定期的にnix-store --optimiseを実行し、 ストア内の同一ファイルをハードリンクします。nix.settings.auto-optimise-storeは新しいパスが追加される際にビルド時に重複排除します。
auto-optimise-storeused to be Linux-only. The optimiser picked its temporary link name with an unguardedrand(), which on macOS collided between concurrent builders and failed them witherror: cannot link '/nix/store/.tmp-link' to '/nix/store/.links/...': File exists(NixOS/nix#7273). Nix 2.34 draws that name frommakeTempPathinstead, so both mechanisms now run everywhere.<<nix-store-optimisation>>nix.optimise.automatic = true; nix.settings.auto-optimise-store = true; - ガベージコレクション
定期的に
nix-collect-garbageを実行してディスク容量を回収します。7日以上前の世代は自動的に削除されます。それ以上古いビルドを保持しても価値は薄く、flakeのロックファイルからいつでも再ビルドできるためです。<<nix-gc>>nix.gc = { automatic = true; options = "--delete-older-than 7d"; }; - Dirty警告
flake評価時の「Git tree is dirty」警告を抑制します。この警告はコミットされていない変更があるとビルドのたびに表示されますが、開発中はそれが通常の状態です。
<<nix-warn-dirty>>nix.settings.warn-dirty = false; - バイナリキャッシュ
I configure three binary caches.
nix-cache.natsukium.comis my self-hosted niks3 cache (on manyara, backed by Cloudflare R2); CI pushes this dotfiles repository's pre-built artifacts to it.natsukiumis my Cachix cache, which my other repositories still push to — only dotfiles has moved to niks3, so for now niks3 is dotfiles-only and Cachix covers everything else, though I may broaden niks3's scope later.nixos-cudacovers CUDA packages, which kilimanjaro needs because it builds withcudaSupport; without it, every bump to a package in that closure turns into a local rebuild. These builds came fromnix-communityuntil November 2025, when the CUDA team moved to its own Hydra.<<nix-substituters>>nix.settings = { substituters = [ "https://nix-cache.natsukium.com" "https://natsukium.cachix.org" "https://cache.nixos-cuda.org" ]; trusted-public-keys = [ "niks3-1:SoIFTPtiPoCW3/OzUkIBKlLG5znMZfbihlr11XAOles=" "natsukium.cachix.org-1:STD7ru7/5+KJX21m2yuDlgV6PnZP/v5VZWAJ8DZdMlI=" "cache.nixos-cuda.org:74DUi4Ye579gUqzH4ziL9IyiJBlDpMRn9MBN8oNan9M=" ]; }; - サンドボックス
Darwinではサンドボックスを
"relaxed"に設定し、予期しないビルド失敗を回避しています。=sandbox = true= では、サンドボックスの制約により一部のパッケージがmacOSでビルドに失敗します。テストスイートがローカルサーバーを起動する際のlocalhost通信に関連していると考えられますが、正確なメカニズムは完全には解明されていません。nixpkgsはサンドボックス内でlocalhostアクセスを許可する__darwinAllowLocalNetworkingを提供しており、同じ種類の問題に対処できる可能性があります。="relaxed"= は通常のderivationをサンドボックス化したまま、=__noChroot = true= を持つderivationがサンドボックスを迂回できるようにし、これらの失敗を防ぎます。<<nix-sandbox>>nix.settings.sandbox = if pkgs.stdenv.hostPlatform.isDarwin then "relaxed" else true; - 信頼されたユーザー
macOSでは、管理者ユーザーは
wheelではなく=admin= グループに属します(=wheel= にはrootのみが含まれます)。=@admin= がないと、DarwinでプライマリユーザーがNixデーモンから信頼されません。<<nix-trusted-users>>nix.settings.trusted-users = [ "root" "@wheel" ] ++ lib.optional pkgs.stdenv.hostPlatform.isDarwin "@admin"; - 追加オプション
3600秒(1時間)出力がないビルドを終了します。一部の重いビルド(例: Chromium、カーネルのコンパイル、CUDAベースのディープラーニングライブラリ)は長時間無出力になることがあるため、本当にハングしたビルドを検出しつつ誤検知を避けるためにタイムアウトは余裕を持って設定されています。
<<nix-extra-options>>nix.extraOptions = '' max-silent-time = 3600 '';
3.3.2.2. Nixpkgs#
Nixpkgsの設定です。=allowUnfree= はデフォルトで有効化されています。可能な限りフリーソフトウェアが望ましいですが、厳密に強制するのは非現実的です。NVIDIAハードウェアにはunfreeドライバーとCUDAライブラリが必要であり、=allowUnfreePredicate= で個別にunfreeパッケージを指定するのは、関連する推移的な依存関係の数を考えると過度に煩雑です。
Zotero 10 still embeds Firefox ESR 140, which reached end-of-life on 2026-10-13. nixpkgs marks that prebuilt Firefox insecure, so every host that installs Zotero fails to evaluate. I accept the stale engine until Zotero moves to a newer ESR. The entry names the exact version, so a bump fails evaluation again and makes me revisit it.
Registered under both NixOS and nix-darwin from the same module binding, since nixpkgs configuration has no platform-specific behavior.
{ ... }:
let
module =
{
config,
lib,
...
}:
let
cfg = config.my.nixpkgs;
in
{
options.my.nixpkgs = {
enable = lib.mkEnableOption "Nixpkgs configuration";
allowUnfree = lib.mkOption {
default = true;
example = false;
description = "Whether to allow unfree packages.";
type = lib.types.bool;
};
};
config = lib.mkIf cfg.enable {
nixpkgs = {
config.allowUnfree = cfg.allowUnfree;
config.permittedInsecurePackages = [
"firefox-esr-140-bin-unwrapped-140.17.0esr"
];
};
};
};
in
{
flake.modules.nixos.nixpkgs = module;
flake.modules.darwin.nixpkgs = module;
}
3.3.2.3. 分散ビルド#
Every non-server host offloads builds to the rest of the fleet over SSH, so
my laptop can hand an aarch64-linux or CUDA derivation to a machine that
builds it natively instead of emulating it. This used to sit outside the
registry as an unconditional import, and server hosts opted out by
overriding nix.distributedBuilds directly. I folded it into a
my.nix.distributedBuilds feature so that the base profile turns it on
and the server profile turns it off the same way every other feature is
toggled, instead of through a setting only this one subsystem knew about.
The build machines, their host keys, and the SSH connection tuning all
derive from one machines attrset. Without that single source the set of
hostnames would appear three times — in nix.buildMachines, in
programs.ssh.knownHosts, and in the ssh_config Host line — and the
three copies would drift as machines join and leave.
Registered under both NixOS and nix-darwin from one systemModule, since
the offloading logic and the SSH host trust it depends on are identical on
both. Each host reaches the other machines' evaluated configurations through
inputs.self.outputs; the options below explain what the builder entries
read from them.
{ ... }:
let
systemModule =
{
inputs,
config,
lib,
...
}:
let
cfg = config.my.nix.distributedBuilds;
inherit (inputs.self.outputs.nixosConfigurations) kilimanjaro serengeti tarangire;
inherit (inputs.self.outputs.darwinConfigurations) mikumi;
in
{
<<distributed-builds-options>>
<<distributed-builds-config>>
};
in
{
flake.modules.nixos.distributed-builds = systemModule;
flake.modules.darwin.distributed-builds = systemModule;
}
3.3.2.3.1. Protocol#
I use ssh-ng because it transfers store paths more efficiently than plain
ssh and supports content-addressed derivations.
3.3.2.3.2. オプション#
Each machine entry hardcodes what its host's configuration cannot answer:
the host key, the platforms it builds for, and its speed factor. The job
limit and build features are read from the machine's own
config.nix.settings, so raising max-jobs on tarangire updates its
builder entry everywhere with no second edit. mikumi's feature list is the
one exception, spelled out by hand because its configuration does not
declare system-features.
excludeHosts keeps a machine out of one host's builder list beyond the
automatic self-exclusion. work is a company Mac that should not offload to
mikumi, my personal one, so mikumi names work there.
connectTimeout caps how long a host waits for a builder that is not
answering. When a builder is down and its packets are silently dropped, SSH
waits out the operating system's default connect timeout — tens of seconds —
before Nix gives up and builds locally, so every build stalls on the dead
machine first. Five seconds is long enough to reach a machine that is merely
slow to answer and short enough that a dead one barely registers.
options.my.nix.distributedBuilds = {
enable = lib.mkEnableOption "distributed builds";
connectTimeout = lib.mkOption {
type = lib.types.ints.positive;
default = 5;
description = "SSH ConnectTimeout in seconds for reaching build machines.";
};
machines = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = {
publicKey = lib.mkOption { type = lib.types.str; };
systems = lib.mkOption { type = lib.types.listOf lib.types.str; };
maxJobs = lib.mkOption { type = lib.types.ints.positive; };
speedFactor = lib.mkOption {
type = lib.types.ints.positive;
default = 1;
};
supportedFeatures = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
};
excludeHosts = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
};
};
}
);
description = "Build machines the fleet can offload to.";
default = {
tarangire = {
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEJJYgE/dmYLXYBrVnPicd0qsaUeqcBtXB8H9LHkJ2j4";
systems = [
"x86_64-linux"
"i686-linux"
];
maxJobs = tarangire.config.nix.settings.max-jobs;
speedFactor = 5;
supportedFeatures = tarangire.config.nix.settings.system-features;
};
kilimanjaro = {
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILhpfAalh6A5xDSE+HOdNE29ZgIjlP7tdlhHs82boSwp";
systems = [
"x86_64-linux"
"i686-linux"
];
maxJobs = kilimanjaro.config.nix.settings.max-jobs;
supportedFeatures = kilimanjaro.config.nix.settings.system-features;
};
serengeti = {
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDWwhfhDSZ+M2XDwP2MlC/zFfVpk3WjUxV/JWFgGzgNW";
systems = [ "aarch64-linux" ];
maxJobs = serengeti.config.nix.settings.max-jobs;
supportedFeatures = serengeti.config.nix.settings.system-features;
};
mikumi = {
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPfWOWKBFuDV08g6xP9MMY78CERI02CNG+5dy8CXQmXs";
systems = [
"aarch64-darwin"
"x86_64-darwin"
];
maxJobs = mikumi.config.nix.settings.max-jobs;
supportedFeatures = [
"apple-virt"
"benchmark"
"big-parallel"
"nixos-test"
];
excludeHosts = [ "work" ];
};
};
};
};
3.3.2.3.3. 設定#
A host builds its nix.buildMachines list from every machine except itself
and any that named it in excludeHosts. The host keys go into
programs.ssh.knownHosts for the whole set, since unattended SSH cannot
answer an interactive host-key prompt, and the ConnectTimeout applies to
the same set through an ssh_config Host block.
config = lib.mkIf cfg.enable {
nix = {
distributedBuilds = true;
extraOptions = ''
builders-use-substitutes = true
'';
buildMachines = lib.pipe cfg.machines [
(lib.filterAttrs (
name: machine:
name != config.networking.hostName && !(lib.elem config.networking.hostName machine.excludeHosts)
))
(lib.mapAttrsToList (
hostName: machine: {
inherit hostName;
protocol = "ssh-ng";
inherit (machine)
systems
maxJobs
speedFactor
supportedFeatures
;
sshUser = "natsukium";
mandatoryFeatures = [ ];
}
))
];
};
programs.ssh.knownHosts = lib.mapAttrs (_: machine: { inherit (machine) publicKey; }) cfg.machines;
programs.ssh.extraConfig = ''
Host ${lib.concatStringsSep " " (lib.attrNames cfg.machines)}
ConnectTimeout ${toString cfg.connectTimeout}
'';
};
3.3.2.4. Hydra#
Hydra's Rust queue runner requires a builder agent on each build machine. The feature wraps Hydra's upstream NixOS and nix-darwin modules. Host configurations only assign runner and builder roles; the feature owns the service wiring.
The upstream REST and gRPC ports, 8080 and 50051, are common development-server choices. I use 24050 and 24051 instead; neither is used elsewhere in this repository, and both sit outside the default ephemeral ranges on Linux and macOS.
{ inputs, ... }:
let
mkBuilderModule =
upstreamModule:
{ config, lib, ... }:
let
cfg = config.my.services.hydra;
in
{
imports = [ upstreamModule ];
options.my.services.hydra = {
queueRunner = {
enable = lib.mkEnableOption "the Hydra queue runner service";
hostname = lib.mkOption {
type = lib.types.str;
default = "kilimanjaro";
description = "Hostname builder agents use to reach the Hydra queue runner.";
};
restPort = lib.mkOption {
type = lib.types.port;
default = 24050;
description = "Port for the queue runner REST API.";
};
grpcPort = lib.mkOption {
type = lib.types.port;
default = 24051;
description = "Port for queue runner connections from builder agents.";
};
};
builder = {
enable = lib.mkEnableOption "the Hydra builder agent";
queueRunnerAddress = lib.mkOption {
type = lib.types.str;
default = "http://${
if cfg.queueRunner.enable then "localhost" else cfg.queueRunner.hostname
}:${toString cfg.queueRunner.grpcPort}";
defaultText = lib.literalExpression ''
"http://''${
if config.my.services.hydra.queueRunner.enable then
"localhost"
else
config.my.services.hydra.queueRunner.hostname
}:''${toString config.my.services.hydra.queueRunner.grpcPort}"
'';
description = "gRPC address of the Hydra queue runner.";
};
maxJobs = lib.mkOption {
type = lib.types.ints.positive;
default = if builtins.isInt config.nix.settings.max-jobs then config.nix.settings.max-jobs else 4;
defaultText = lib.literalExpression ''
if builtins.isInt config.nix.settings.max-jobs then config.nix.settings.max-jobs else 4
'';
description = "Maximum concurrent jobs accepted by this builder.";
};
speedFactor = lib.mkOption {
type = lib.types.oneOf [
lib.types.ints.positive
lib.types.float
];
default = 1;
description = "Relative scheduling speed of this builder.";
};
systems = lib.mkOption {
type = lib.types.nullOr (lib.types.listOf lib.types.str);
default = null;
description = "Systems advertised by this builder, or null to read them from Nix.";
};
supportedFeatures = lib.mkOption {
type = lib.types.nullOr (lib.types.listOf lib.types.str);
default = null;
description = "Features advertised by this builder, or null to read them from Nix.";
};
mandatoryFeatures = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "Features required by every build assigned to this builder.";
};
};
};
config = lib.mkIf cfg.builder.enable {
services.hydra-queue-builder-dev = {
enable = true;
queueRunnerAddr = cfg.builder.queueRunnerAddress;
settings = {
inherit (cfg.builder)
mandatoryFeatures
maxJobs
speedFactor
supportedFeatures
systems
;
};
};
};
};
# The firewall only admits gRPC through the trusted Tailscale interface,
# so application-level TLS would duplicate the tailnet's authenticated transport.
queueRunnerModule =
{ config, lib, ... }:
let
cfg = config.my.services.hydra.queueRunner;
in
{
config = lib.mkIf cfg.enable {
services.hydra-queue-runner-dev = {
enable = true;
grpc = {
address = "[::]";
port = cfg.grpcPort;
};
rest.port = cfg.restPort;
settings = {
machineFreeFn = "DynamicWithMaxJobLimit";
useSubstitutes = true;
};
};
services.hydra-dev.extraConfig = ''
queue_runner_endpoint = http://localhost:${toString cfg.restPort}
'';
};
};
in
{
flake.modules.nixos.hydra.imports = [
(mkBuilderModule inputs.hydra.nixosModules.hydra)
queueRunnerModule
];
flake.modules.darwin.hydra = mkBuilderModule inputs.hydra.darwinModules.builder;
}
3.3.2.5. ユーザーレベルの設定#
ユーザーレベルのNix設定です。システム設定ではなくユーザーの環境に属するため、home-managerで管理されています。
XDGベースディレクトリを有効にして、=~/.nix-defexpr= と ~/.nix-profile をそれぞれ
~/.config/nix/ と ~/.local/state/nix/ の下に配置し、=$HOME= のドットファイルの散らかりを減らします。
result シンボリックリンクはgitの無視対象に追加されています。=nix build= はデフォルトでプロジェクトルートに result
シンボリックリンクを作成し、コミットすべきではないためです。これは特定のリポジトリではなく、ユーザーのグローバルgitignoreに適用されます。
flake.modules.homeManager.nix =
{
config,
lib,
...
}:
let
cfg = config.my.nix;
in
{
options.my.nix.enable = lib.mkEnableOption "nix";
config = lib.mkIf cfg.enable {
nix.settings.use-xdg-base-directories = config.xdg.enable;
programs.git.ignores = [ "result" ];
};
};
3.3.3. ネットワーク#
3.3.3.1. Tailscale#
NixOSシステム向けの独自のTailscale VPN設定です。このモジュールはMagicDNS、SSHアクセス、適切なファイアウォール設定とともにTailscaleを実行するための合理的なデフォルトを提供します。
{ ... }:
{
flake.modules.nixos.tailscale =
{ config, lib, ... }:
let
cfg = config.my.services.tailscale;
in
{
<<tailscale-options>>
<<tailscale-config>>
};
}
3.3.3.1.1. オプション#
Tailscaleの動作を制御するモジュールオプションです。
options.my.services.tailscale = {
enable = lib.mkEnableOption "Tailscale VPN";
<<configureResolver-option>>
};
- configureResolver
デスクトップシステムではサスペンド/レジューム後にDNS解決の失敗が発生することがあります。システムがレジュームすると、外部ドメインの解決(例: github.com)が失敗する一方、Tailnetのホスト名は引き続き動作します。これはネットワーク遷移時のTailscaleのDNS状態管理に関する既知の問題です。
configureResolverを有効にするとsystemd-resolvedがアクティベートされ、サスペンド/レジューム後のDNS解決の問題が軽減されます。これで問題が完全に解消されるわけではありませんが(アップストリームのバグは未解決)、最も信頼性の高いMagicDNS体験を提供します。ヘッドレスサーバーでは、このオプションは通常不要です。サーバーはサスペンドしないため、レジュームに関連するこのDNSバグに遭遇することがないためです。
アップストリームの議論は tailscale/tailscale#4254を参照してください。
<<configureResolver-option>>configureResolver = lib.mkOption { type = lib.types.bool; default = false; description = '' Enable systemd-resolved for Tailscale DNS. Recommended for desktop systems to mitigate DNS failures after suspend/resume. https://github.com/tailscale/tailscale/issues/4254 ''; };
3.3.3.1.2. 設定#
config = lib.mkIf cfg.enable (
lib.mkMerge [
{
<<tailscale-service>>
<<tailscale-networking>>
<<tailscale-secrets>>
}
<<tailscale-resolver>>
]
);
- サービス設定
Tailscaleサービスのコア設定
useRoutingFeatures = "server"はこのマシンをサブネットルーターまたはイグジットノードとして機能させます。この設定のすべてのマシンは他のデバイスのイグジットノードとして機能でき、旅行中や制限されたネットワーク上での柔軟性を提供します。authKeyFileはTailscale認証キーを含むSOPS管理のシークレットを指します。認証キーを使用することで無人認証が可能になり、cominなどのツールを使った自動デプロイやGitOpsワークフローに不可欠です。--sshはTailscale SSHを有効にし、SSHキーの管理やポート22のパブリックインターネットへの公開なしにTailscaleネットワーク経由でSSHアクセスを可能にします。<<tailscale-service>>services.tailscale = { enable = true; useRoutingFeatures = "server"; authKeyFile = config.sops.secrets.tailscale-authkey.path; extraUpFlags = [ "--ssh" ]; }; - ネットワーク
Tailscale統合のためのファイアウォールとDNS設定です。
tailscale0インターフェースは信頼されています。このインターフェース上のすべてのトラフィックはTailscaleによって認証されるためです。これにより、追加のファイアウォールルールなしにサービスをtailnetのみに公開できます。100.100.100.100はTailscaleのMagicDNSリゾルバーで、tailnetホスト名(例:hostname.tail4108.ts.net=)の解決を可能にします。=8.8.8.8はtailnet以外のクエリのフォールバックを提供しますが、実際にはMagicDNSがアップストリームリゾルバーへの転送を処理します。検索ドメインはtailnetドメインに設定されており、短いホスト名(例:
ssh manyara.tail4108.ts.netの代わりに =ssh manyara=)が使えます。<<tailscale-networking>>networking = { firewall = { trustedInterfaces = [ "tailscale0" ]; allowedUDPPorts = [ config.services.tailscale.port ]; }; nameservers = [ "100.100.100.100" "8.8.8.8" ]; search = [ "tail4108.ts.net" ]; }; - シークレット
Tailscale認証キーのSOPSシークレット宣言です。実際のキーはリポジトリのシークレットファイルに暗号化されて保存され、アクティベーション時に復号されます。
<<tailscale-secrets>>sops.secrets.tailscale-authkey = { }; - リゾルバー
条件付きのsystemd-resolved設定です。=configureResolver= がtrueの場合にのみ有効になり、通常はサスペンド/レジュームするデスクトップシステムで使用されます。
<<tailscale-resolver>>(lib.mkIf cfg.configureResolver { services.resolved.enable = cfg.configureResolver; })
3.3.3.2. SSH Client#
SSH is mostly tailscale's business, but a client still needs ~/.ssh/config
for the few hosts I reach by a name that is not their real address. This
module only claims the file and turns off enableDefaultConfig, as upstream
recommends.
{ ... }:
{
flake.modules.homeManager.ssh =
{ config, lib, ... }:
{
options.my.programs.ssh.enable = lib.mkEnableOption "ssh client";
config = lib.mkIf config.my.programs.ssh.enable {
programs.ssh = {
enable = true;
enableDefaultConfig = false;
};
};
};
}
3.3.4. File Synchronization#
3.3.4.1. org-sync#
I keep my org notes in ~/dropbox/org and have synced them across my
desktop and laptops through Dropbox. I added Syncthing so the hermes-agent
VM can read the same notes, and I plan to drop Dropbox and rely on Syncthing
alone once it has proven itself.
This is a user-level services.syncthing instance, separate from the
system-level daemon kilimanjaro already runs; its ports are shifted off the
defaults so the two do not collide.
{ ... }:
{
flake.modules.homeManager."org-sync" =
{
config,
lib,
...
}:
let
cfg = config.my.services.org-sync;
in
{
options.my.services.org-sync = {
enable = lib.mkEnableOption "syncing the org folder across the user's devices";
devices = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options.id = lib.mkOption {
type = lib.types.str;
description = "Syncthing device ID.";
};
}
);
default = { };
description = "Peer devices that participate in the org folder.";
};
};
config = lib.mkIf cfg.enable {
services.syncthing = {
enable = true;
guiAddress = "127.0.0.1:8385";
overrideDevices = true;
overrideFolders = true;
settings = {
options.listenAddresses = [
"tcp://0.0.0.0:22001"
"quic://0.0.0.0:22001"
];
devices = cfg.devices;
folders.org = {
path = "${config.home.homeDirectory}/dropbox/org";
devices = lib.attrNames cfg.devices;
};
};
};
};
};
}
3.3.5. シェル#
対話的シェルとスクリプト用シェルは異なる目的を持ちます。対話的シェルはPOSIX互換である必要はなく、重要なのは使いやすさ、幅広い環境サポート、拡張性です。
3.3.5.1. Fish#
fishはプライマリの対話的シェルです。すぐに使えるエクスペリエンス(シンタックスハイライト、オートサジェスト、タブ補完が設定やプラグインなしで動作する)が選定理由です。幅広い環境とソフトウェアのサポートを持つシェルの中で、fishは最小限のセットアップで最も便利で拡張可能な対話的エクスペリエンスを提供します。
Both NixOS and nix-darwin hand fish its environment by sourcing each
environment.*Init snippet – and on NixOS the whole session environment –
through a bash foreign-env round-trip on every interactive startup. Spawning
bash is a fixed cost paid even when a snippet is empty; I measured ~22ms
across those calls on one of my NixOS hosts. So I enable useBabelfish to
translate the environment to native fish once at build time instead.
programs.fish.useBabelfish = true;
# System scope: install fish, register it as a login shell, and make it the
# primary user's default shell.
{ ... }:
let
systemModule =
{
config,
lib,
pkgs,
...
}:
{
options.my.programs.fish.enable = lib.mkEnableOption "fish";
config = lib.mkIf config.my.programs.fish.enable {
programs.fish.enable = true;
environment.shells = [ pkgs.fish ];
users.users.${config.my.username}.shell = pkgs.fish;
<<useBabelfish>>
};
};
# Darwin layers macOS-only setup on top of the shared system config.
darwinModule =
{
config,
lib,
...
}:
{
imports = [ systemModule ];
# distributed builds fail with "fish: Unknown command: nix-store" over SSH;
# source the nix-daemon vars so nix-store is reachable.
# https://github.com/NixOS/nix/issues/7508#issuecomment-2597403478
config = lib.mkIf config.my.programs.fish.enable {
programs.fish.shellInit = ''
if test -e '/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.fish' && test -n "$SSH_CONNECTION"
source '/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.fish'
end
'';
# nix-darwin only manages a user's shell when the account is "known" and its
# uid matches the existing one, so the shell setting above takes effect.
# https://github.com/LnL7/nix-darwin/issues/1237#issuecomment-2562242340
users.users.${config.my.username}.uid = lib.mkDefault 501;
users.knownUsers = [ config.my.username ];
};
};
in
{
flake.modules.nixos.fish = systemModule;
flake.modules.darwin.fish = darwinModule;
flake.modules.homeManager.fish =
{
config,
lib,
pkgs,
...
}:
let
<<fish-any-nix-shell>>
in
{
options.my.programs.fish.enable = lib.mkEnableOption "fish";
config = lib.mkIf config.my.programs.fish.enable {
programs.fish = {
enable = true;
<<fish-interactive-shell-init>>
<<fish-abbreviations>>
<<fish-functions>>
<<fish-plugins>>
<<fish-launchctl-completion>>
};
xdg.configFile = {
"fish/functions/nix.fish".source = anyNixShellFunction "nix";
"fish/functions/nix-shell.fish".source = anyNixShellFunction "nix-shell";
};
};
};
}
3.3.5.1.1. 対話シェルの初期化#
fishが対話セッションを開始する際に適用される設定です。キーバインド、プラグイン設定、環境変数、シェル統合が含まれます。
interactiveShellInit = ''
<<fish-keybindings>>
<<fish-done-config>>
<<fish-pinentry>>
<<fish-extra-abbrs>>
'';
- キーバインド
Ctrl+Sに =zi=(zoxideのインタラクティブモード)をバインドし、ファジーなディレクトリジャンプに使用します。<<fish-keybindings>>bind \cs zi - Pinentry
SSH経由で接続している場合、GPGのpinentryをcurses(ターミナル)バリアントに切り替えます。デフォルトのグラフィカルなpinentryはX11/Waylandフォワーディングなしではリモートセッションで表示できないため、SSH経由でのコミット署名やシークレット復号にTUIのフォールバックが必要です。Gentoo Wiki: GnuPG - Changing pinentry for SSH loginsを参照してください。
<<fish-pinentry>># set environment variable for pinentry if test "$SSH_CONNECTION" != "" set -x PINENTRY_USER_DATA "USE_CURSES" end - 追加の略語
位置認識型の略語で、fishの高度な機能(=–position anywhere=、=–regex=、=–function=)を使用しています。これらはhome-managerの
shellAbbrsオプションでは利用できません。<<fish-extra-abbrs>># extra abbrs abbr -a L --position anywhere --set-cursor "% | less" abbr -a !! --position anywhere --function _abbr_last_history_item abbr -a extract_tar_gz --position command --regex ".+\.tar\.gz" --function _abbr_extract_tar_gz abbr -a dotdot --regex '^\.\.+$' --function _abbr_multicd
3.3.5.1.2. 略語#
shellAbbrs = {
<<fish-abbr-general>>
<<fish-abbr-nix>>
};
- 一般
<<fish-abbr-general>># spellchecker:off l = "ls"; - Nixリモートビルド
Nixのビルドターゲットシステムを指定する略語で、主にnixpkgsで作業する際に使用されます。各略語は
--system <triple>に展開され、ターゲットシステムが現在のホストと異なる場合は条件付きで-j0が追加されます。=-j0= はローカルジョブ数の上限をゼロに設定し、すべてのビルドをリモートビルダーに委任させます(分散ビルド参照)。これにより、Nixがホスト上でビルドを試みてアーキテクチャの不一致で失敗することを防ぎます。<<fish-abbr-nix>># spellchecker:on "--sxl" = { position = "anywhere"; expansion = "--system x86_64-linux" + pkgs.lib.optionalString ( pkgs.stdenv.hostPlatform.isDarwin || pkgs.stdenv.hostPlatform.isAarch64 ) " -j0"; }; "--sal" = { position = "anywhere"; expansion = "--system aarch64-linux" + pkgs.lib.optionalString ( pkgs.stdenv.hostPlatform.isDarwin || pkgs.stdenv.hostPlatform.isx86_64 ) " -j0"; }; "--sxd" = { position = "anywhere"; expansion = "--system x86_64-darwin" + pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isLinux " -j0"; }; "--sad" = { position = "anywhere"; expansion = "--system aarch64-darwin" + pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isLinux " -j0"; };
3.3.5.1.3. 関数#
略語システムで使用されるヘルパー関数です。fishでは --function
略語が参照する前に関数を定義する必要があるため、略語の定義とは分離されています。
functions = {
_abbr_last_history_item = "echo $history[1]";
_abbr_extract_tar_gz = "echo tar avfx $argv";
_abbr_multicd = "echo cd (string repeat -n (math (string length -- $argv[1]) - 1) ../)";
};
- any-nix-shell
any-nix-shell keeps fish as the interactive shell inside
nix shellandnix developenvironments; without it, entering a Nix shell drops into bash and loses fish's interactive features.any-nix-shell fishonly emits two function definitions (nixandnix-shell), but generating them shells out to/bin/shplus threewhichlookups — ~24ms on every interactive startup if sourced eagerly. Running it once at build time and splitting the output into fish's autoload directory moves that cost off the hot path (fish parses each function on first call), and uses any-nix-shell's real wrappers rather than a hardcoded copy, so itsnix runversion gate and--purehandling stay in sync with the upstream package.<<fish-any-nix-shell>># Generate any-nix-shell's fish wrappers at build time. Each call extracts one # function definition into its own file for fish's autoload directory, so the # wrappers stay in sync with the package rather than being hardcoded. anyNixShellFunction = name: pkgs.runCommand "any-nix-shell-${name}.fish" { nativeBuildInputs = [ pkgs.any-nix-shell ]; } '' any-nix-shell fish | awk -v want=${name} ' $1 == "function" { current = $2; depth = 0 } current == want { print if ($1 ~ /^(function|begin|if|for|while|switch)$/) depth++ else if ($1 == "end" && --depth == 0) current = "" } ' > $out '';
3.3.5.1.4. プラグイン#
plugins = [
{
name = "done";
src = pkgs.fishPlugins.done.src;
}
{
name = "fzf-fish";
src = pkgs.fishPlugins.fzf-fish.src;
}
];
doneプラグインは長時間実行されるコマンドのデスクトップ通知を提供します。閾値は15秒に設定されています。これより短いコマンドは通常対話的で通知の恩恵がなく、長いコマンド(ビルド、テストスイート、大きなファイル操作)はバックグラウンドで実行されることが多く、通知が有用です。
# set done's variable
set -U __done_min_cmd_duration 15000
fzf-fishはfzfをfishのタブ補完、履歴検索、ファイル/ディレクトリナビゲーションと統合します。fishの組み込み履歴検索(=Ctrl+R=)をfzfのファジーファインダーに置き換え、大きな履歴をより効果的に処理します。
3.3.5.1.5. Launchctl Completion#
Fish completes launchctl subcommands but not service targets like
gui/<uid>/.... This replaces the built-in completions with dynamic
candidates parsed from launchctl print and launchctl help, showing each
service's running state alongside its target.
Targets are fully qualified so fish's substring matching can complete a
service without knowing its domain upfront. Subcommands and flags are parsed
on demand from launchctl help to match the installed macOS version.
completions = lib.mkIf pkgs.stdenv.hostPlatform.isDarwin {
launchctl.body = builtins.readFile ./completions/launchctl.fish;
};
function __fish_launchctl_subcommands
launchctl help 2>&1 | awk '
/^Subcommands:$/ { inside = 1; next }
inside && /^\t/ { name = $1; $1 = ""; sub(/^[ \t]+/, ""); print name "\t" $0 }
'
end
# launchctl pads the flag column with a NUL byte, which awk would read as the end
# of the line.
function __fish_launchctl_options
string match -q -- '-*' (commandline --current-token); or return
set -l tokens (commandline --current-process --tokens-expanded --cut-at-cursor)
set -q tokens[2]; or return
launchctl help $tokens[2] 2>&1 | tr -d '\0' | awk '
match($0, /^ +/) && RLENGTH == 8 && $1 ~ /^--?[A-Za-z0-9]/ {
flag = $1; $1 = ""; sub(/^[ \t]+/, "")
if ($0 == "") { pending = flag; next }
print flag "\t" $0; pending = ""; next
}
pending != "" { sub(/^ +/, ""); print pending "\t" $0; pending = "" }
'
end
function __fish_launchctl_domains
set -l uid (id -u)
printf '%s\n' system gui/$uid user/$uid
end
function __fish_launchctl_domain_services -a domain
launchctl print $domain 2>/dev/null | awk '
/^\tservices = \{$/ { inside = 1; next }
inside && /^\t\}$/ { exit }
inside { print $NF "\t" ($1 == 0 ? "not running" : "pid " $1) }
'
end
function __fish_launchctl_service_targets
set -l token (commandline --current-token)
set -l domains (__fish_launchctl_domains)
if set -l matched (string match -r '^(system|(?:gui|user|pid|login|session)/[0-9]+)/' -- $token)
set domains $matched[2]
end
for domain in $domains
printf "$domain/%s\n" (__fish_launchctl_domain_services $domain)
end
end
function __fish_launchctl_labels
launchctl list | awk 'NR > 1 { print $3 "\t" ($1 == "-" ? "not running" : "pid " $1) }'
end
set -l service_target_commands attach blame bootout debug disable enable kickstart print runstats uncache
set -l label_commands list remove start stop
complete -c launchctl -n __fish_use_subcommand -xa '(__fish_launchctl_subcommands)'
complete -c launchctl -n 'not __fish_use_subcommand' -a '(__fish_launchctl_options)'
complete -c launchctl -n "__fish_seen_subcommand_from $service_target_commands" -xa '(__fish_launchctl_service_targets)'
complete -c launchctl -n "__fish_seen_subcommand_from $label_commands" -xa '(__fish_launchctl_labels)'
complete -c launchctl -n '__fish_seen_subcommand_from print-disabled' -xa '(__fish_launchctl_domains)'
complete -c launchctl -n '__fish_seen_subcommand_from bootstrap; and __fish_is_token_n 2' -xa '(__fish_launchctl_domains)'
complete -c launchctl -n '__fish_seen_subcommand_from kill; and __fish_is_token_n 2' -xa '(kill -l)'
complete -c launchctl -n '__fish_seen_subcommand_from kill; and __fish_is_token_n 3' -xa '(__fish_launchctl_service_targets)'
3.3.5.2. Bash#
Bashは完全な対話環境ではなく、最小限のフォールバックシェルとして設定されています。主な用途はfishが利用できない場合に使えるシェルを確保すること、およびbashを前提とするスクリプトやツールにPOSIX互換シェルを提供することです。また、fishが予期しない動作を示す際のテスト環境としても機能します。クリーンなbashがあれば、問題がfish固有かどうかの切り分けに役立ちます。
Registered as a home-manager feature behind my.programs.bash.enable,
matching the toggle every other shell in this section already has, rather
than applying unconditionally to every home that imports the profile it used
to live in.
{ ... }:
{
flake.modules.darwin.bash =
{
config,
lib,
...
}:
{
options.my.programs.bash.enable = lib.mkEnableOption "bash";
config = lib.mkIf config.my.programs.bash.enable {
<<bash-etc-profile>>
};
};
flake.modules.homeManager.bash =
{
pkgs,
lib,
config,
...
}:
let
cfg = config.my.programs.bash;
in
{
options.my.programs.bash.enable = lib.mkEnableOption "bash";
config = lib.mkIf cfg.enable {
home.packages = with pkgs; [ bashInteractive ];
programs.bash = {
enable = true;
<<bash-history>>
<<bash-completion>>
<<bash-aliases>>
<<bash-init>>
};
};
};
}
3.3.5.2.1. System Profile#
macOS builds the system PATH with path_helper, which Apple's shell
profiles run at login. It reads /etc/paths and /etc/paths.d, puts those
directories first, and appends whatever PATH it inherited. Under nix that
ordering is backwards: /usr/bin lands ahead of the nix profiles.
nix-darwin and nixpkgs already avoid it. The /etc/zprofile nix-darwin
installs over Apple's has no path_helper call, and nixpkgs patches fish to
skip path_helper when __NIX_DARWIN_SET_ENVIRONMENT_DONE is set, the flag
nix-darwin exports once it has built PATH itself. Apple's /etc/profile
is the one file neither of them owns. Nothing repairs the damage afterwards
either, because the /etc/bashrc that /etc/profile goes on to source only
runs set-environment while that same flag is unset. A login sh therefore
ends up with /usr/bin first, and finds the Xcode command line tools stub
instead of my git.
# /etc/profile: DO NOT EDIT -- this file has been generated automatically
# from https://github.com/natsukium/dotfiles.
# This file is read for sh(1) login shells.
environment.etc."profile" = {
knownSha256Hashes = [ "a3fe9f414586c0d3cacbe3b6920a09d8718e503bca22e23fef882203bf765065" ];
text = ''
if [ "''${BASH-no}" != "no" ]; then
[ -r /etc/bashrc ] && . /etc/bashrc
fi
'';
};
3.3.5.2.2. 履歴#
履歴は XDG_CONFIG_HOME 配下に保存し、=$HOME=
をクリーンに保ちます。リポジトリのXDGベースディレクトリの方針と一貫しています(ユーザーレベルの設定参照)。
historyFile = "$XDG_CONFIG_HOME/bash/history";
3.3.5.2.3. 補完#
Bashの補完はプライマリの対話的シェルとして使用されていないため無効化されています。補完スクリプトの読み込みは起動時間を追加(約100ms以上)しますが、フォールバックやスクリプト実行にのみ使用するシェルではメリットがありません。
enableCompletion = false;
3.3.5.2.4. エイリアス#
bashを対話的に使用する場合の最小限の利便性向上のための基本的なエイリアスです。意図的にシンプルにしています。リッチな対話エクスペリエンスはfishが担当します。
shellAliases = {
l = "ls -CF";
grep = "grep --color=auto";
fgrep = "fgrep --color=auto";
egrep = "egrep --color=auto";
};
3.3.5.2.5. 初期化#
この設定はfishがデフォルトのログインシェルに設定される前の名残です。以前はbashがログインシェルで .bashrc
からfishを起動していたため、これらの設定はすべての対話セッションに必要でした。bashがフォールバックとして使用されるため保持されています。
+ lib.optionalString (!config.programs.kitty.enable) ''
<<bash-tmux-autostart>>
initExtra = ''
<<bash-terminal-settings>>
''
'';
- ターミナル設定
stty stop undefdisablesCtrl+Sfrom sendingXOFF, freeing it for use as a keybinding in fish and other TUI applications. Without it,Ctrl+Sfreezes the terminal untilCtrl+Qis pressed.<<bash-terminal-settings>>stty stop undef # Ctrl-s - TMUXの自動アタッチ
bashが対話的シェルの場合にtmuxセッションを自動的に開始またはアタッチしますが、kittyがターミナルエミュレーターの場合のみ除外されます。kittyには独自のタブ/ウィンドウ管理(分割、レイアウト、タブ)があり、tmuxの多重化と競合します。kitty内でtmuxを実行すると冗長なネストが生じます。よりシンプルなターミナル(例: =xterm=、=alacritty=、またはLinuxコンソール)を使用する場合、tmuxはそれ以外では欠けるセッション永続性とウィンドウ管理を提供します。
<<bash-tmux-autostart>># TMUX (from ArchWiki) # if no session is started, start a new session if type tmux > /dev/null 2>&1; then test -z $TMUX && tmux # when quitting tmux, try to attach while test -z $TMUX; do tmux attach || break done fi
3.3.5.3. Readline#
readline provides line editing for bash and for most REPLs with a plain
prompt, such as the Python interpreter and psql. Configuring it here
rather than through bash keybindings covers all of them at once.
Ctrl+W deletes back to the previous / rather than the previous word
boundary, which suits the paths that make up most of what I type at a
prompt. The binding only holds with bind-tty-special-chars off: readline
otherwise copies the terminal's werase character into its keymap after
reading inputrc, which puts Ctrl+W back on unix-word-rubout.
completion-ignore-case makes tab completion case-insensitive.
{ ... }:
{
flake.modules.homeManager.readline =
{
lib,
config,
...
}:
let
cfg = config.my.programs.readline;
in
{
options.my.programs.readline.enable = lib.mkEnableOption "readline";
config = lib.mkIf cfg.enable {
programs.readline = {
enable = true;
variables = {
completion-ignore-case = true;
bind-tty-special-chars = false;
};
bindings = {
"\\C-w" = "unix-filename-rubout";
};
};
};
};
}
3.3.5.4. Nushell#
Nushellは構造化データをネイティブに扱える能力のために有効化されており、PowerShellに似ています。従来のシェルがコマンド間でテキストをパイプするのに対し、Nushellはシリアライズされたデータ形式(JSON、YAML、CSVなど)をファーストクラスのテーブルやレコードとして操作し、=jq= のような外部ツールなしで簡単なデータ操作を可能にします。fishと同様に非POSIXですが、対話的シェルにはPOSIX互換性は要件ではありません。
カスタム設定はまだ追加されていません。デフォルトで探索的な使用には十分であり、Nushell固有のワークフローにコミットするのは時期尚早です。
Registered behind my.programs.nushell.enable, like the other shells in
this section.
{ ... }:
{
flake.modules.homeManager.nushell =
{
config,
lib,
pkgs,
...
}:
let
cfg = config.my.programs.nushell;
completerCfg = config.ext.programs.nushell.externalCompleter;
in
{
options.my.programs.nushell.enable = lib.mkEnableOption "nushell";
<<nushell-external-completer-option>>
config = lib.mkMerge [
(lib.mkIf cfg.enable {
programs.nushell = {
enable = true;
loadSessionVariables = true;
};
})
<<nushell-external-completer-config>>
];
};
}
3.3.5.4.1. External Completer#
Nushell has no built-in tab-completion source for external (non-Nushell) commands. Piping candidates through fish's own completion engine reuses every completion already defined for fish — package names, git subcommands, CLI flags — instead of duplicating that logic in Nushell's completer syntax.
externalCompleter defaults to following whether Nushell itself is enabled,
so enabling the shell is enough to get external completions without a second
toggle; enableFishCompleter exists to opt out if fish is not the shell
doing the completing.
options.ext.programs.nushell.externalCompleter = {
enable = lib.mkOption {
type = lib.types.bool;
default = config.programs.nushell.enable;
};
enableFishCompleter = lib.mkOption {
type = lib.types.bool;
default = true;
};
};
| $"value(char tab)description(char newline)" + $in |
| from tsv --flexible --no-infer |
(lib.mkIf completerCfg.enable {
programs.nushell.extraConfig = lib.mkIf completerCfg.enableFishCompleter ''
let fish_completer = {|spans|
${lib.getExe pkgs.fish} --command $'complete "--do-complete=($spans | str join " ")"'
}
| default {} |
| insert enable { true } |
| insert completer { $fish_completer } |
$env.config = ($env.config? | default {})
$env.config.completions = ($env.config.completions? | default {})
$env.config.completions.external = (
$env.config.completions.external?
)
'';
})
3.3.5.5. Starship#
StarshipはRustで書かれたクロスシェルプロンプトで、ここで設定されているすべてのシェルで一貫したプロンプト体験を提供するために使用されています。前身のspacefishの頃から使用されており、簡単なTOMLベースの設定が使い続けている主な理由です。
Starshipはネイティブで非同期プロンプトレンダリングをサポートしていないため、このセクションにはfish用のカスタム非同期プロンプトモジュールも含まれています。
Registered behind my.programs.starship.enable, with
enableFishAsyncPrompt as a second, independent toggle on the same module —
a host can run starship without the async prompt, but not the reverse.
{ ... }:
{
flake.modules.homeManager.starship =
{
config,
lib,
...
}:
let
cfg = config.my.programs.starship;
in
{
options.my.programs.starship = {
enable = lib.mkEnableOption "starship";
enableFishAsyncPrompt = lib.mkOption {
type = lib.types.bool;
default = false;
};
};
config = lib.mkMerge [
(lib.mkIf cfg.enable {
programs.starship = {
enable = true;
settings = builtins.fromTOML (builtins.readFile ./starship.toml);
};
})
<<starship-async-prompt-config>>
];
};
}
3.3.5.5.1. プロンプトフォーマット#
プロンプトフォーマットはデフォルトのモジュールの前にシェルインジケーターを追加します。これにより、どのシェルがアクティブかが即座に分かり、テストやデバッグでfishとbashを切り替える際に便利です。
"$schema" = "https://starship.rs/config-schema.json"
format = "$shell$all$line_break$character"
3.3.5.5.2. シェルインジケーター#
各シェルには固有のアイコンがあります。fishには ==(fishアイコン)。Bashとnushellはデフォルトのインジケーターを使用します。
[shell]
fish_indicator = ""
powershell_indicator = ""
disabled = false
3.3.5.5.3. リモートコンテナの検出#
VS Code Remote Container(Dev Container)内で実行されているかを REMOTE_CONTAINERS
環境変数のチェックにより検出するカスタムモジュールで、視覚的なリマインダーとしてクジラの絵文字(🐋)を表示します。数年間使用されておらず、もう関連性がないかもしれません。
[custom.remote-container]
when = """ test "$REMOTE_CONTAINERS" """
symbol = "🐋"
format = " in $symbol "
3.3.5.5.4. 無効化されたモジュール#
gcloud
はホームディレクトリ(=~/.config/gcloud/=)からアクティブなGCP設定を読み込むため無効化されています。プロジェクトがGCPを使用しているかどうかに関係なく、すべてのリポジトリで表示されてしまいます。
[gcloud]
disabled = true
3.3.5.5.5. 非同期プロンプトモジュール#
Starship's default fish integration is replaced with an asynchronous
variant, gated on the same module's enableFishAsyncPrompt toggle rather
than a separate file, since the two options always travel together. The
async prompt script is cherry-picked from
duament's
gist, based on
fish-async-prompt. See
also
fish-shell#8223
for upstream discussion on async prompt support.
# use my own script to ensure the execution order
(lib.mkIf cfg.enableFishAsyncPrompt {
programs.starship.enableFishIntegration = lib.mkForce false;
programs.fish.interactiveShellInit = ''
if test "$TERM" != dumb
${lib.getExe config.programs.starship.package} init fish | source
source ${./async_prompt.fish}
end
'';
})
- 非同期プロンプトスクリプト
# cherry picked from https://gist.github.com/duament/bac0181935953b97ca71640727c9c029 status is-interactive or exit 0 if test -n "$XDG_RUNTIME_DIR" set -g __starship_async_tmpdir "$XDG_RUNTIME_DIR"/fish-async-prompt else set -g __starship_async_tmpdir /tmp/fish-async-prompt end mkdir -p "$__starship_async_tmpdir" set -g __starship_async_signal SIGUSR1 # Starship set -g VIRTUAL_ENV_DISABLE_PROMPT 1 builtin functions -e fish_mode_prompt set -gx STARSHIP_SHELL fish set -gx STARSHIP_SESSION_KEY (random 10000000000000 9999999999999999) # Prompt function fish_prompt printf '\e[0J' # Clear from cursor to end of screen if test -e "$__starship_async_tmpdir"/"$fish_pid"_fish_prompt cat "$__starship_async_tmpdir"/"$fish_pid"_fish_prompt else __starship_async_simple_prompt end end # Async task function __starship_async_fire --on-event fish_prompt switch "$fish_key_bindings" case fish_hybrid_key_bindings fish_vi_key_bindings set STARSHIP_KEYMAP "$fish_bind_mode" case '*' set STARSHIP_KEYMAP insert end set STARSHIP_CMD_PIPESTATUS $pipestatus set STARSHIP_CMD_STATUS $status set STARSHIP_DURATION "$CMD_DURATION" set STARSHIP_JOBS (count (jobs -p)) set -l tmpfile "$__starship_async_tmpdir"/"$fish_pid"_fish_prompt fish -c ' starship prompt --terminal-width="'$COLUMNS'" --status='$STARSHIP_CMD_STATUS' --pipestatus="'$STARSHIP_CMD_PIPESTATUS'" --keymap='$STARSHIP_KEYMAP' --cmd-duration='$STARSHIP_DURATION' --jobs='$STARSHIP_JOBS' > '$tmpfile' kill -s "'$__starship_async_signal'" '$fish_pid & disown end function __starship_async_simple_prompt set_color brgreen echo -n '❯' set_color normal echo ' ' end function __starship_async_repaint_prompt --on-signal "$__starship_async_signal" commandline -f repaint end function __starship_async_cleanup --on-event fish_exit rm -f "$__starship_async_tmpdir"/"$fish_pid"_fish_prompt end # https://github.com/acomagu/fish-async-prompt # https://github.com/fish-shell/fish-shell/issues/8223
3.3.6. バージョン管理#
3.3.6.1. Git#
Gitバージョン管理の設定です。Gitはhome-managerの=programs.git= モジュールを通じて直接設定され、=~/.config/git/config= を宣言的に生成します。
Registered behind my.programs.git.enable. The Scalar
performance tuning below is a second, independent toggle on the same module
rather than a separate file, since scalar has no meaning without the git
module it extends.
{ ... }:
{
flake.modules.homeManager.git =
{
config,
lib,
...
}:
let
cfg = config.my.programs.git;
scalarCfg = config.programs.git.scalar;
in
{
options.my.programs.git.enable = lib.mkEnableOption "git";
<<git-scalar-option>>
config = lib.mkMerge [
(lib.mkIf cfg.enable {
programs.git = {
enable = true;
<<git-settings>>
<<git-signing>>
<<git-ignores>>
<<git-scalar>>
};
<<git-gh>>
<<git-delta>>
<<git-difftastic>>
<<git-fish-abbreviations>>
})
<<git-scalar-config>>
];
};
}
3.3.6.1.1. コア設定#
基本的なgitの動作設定です。
settings = {
<<git-user-identity>>
<<git-github-user>>
<<git-editor>>
<<git-color>>
<<git-default-branch>>
<<git-push-safety>>
};
- User Identity
<<git-user-identity>>user = { name = "natsukium"; email = "[email protected]"; }; - GitHub Identity
I read GitHub issues and notifications in Emacs through forge, whose API layer
ghubtakes the account name fromgithub.userto find the matching~/.authinfo.ageentry. Without it a pull aborts with "Cannot determine username" before any request goes out, and the notification list keeps showing whatever the last successful fetch stored. The token that goes with it is described in the forge section below.<<git-github-user>>github.user = "natsukium"; - エディター
core.editor = "vim"はgit操作のフォールバックエディターとして設定されています。=EDITOR= 環境変数はホーム設定でnvimに設定されていますが、一部のコンテキスト(最小限の環境での=git commit= など)ではセッションの環境変数を引き継がない場合があります。gitconfigで明示的に設定することで一貫した動作を保証します。<<git-editor>>core.editor = "vim"; - カラー
すべてのgitサブコマンドでターミナルのカラーサポートを自動検出します。
<<git-color>>color = { status = "auto"; diff = "auto"; branch = "auto"; interactive = "auto"; grep = "auto"; }; - デフォルトブランチ
<<git-default-branch>>init.defaultBranch = "main"; - 強制プッシュの安全対策
push.useForceIfIncludesは強制プッシュの安全チェックを有効にします。gitはローカルブランチがリモートの現在のtipを含んでいるか確認してから強制プッシュを許可します。これにより、最後のfetch以降に他の人がプッシュしたコミットを誤って上書きすることを防ぎます。=–force-with-lease= だけでは、リモートrefがバックグラウンドで更新された場合(例: lazygitの定期的なfetchやバックグラウンドの=git fetch=)に検出できません。<<git-push-safety>>push.useForceIfIncludes = true;
3.3.6.1.2. コミット署名#
SSHベースのコミット署名です。SSHキーはプッシュ認証にすでに必要なため、別途GPGキーチェーンを管理する必要がなくなることからGPGよりSSHキーが選ばれました。GitのSSH署名サポート(Git 2.34で追加)は、よりシンプルな鍵管理でGPG署名と同じ整合性保証を提供します。認証と署名の両方に1つの鍵ペアで対応できます。
GitHubは2022年8月からSSHコミット検証をサポートしており、2024年11月から検証結果がサーバー側に永続化されるようになりました。この永続的検証により、SSH署名の主な懸念点であった鍵のローテーションによる過去の署名の無効化が解消され、長期的なIDの保証におけるGPGの主な利点がなくなりました。
将来的には、gitsign(Sigstoreベースの署名)のようなキーレスソリューションに移行することで、鍵管理を完全に不要にしワークフローをさらに簡素化できます。GitHubがSigstore検証をネイティブサポートするのを待っている状態です。
signByDefault = true はすべてのコミットを=git commit -S= なしで署名し、未署名のコミットが紛れ込むのを防ぎます。
signing = {
format = "ssh";
key = "~/.ssh/id_ed25519.pub";
signByDefault = true;
};
3.3.6.1.3. グローバルIgnore#
すべてのリポジトリでコミットすべきでないパターンです。リポジトリごとの .gitignore
エントリではなくグローバルignoreとしているのは、個人的なツール選択を反映しており、共同作業者に押し付けるべきではないためです。
ignores = [
<<git-ignores-os>>
<<git-ignores-dev>>
<<git-ignores-workflow>>
<<git-ignores-private>>
];
- OSアーティファクト
macOS Finderのメタデータファイルです。LinuxとmacOSの両方で使用されるクロスプラットフォームなdotfilesリポジトリのため、グローバルignoreに含めています。
<<git-ignores-os>>".DS_Store" - 開発環境
ローカルに留めるべき開発ツールが生成するアーティファクトです:
.aider*はaiderの会話履歴と設定ファイルにマッチします。 セッション固有のコンテキストを含むため、リポジトリに漏れるべきではありません。.direnv=、.envrc=: direnvの状態と設定。各プロジェクトが独自の.envrcを定義できますが、このリポジトリではflakeベースのdevshellを使用するため、.envrcファイルは自動生成されコミットすべきではありません。.ipynb_checkpoints: Jupyter Notebookの自動保存ファイル。.pre-commit-config.yaml: コミットではなくdevshell環境でプロジェクトごとに管理し、 ツールバージョンの異なるコントリビューター間のバージョン競合を避けます。.vscode/: 開発者ごとに異なるエディター固有の設定。__pycache__/: Pythonバイトコードキャッシュ。実行ごとに再生成されます。
<<git-ignores-dev>>".aider*" ".direnv" ".envrc" ".ipynb_checkpoints" ".pre-commit-config.yaml" ".vscode/" "__pycache__/" - ワークフロー
.worktree: git worktreeワークフローで使用されるマーカーファイル。
<<git-ignores-workflow>>".worktree" - プライベートノート
.private/は個人的なメモ、LLMコンテキストファイル、その他の共有すべきでないローカル専用のドキュメントのためのディレクトリです。<<git-ignores-private>>".private/"
3.3.6.1.4. Scalar#
Scalarは大規模リポジトリのgitパフォーマンスを最適化します。600,000件以上のコミットを持つnixpkgsリポジトリ向けに特に有効化されており、Scalarのバックグラウンドメンテナンス(prefetch、commit-graph、loose-objects)とファイルシステムモニターの統合から大きな恩恵を受けます。
programs.git.scalar is a custom option this module extends git with,
configuring git's built-in performance optimizations (multipack index,
preload index, untracked cache, fsmonitor) whenever a repository is
registered. It is a second toggle rather than folded into the base git
config unconditionally, since these settings only pay off on the handful of
very large repositories that need them.
options.programs.git.scalar = {
enable = lib.mkEnableOption "scalar";
repo = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "${config.home.homeDirectory}/NixOS/nixpkgs" ];
};
};
scalar = {
enable = true;
repo = [ "${config.programs.git.settings.ghq.root}/github.com/natsukium/nixpkgs" ];
};
(lib.mkIf scalarCfg.enable {
programs.git = {
settings = {
scalar.repo = scalarCfg.repo;
maintenance.repo = scalarCfg.repo;
};
includes = map (repo: {
condition = "gitdir:${repo}/";
contents = {
core = {
multipackindex = true;
preloadindex = true;
untrackedcache = true;
autocrlf = false;
safecrlf = false;
fsmonitor = true;
};
am.keepcr = true;
credential = {
"https://dev.azure.com".usehttppath = true;
validate = false;
};
gc.auto = 0;
gui.gcwarning = false;
index = {
threads = true;
version = 4;
};
merge = {
stat = false;
renames = true;
};
pack = {
usebitmaps = false;
usesparse = true;
};
receive.autogc = false;
feature = {
manyfiles = false;
experimental = false;
};
fetch = {
unpacklimit = 1;
writecommitgraph = false;
showforcedupdates = false;
};
status.aheadbehind = false;
commitgraph.generationversion = 1;
log.excludedecoration = "refs/prefetch/*";
maintenance = {
auto = false;
strategy = "incremental";
};
};
}) scalarCfg.repo;
};
})
3.3.6.1.5. GitHub CLI#
gh (GitHub CLI) is enabled alongside git
because it provides a credential helper
(programs.gh.gitCredentialHelper.enable,
on by default) that handles HTTPS authentication for GitHub repositories,
for fetch and push alike. So git talks to GitHub over HTTPS only, and an SSH
key is never needed to move commits — signing is the one thing SSH is still
used for.
I used to rewrite push URLs to SSH with
url."[email protected]:".pushInsteadOf, on the theory that it was a harmless
fallback for machines without gh. It was not harmless: URL rewriting
happens before the credential helper is ever consulted, so pushes really did
go over SSH, and the fallback could never fire anyway because the rewrite is
tangled from the same module that installs gh. Since the work account
needs pushes to authenticate as the right GitHub user, one auth path is
easier to reason about than two, and I dropped the rewrite. The work host
narrows the same helper to a second gh config directory for repositories
under its own root.
programs.gh.enable = true;
3.3.6.1.6. Delta#
deltaはターミナルでシンタックスハイライト付きのdiffを提供し、行番号やサイドバイサイド表示をサポートします。クリーンな視覚的表現が選定理由です。
programs.delta = {
enable = true;
enableGitIntegration = true;
};
3.3.6.1.7. Difftastic#
Difftasticは、行単位の比較ではなくAST(抽象構文木)レベルで動作する構造的差分ツールです。関数の移動や変数のリネームを、削除と挿入のブロックではなく、単一の意味的変更として認識します。これはJSXのリファクタリング、インデントの変更、ネストされたタグの再構成において特に有用で、行ベースの差分ではノイズが多く読みにくい出力になる場面で力を発揮します。
difftasticとdeltaの両方を併用しているのは、それぞれ補完的な役割を持つためです。deltaは日常的な操作(=git diff=、=git log=)でGitの組み込み行差分にシンタックスハイライトを追加し、difftasticは構造的な理解が重要な場面でlazygit経由で使用します。
programs.difftastic = {
enable = true;
};
3.3.6.1.8. Lazygit#
Lazygit is a terminal UI for git. A TUI was chosen over raw git commands for interactive operations like staging individual hunks, interactive rebase, and conflict resolution, where the visual feedback loop significantly reduces errors. Previously used gitui, but switched to lazygit for its broader feature set. gitui's performance advantage did not materialize in practice, and it lacked several workflow-critical operations.
nixpkgsのような大規模リポジトリではパフォーマンスが顕著に低下しますが、操作性と活発な開発により、このトレードオフにもかかわらずワークフローの信頼できる一部となっています。
Lazygit is a front end for the git configuration above and has no
meaning without it, so it rides my.programs.git.enable rather than
carrying a toggle of its own.
{ self, ... }:
{
flake.modules.homeManager.lazygit =
{ config, lib, ... }:
{
config = lib.mkIf config.my.programs.git.enable {
programs.lazygit = {
enable = true;
settings = {
<<lazygit-gui>>
<<lazygit-git>>
};
};
};
};
<<lazygit-check>>
}
- GUI
視認性向上のため、lazygitのインターフェースでNerd Fontアイコンを有効にします。
<<lazygit-gui>>gui = { showIcons = true; }; - Git連携
overrideGpg = truetells lazygit to run git commands inline instead of spawning a subprocess for GPG/SSH signing. By default, lazygit defers to a subprocess so the user can type a passphrase interactively. However, this subprocess mode prevents lazygit from orchestrating interactive rebase internally. Rewording, reordering, or editing any commit other than HEAD is disabled entirely because the rebase would trigger multiple signing prompts that lazygit cannot handle mid-rebase. WithoverrideGpg = true, lazygit assumes the signing agent (ssh-agent, macOS Keychain) caches the passphrase, so no interactive prompt is needed and all rebase operations work normally.diffRenderersregisters both delta and difftastic as diff views, cycled with|. Delta post-processes git's own line diff (--paging=neverbecause lazygit pages the output itself), while difftastic replaces diff generation entirely, so it is anextDiffentry rather than a filter.<<lazygit-git>>git = { overrideGpg = true; diffRenderers = [ { command = "delta --dark --paging=never"; } { type = "extDiff"; command = "difft --color=always"; } ]; }; - Schema Check
settingsis free-form YAML, so a key lazygit no longer accepts passes evaluation untouched. Lazygit then refuses to start, because it migrates renamed keys by rewriting the config file and the store copy is read-only.The schema ships in lazygit's own source, so the check reads it from the package the host installs. A version bump that renames a key fails the build rather than the next launch.
<<lazygit-check>>perSystem = { pkgs, ... }: let inherit (pkgs) lib; onThisSystem = lib.filterAttrs ( _: host: host.pkgs.stdenv.hostPlatform.system == pkgs.stdenv.hostPlatform.system ) (self.nixosConfigurations // self.darwinConfigurations); # home-manager writes the file under XDG on Linux but under Library/Application # Support on Darwin, so the entry is found by suffix rather than by a fixed key. configsOf = user: lib.mapAttrsToList (_: file: { inherit (file) source; schema = "${user.programs.lazygit.package.src}/schema/config.json"; }) (lib.filterAttrs (name: _: lib.hasSuffix "lazygit/config.yml" name) user.home.file); # The generated file is named without a .yml suffix in the store. configs = lib.unique ( lib.concatMap (host: lib.concatMap configsOf (lib.attrValues host.config.home-manager.users)) ( lib.attrValues onThisSystem ) ); in { checks = lib.optionalAttrs (configs != [ ]) { lazygit-config = pkgs.runCommand "lazygit-config-check" { nativeBuildInputs = [ pkgs.check-jsonschema ]; } '' ${lib.concatMapStringsSep "\n" ( config: "check-jsonschema --force-filetype yaml --schemafile ${config.schema} ${config.source}" ) configs} touch $out ''; }; };
3.3.6.1.9. Fishの略語#
頻繁に使うgit操作のシェル略語です。エイリアスではなく略語を使用しているのは、fishが実行前にインラインで展開するため、履歴に実際のコマンドが表示され、実行前の修正も可能になるためです。
programs.fish.shellAbbrs = {
<<git-abbr-push>>
<<git-abbr-pull>>
<<git-abbr-commit>>
<<git-abbr-status>>
<<git-abbr-stash>>
<<git-abbr-switch>>
<<git-abbr-ghq>>
};
- Push
lease付きの強制プッシュです。まだfetchされていないリモートの変更の上書きを防ぐため、=–force= よりも安全です。
<<git-abbr-push>>gpf = "git push --force-with-lease"; - Pull
gpmはリモートのデフォルトブランチからpullします。=main= やmasterをハードコードするのではなく、=git remote show= で動的に検出します。これにより=master= や他のブランチ命名規約を使用するリポジトリにも対応できます。gpuはupstreamからpullします。フォークワークフローでオリジナルのリポジトリと同期するために使用されます。<<git-abbr-pull>>gpm = "git pull (git remote show origin | sed -n '/HEAD branch/s/.*: //p')"; gpu = "git pull upstream"; - Commit
gciはコミットを作成します。末尾のスペースにより-m "message"を直接追加できます。gcaは直前のコミットを修正します。インタラクティブrebaseのワークフローで頻繁に使用されます。<<git-abbr-commit>>gci = "git commit "; gca = "git commit --amend"; - Status
<<git-abbr-status>>gs = "git status"; - Stash
<<git-abbr-stash>>gst = "git stash"; gstp = "git stash pop"; - Switch
<<git-abbr-switch>>gsw = "git switch"; gswc = "git switch -c"; - ghq
A fresh
ghq getclone is invisible tozuntil I cd into it once. Piping the path thatghq getprints on stdout since 1.10.0 intozoxide addregisters it right away.I use an abbreviation rather than a function wrapping
ghqso the pipe stays visible and I can delete it when I needghq get --look, whose nested shell the pipe would swallow.xargs -I{}keeps a path with spaces whole and runs nothing when a failed clone leaves stdout empty.<<git-abbr-ghq>>ghq-get = { command = "ghq"; regex = "get"; setCursor = true; expansion = "get % | xargs -I{} zoxide add {}"; };
3.3.6.2. Jujutsu#
I am moving my data analysis work to
Jujutsu, where the history has a shape git
does not expect. Analysis is a long run of small experiments and most of
them are abandoned, so git records only the states I remembered to
commit. jj snapshots the working copy on every command, which means the
state I had twenty minutes ago is still in the operation log whether or not
I judged it worth keeping at the time. Rearranging the survivors into a
sequence someone can read is then jj squash and jj rebase, instead of an
interactive rebase I would have had to plan for while still exploring.
jj keeps its commits in a git repository and colocates with .git by
default, so the git tooling in the Git section keeps working
inside a jj repo. The two are registered as separate features because jj is
not a replacement here: git remains what I use everywhere else.
{ ... }:
{
flake.modules.homeManager.jujutsu =
{
config,
lib,
pkgs,
...
}:
let
cfg = config.my.programs.jujutsu;
gitCfg = config.programs.git;
in
{
options.my.programs.jujutsu.enable = lib.mkEnableOption "jujutsu";
config = lib.mkIf cfg.enable {
<<jj-git-assertion>>
programs.jujutsu = {
enable = true;
settings = {
<<jj-user-identity>>
<<jj-signing>>
<<jj-ui>>
<<jj-snapshot>>
};
};
};
};
}
3.3.6.2.2. User Identity#
user = {
inherit (gitCfg.settings.user) name email;
};
3.3.6.2.3. コミット署名#
The key is git's, and so is the decision to sign at all: signByDefault
becomes jj's own, which signs the commits I author. Mapping git's format
onto jj's backend covers the case where I move off ssh keys, since the two
spell the same three backends differently.
jj rewrites the working-copy commit on every snapshot, which calls the
signing program far more often than git's per-commit signing does. I
measured the difference at a few milliseconds per command, below the cost of
the snapshot itself, so the frequency is not a reason to weaken this to
keep.
Signature status shows as unknown in jj log because I keep no
allowed-signers file. That only affects local verification; GitHub and
Forgejo check the signature against the key registered on my account.
signing = {
backend =
{
openpgp = "gpg";
ssh = "ssh";
x509 = "gpgsm";
}
.${gitCfg.signing.format};
behavior = if gitCfg.signing.signByDefault then "own" else "keep";
key = gitCfg.signing.key;
};
3.3.6.2.4. UI#
A bare jj already falls back to jj log, but prints a hint naming this
setting on every run until it is set. Setting it buys nothing except
silence, which is reason enough.
The editor is git's, set there because ; jj falls back the same way and deserves the same answer.
difftastic replaces the built-in colour-words diff. jj invokes a diff formatter on the two file versions directly, which is the interface difftastic wants, so no wrapper is needed. delta stays git-only because it reads a unified diff rather than a pair of files.
ui = {
default-command = [ "log" ];
editor = gitCfg.settings.core.editor;
diff-formatter = [
(lib.getExe pkgs.difftastic)
"--color=always"
"$left"
"$right"
];
};
3.3.6.2.5. Snapshot Size Limit#
Automatic snapshotting is what makes jj worth using for analysis, and it is
also the thing that would put a parquet file into the store the moment I
dropped one next to the notebook. jj has no equivalent of git-lfs, so the
store would simply grow. max-new-file-size is the guard: a new file larger
than the limit is not tracked at all.
The value below is jj's own default, written out because it carries the rule that data lives outside the repository. Without that rule the automatic snapshot turns from the reason I chose jj into the reason I would have to abandon it.
One catch is worth knowing before it bites. jj 0.44 skips an oversized file
in silence: nothing is printed, and jj status does not list the file as
untracked, so a file I assumed was committed can turn out not to be. When
that happens the fix is to move the data out of the tree, not to raise the
limit.
snapshot.max-new-file-size = "1MiB";
3.3.6.3. Forgejo#
I self-host Forgejo on manyara as my personal
forge at git.natsukium.com. This feature carries the NixOS module that
runs the instance and the home-manager module that configures my machines to
talk to it.
3.3.6.3.1. Instance#
Registration is closed since I am the only human user. Forgejo has no declarative way to define users, so my admin account and the renovate bot's are created at service start instead. Stars and forks are off for the same reason: both are social features, and with no second account they leave nothing but a button and a counter that never move.
New repositories start out with the two settings I would otherwise pick by hand: squash merges, and rebase when a pull request branch needs updating. Deleting the branch after a merge is the third habit, but Forgejo has no instance-level default for it, so I still tick that box per repository.
flake.modules.nixos.forgejo =
{
config,
lib,
pkgs,
...
}:
let
cfg = config.my.services.forgejo;
in
{
options.my.services.forgejo.enable = lib.mkEnableOption "my forgejo instance";
config = lib.mkIf cfg.enable {
services.forgejo = {
enable = true;
package = pkgs.forgejo;
database.type = "postgres";
settings = {
service.DISABLE_REGISTRATION = true;
repository = {
DISABLE_STARS = true;
DISABLE_FORKS = true;
};
"repository.pull-request" = {
DEFAULT_MERGE_STYLE = "squash";
DEFAULT_UPDATE_STYLE = "rebase";
};
server = {
HTTP_PORT = 3010;
DOMAIN = "git.natsukium.com";
ROOT_URL = "https://git.natsukium.com/";
};
actions = {
ENABLED = true;
DEFAULT_ACTIONS_URL = "github";
};
};
};
systemd.services.forgejo.preStart = ''
${lib.getExe config.services.forgejo.package} admin user create \
--username natsukium \
--email "[email protected]" \
--password "$(tr -d '\n' < ${config.sops.secrets.forgejo-admin-password.path})" \
--admin || true
${lib.getExe config.services.forgejo.package} admin user create \
--username renovate \
--email "[email protected]" \
--password "$(tr -d '\n' < ${config.sops.secrets.forgejo-renovate-password.path})" \
--must-change-password=false || true
'';
sops.secrets.forgejo-admin-password = {
sopsFile = ./secrets.yaml;
owner = "forgejo";
};
sops.secrets.forgejo-renovate-password = {
sopsFile = ./secrets.yaml;
owner = "forgejo";
};
my.services.cloudflared-tunnel.ingress.${config.services.forgejo.settings.server.DOMAIN} =
let
inherit (config.services.forgejo.settings.server) HTTP_ADDR HTTP_PORT;
in
{
service = "http://${toString HTTP_ADDR}:${toString HTTP_PORT}";
};
<<forgejo-ssh-port>>
<<forgejo-signing>>
<<forgejo-backup>>
};
};
- SSH Port
Tailscale runs with , so on the tailnet tailscaled answers port 22 itself and hands out a plain shell as
forgejo. That bypasses thecommand="forgejo serv"restriction git depends on, so sshd moves to 2022.SSH_PORTis pinned to 22 anyway: forgejo only uses it to render clone URLs, and any other value turns them into fullssh://URIs. The ssh route on the client carries the real port.<<forgejo-ssh-port>>services.openssh.ports = [ 2022 ]; services.forgejo.settings.server.SSH_PORT = 22; - Instance Signing
Forgejo authors the commit itself when I merge a pull request or edit a file in the web UI, so those commits carry no signature. An instance key signs them, in the same SSH format as .
<<forgejo-signing>>sops.secrets.forgejo-signing-key = { sopsFile = ./secrets.yaml; owner = "forgejo"; path = "/var/lib/forgejo-signing/key"; }; systemd.tmpfiles.settings."10-forgejo-signing" = { "/var/lib/forgejo-signing".d = { user = "forgejo"; group = "forgejo"; mode = "0750"; }; "${config.sops.secrets.forgejo-signing-key.path}.pub"."L+".argument = toString ( pkgs.writeText "forgejo-signing-key.pub" "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAWHoqNuRjeO1o4x6pdrF3ZCZRluh80HWVP6X8j4k8pn [email protected]\n" ); }; services.forgejo.settings."repository.signing" = { FORMAT = "ssh"; SIGNING_KEY = "${config.sops.secrets.forgejo-signing-key.path}.pub"; SIGNING_NAME = "Forgejo"; SIGNING_EMAIL = "[email protected]"; MERGES = "always"; CRUD_ACTIONS = "always"; }; systemd.services.forgejo.path = [ pkgs.openssh ]; - Backup
The repositories under
stateDirare only half the instance: issues, users and access tokens live in postgres, and restoring one without the other leaves a Forgejo that cannot serve any of it. Upstream'sforgejo dumpis not an alternative: it warns that its own SQL dump has long-standing re-import bugs, and its zip would arrive as a fresh opaque blob every time, leaving restic nothing to deduplicate against yesterday's.<<forgejo-backup>>services.postgresqlBackup = { enable = true; databases = [ config.services.forgejo.database.name ]; }; my.services.restic.backups.forgejo.paths = [ config.services.forgejo.stateDir "${config.services.postgresqlBackup.location}/${config.services.forgejo.database.name}.sql.gz" ];
3.3.6.3.2. Client Settings#
What my machines need to talk to the instance: where ssh goes, which remotes
take that route, and the credentials
fj uses against the
API. Every machine turns on the ssh and git settings; credentials.enable
is separate because only the desktop hosts carry a user age key that can
decrypt homes/shared/secrets.yaml.
flake.modules.homeManager.forgejo =
{
config,
lib,
pkgs,
...
}:
{
options.my.programs.forgejo = {
enable = lib.mkEnableOption "the ssh route to my forge";
credentials.enable = lib.mkEnableOption "fj credentials from sops";
};
config = lib.mkIf config.my.programs.forgejo.enable (
lib.mkMerge [
{
<<forgejo-ssh-route>>
<<forgejo-push-url>>
}
(lib.mkIf config.my.programs.forgejo.credentials.enable {
<<forgejo-cli-keys>>
})
]
);
};
- SSH Route
git.natsukium.comresolves publicly to the cloudflared tunnel, which carries HTTP only, so ssh needs another path. Every machine of mine reaches manyara over tailscale, so this block points the public name at the tailnet one. It lands in~/.ssh/configthrough the SSH Client module, which owns the file.<<forgejo-ssh-route>>programs.ssh.settings."git.natsukium.com" = { HostName = "manyara.tail4108.ts.net"; Port = 2022; }; - Push URLs
Remotes cloned over https would push over https as well. This rewrite routes every push to the forge over ssh instead, leaving fetch on the tunnel.
<<forgejo-push-url>>programs.git.settings.url."[email protected]:".pushInsteadOf = "https://git.natsukium.com/"; - CLI Credentials
When
fjinfers the instance from a checkout's git remote, it runs the remote host through~/.ssh/configeven forhttps://URLs. The ssh route above therefore rewritesgit.natsukium.comtomanyara.tail4108.ts.net, andfjdialshttps://manyara.tail4108.ts.net, where nothing is listening. Thealiasesmap inkeys.jsonrewrites an inferred host beforefjconnects, so mapping the tailnet name back togit.natsukium.comfixes inference in checkouts.keys.jsonalso holds the API token, andfjrewrites the whole file on login, logout, and OAuth refresh, so the alias cannot be managed on its own. Rendering the entire file from sops with anApplicationtoken works because application tokens never refresh, leavingfjno reason to write to the read-only file after activation; rotating the token just means editinghomes/shared/secrets.yamlinstead of runningfj auth.<<forgejo-cli-keys>>sops.secrets.forgejo-cli-token.sopsFile = ../../../homes/shared/secrets.yaml; sops.templates."forgejo-cli-keys.json" = { path = if pkgs.stdenv.hostPlatform.isDarwin then "${config.home.homeDirectory}/Library/Application Support/forgejo-cli.forgejo-cli/keys.json" else "${config.xdg.dataHome}/forgejo-cli/keys.json"; content = builtins.toJSON { hosts."git.natsukium.com" = { type = "Application"; token = config.sops.placeholder.forgejo-cli-token; }; aliases."manyara.tail4108.ts.net" = "git.natsukium.com"; }; };
3.3.7. Virtual Machines#
Declares virtual machines managed through libvirt using NixVirt, allowing domain definitions to be expressed declaratively alongside the rest of the system configuration.
3.3.7.1. Host#
Configures libvirt, a software TPM emulator, a default NAT network, and VM
viewers. Guests contribute their own static DHCP leases via dhcpHosts.
{ inputs, ... }:
{
flake.modules.nixos.libvirt =
{
config,
lib,
pkgs,
...
}:
let
nixvirt = inputs.nixvirt.lib;
cfg = config.my.virtualisation.libvirt;
in
{
imports = [ inputs.nixvirt.nixosModules.default ];
options.my.virtualisation.libvirt = {
enable = lib.mkEnableOption "libvirt with a NAT network for local guests";
dhcpHosts = lib.mkOption {
type = lib.types.listOf (
lib.types.submodule {
options = {
mac = lib.mkOption {
type = lib.types.str;
description = "MAC address the guest's interface is given.";
};
name = lib.mkOption {
type = lib.types.str;
description = "Name the lease is registered under.";
};
ip = lib.mkOption {
type = lib.types.str;
description = "Address to hand out for that MAC.";
};
};
}
);
default = [ ];
description = ''
Fixed leases on the guest network, contributed by the guests
themselves.
'';
};
};
# Windows 11 refuses to install without a TPM 2.0, and nothing else
# here minds having one.
config = lib.mkIf cfg.enable {
virtualisation.libvirt = {
enable = true;
swtpm.enable = true;
connections."qemu:///system".networks = [
{
definition = nixvirt.network.writeXML (
nixvirt.network.templates.bridge {
uuid = "4de77f71-1aba-49e0-a139-5454e752db84";
subnet_byte = 122;
dhcp_hosts = cfg.dhcpHosts;
}
);
active = true;
}
];
};
# Host shutdown stops a guest rather than saving it. A memory image
# restored onto a disk that has since been replaced is a guest that never
# comes back, and libvirt-guests starts a stopped guest again on the next
# boot anyway.
virtualisation.libvirtd.onShutdown = "shutdown";
environment.systemPackages = [
pkgs.virt-manager
pkgs.virt-viewer
];
users.users.${config.my.username}.extraGroups = [ "libvirtd" ];
};
};
}
3.3.7.2. Windows#
Manages a Windows golden image and an interactive guest backed by a persistent overlay. The Forgejo runner shares this same base image.
A logon scheduled task executes D:\bootstrap.ps1 on the guest's state disk
if present, decoupling guest-specific startup logic from the shared base
image.
{ inputs, ... }:
{
flake.modules.nixos.windows-vm =
{
config,
lib,
pkgs,
...
}:
let
nixvirt = inputs.nixvirt.lib;
cfg = config.my.services.windows-vm;
<<windows-vm-disks>>
<<windows-vm-payload>>
<<windows-vm-answer-image>>
<<windows-vm-domain>>
in
{
options.my.services.windows-vm = {
<<windows-vm-options>>
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = !cfg.installer || cfg.installerIso != null;
message = "my.services.windows-vm.installerIso has to name an installation ISO while installer is on.";
}
];
my.virtualisation.libvirt = {
enable = true;
dhcpHosts = [
{
mac = guestMac;
name = "windows";
ip = "192.168.122.10";
}
];
};
<<windows-vm-libvirt>>
<<windows-vm-images>>
};
};
}
3.3.7.2.1. オプション#
Resource and disk configuration for the Windows guest. baseImage and
payloadIso are exposed as read-only options so secondary guests can
reference them without coupling to internal directory layout.
enable = lib.mkEnableOption "a Windows guest and the golden image behind it";
installer = lib.mkEnableOption ''
the install shape, where the guest writes into the golden image itself
'';
installerIso = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Windows installation ISO on the host, read only while
{option}`installer` is on.
'';
};
vcpu = lib.mkOption {
type = lib.types.ints.positive;
default = 4;
description = "Threads given to the guest.";
};
memory = lib.mkOption {
type = lib.types.ints.positive;
default = 8;
description = "Memory given to the guest, in GiB.";
};
stateDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/libvirt/windows";
description = "Directory holding the golden image and the guest's disks.";
};
sharedDirectories = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "/data" ];
description = ''
Host directories the guest reaches over virtiofs, read and write, one mount
each, tagged with the directory's own name.
'';
};
baseImage = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "${cfg.stateDir}/base.qcow2";
description = "The golden image, for guests that stack an overlay on it.";
};
payloadIso = lib.mkOption {
type = lib.types.package;
readOnly = true;
default = payloadIso;
description = ''
The disc carrying {file}`provision.ps1`, which every guest needs to set its
own D: up.
'';
};
3.3.7.2.2. Disks#
Defines disk paths for the base image, the system overlay (C:), and the persistent state disk (D:). Unlike ephemeral runner guests, this interactive guest preserves its overlay across reboots.
# libvirt chowns every disk source it opens, which fails on the ntfs-3g mount
# the ISO lives on, so the unit copies it in first.
baseImage = cfg.baseImage;
systemImage = "${cfg.stateDir}/system.qcow2";
stateImage = "${cfg.stateDir}/state.qcow2";
installIsoCopy = "${cfg.stateDir}/install.iso";
guestMac = "52:54:00:c9:a0:e4";
3.3.7.2.3. The payload disc#
Builds an ISO carrying provisioning scripts and the SPICE guest agent. The
agent version is pinned in spice-vdagent.nix rather than inline so
Renovate updates are not overwritten by tangling.
spiceVdagent = pkgs.callPackage ./spice-vdagent.nix { };
payloadIso = pkgs.runCommand "windows-vm-payload.iso" { nativeBuildInputs = [ pkgs.xorriso ]; } ''
mkdir payload
cp ${./provision.ps1} payload/provision.ps1
cp ${./bootstrap.ps1} payload/bootstrap.ps1
cp ${./share.ps1} payload/share.ps1
cp ${spiceVdagent} payload/spice-vdagent.msi
xorriso -as mkisofs -J -r -V WINVM -o $out payload
'';
3.3.7.2.4. The answer disk#
Generates a partitioned FAT disk image containing autounattend.xml (for
WinPE) and unattend.xml (for post-reboot passes). Windows Setup requires
a partitioned disk to read unattended answers and ignores ISO media. Target
disk selection is omitted to prevent unattended installation from
overwriting the wrong drive.
answerImage =
pkgs.runCommand "windows-vm-answer.img"
{
nativeBuildInputs = [
pkgs.dosfstools
pkgs.mtools
pkgs.util-linux
];
}
''
truncate -s 16M image
printf 'label: dos\n2048,,c,*\n' | sfdisk image
mkfs.fat --offset 2048 -F 16 -n ANSWER image
sed '/<settings pass="windowsPE">/,/<\/settings>/d' ${./autounattend.xml} > unattend.xml
mcopy -i image@@1M ${./autounattend.xml} ::/autounattend.xml
mcopy -i image@@1M unattend.xml ::/unattend.xml
mv image $out
'';
3.3.7.2.5. The domain#
Extends NixVirt's Windows template for both installer and normal modes. Reusing a single domain preserves NVRAM variables written during installation, ensuring the Windows boot loader entry remains intact when booting the overlay.
# The fallback model is an rtl8139, which Windows has no driver for. NetKVM
# comes off the virtio disc while WinPE is up.
# null keeps the virtio framebuffer without 3D acceleration. SPICE's OpenGL
# path only adds a failure mode, and nothing here draws hard enough to miss it.
template = nixvirt.domain.templates.windows {
name = "windows";
uuid = "e68fc8b5-3214-4c79-8469-62c76aec521b";
vcpu = {
count = cfg.vcpu;
};
memory = {
count = cfg.memory;
unit = "GiB";
};
storage_vol = if cfg.installer then baseImage else systemImage;
backing_vol = if cfg.installer then null else baseImage;
install_vol = if cfg.installer then installIsoCopy else null;
nvram_path = "${cfg.stateDir}/nvram.fd";
net_iface_mac = guestMac;
virtio_net = true;
virtio_drive = true;
virtio_video = null;
install_virtio = true;
};
# virtiofsd reads the guest's memory rather than copying through qemu, so
# qemu has to hold it somewhere another process can map.
# Per-device boot order rather than an os-level `boot dev='cdrom'`, which
# with three CDROMs attached drops OVMF into its boot manager on every start.
# libvirt rejects the two forms together.
domain =
template
// lib.optionalAttrs (cfg.sharedDirectories != [ ]) {
memoryBacking.access.mode = "shared";
}
// {
os = template.os // {
boot = [ ];
};
devices = template.devices // {
memballoon = {
model = "virtio";
freePageReporting = true;
};
# NixOS links no virtiofsd where libvirt goes looking for one, so the
# domain names the binary itself.
filesystem = map (dir: {
type = "mount";
accessmode = "passthrough";
driver.type = "virtiofs";
binary.path = "${pkgs.virtiofsd}/bin/virtiofsd";
source.dir = dir;
target.dir = builtins.baseNameOf dir;
}) cfg.sharedDirectories;
# Without the guest agent channel `virsh shutdown` reaches nothing and the
# guest can only be killed.
channel = template.devices.channel ++ [
{
type = "unix";
target = {
type = "virtio";
name = "org.qemu.guest_agent.0";
};
}
];
# The template's own three, in order: system disk, installer CDROM,
# virtio-win CDROM.
# The image sits in the store, and qemu opens a writable disk for writing,
# which the store refuses. Setup only ever reads the answer file.
disk = [
(
builtins.elemAt template.devices.disk 0
// {
boot.order = if cfg.installer then 2 else 1;
}
)
(builtins.elemAt template.devices.disk 1 // lib.optionalAttrs cfg.installer { boot.order = 1; })
(builtins.elemAt template.devices.disk 2)
{
type = "file";
device = "cdrom";
driver = {
name = "qemu";
type = "raw";
};
source.file = "${payloadIso}";
target = {
dev = "hde";
bus = "sata";
};
readonly = true;
}
{
type = "file";
device = "disk";
driver = {
name = "qemu";
type = "qcow2";
cache = "none";
discard = "unmap";
};
source.file = stateImage;
target = {
dev = "vdb";
bus = "virtio";
};
}
]
++ lib.optional cfg.installer {
type = "file";
device = "disk";
driver = {
name = "qemu";
type = "raw";
};
source.file = "${answerImage}";
target = {
dev = "sda";
bus = "usb";
};
readonly = true;
};
};
};
The domain is defined but not started automatically on host activation, as it is intended for on-demand interactive use.
# Setup only reads the answer file off a removable drive, and NixVirt's
# schema has no attribute for that. Without the flag the disk mounts and
# setup silently asks every question itself.
virtualisation.libvirt.connections."qemu:///system".domains = [
{
definition = pkgs.runCommand "windows.xml" { } ''
sed "s|<target dev='sda' bus='usb'/>|<target dev='sda' bus='usb' removable='on'/>|" \
${nixvirt.domain.writeXML domain} > $out
'';
active = null;
}
];
3.3.7.2.6. Images#
Initializes empty qcow2 images for the base image, state disk, and system
overlay on first boot. When installer is enabled, synchronizes the
installer ISO into the state directory.
# Copying an 8 GiB ISO runs well past the 90 second default.
# btrfs doing copy-on-write on top of qcow2's own fragments the image
# badly. The attribute is inherited, so it has to land before the images do.
# Sizes rather than timestamps: a truncated download leaves a copy that
# looks current.
systemd.services.windows-vm-images = {
description = "Provision disk images for the Windows guest";
wantedBy = [ "multi-user.target" ];
before = [
"libvirtd.service"
"nixvirt.service"
];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
TimeoutStartSec = "30min";
};
path = [
pkgs.e2fsprogs
pkgs.qemu-utils
];
script = ''
mkdir -p ${cfg.stateDir}
chattr +C ${cfg.stateDir} || true
test -e ${baseImage} || qemu-img create -f qcow2 ${baseImage} 80G
test -e ${stateImage} || qemu-img create -f qcow2 ${stateImage} 32G
''
+ lib.optionalString (!cfg.installer) ''
if [ -e ${baseImage} ] && [ ! -e ${systemImage} ]; then
qemu-img create -f qcow2 -F qcow2 -b ${baseImage} ${systemImage}
fi
''
+ lib.optionalString cfg.installer ''
if [ "$(stat -c %s ${cfg.installerIso})" != "$(stat -c %s ${installIsoCopy} 2>/dev/null)" ]; then
cp ${cfg.installerIso} ${installIsoCopy}
fi
'';
};
3.3.7.2.7. Provisioning#
provision.ps1 configures the guest environment: virtio drivers, QEMU guest
agent, Git, Node.js, MSVC tools, and the nightly Rust toolchain. Software is
installed via direct downloads because winget is unavailable in unattended
sessions.
Toolchain caches are directed to D: so they persist across overlay resets. Windows Update, Store app updates, and Edge's self-update are all shut off, because every boot starts from the image and a reset discards whatever they download. Desktop animations and transparency are disabled to reduce CPU overhead under the unaccelerated virtio-gpu driver. All steps are idempotent.
#Requires -RunAsAdministrator
<#
Turns a bare Windows install into the golden image, and a guest's blank
second disk into D:.
Run from the payload disc:
powershell -ExecutionPolicy Bypass -File X:\provision.ps1
#>
Re-running is safe. Each step checks for its own result first, so this is
both how the image is changed after the fact and how a new guest gets its own
toolchain onto D:.
[CmdletBinding()]
param()
$ErrorActionPreference = "Stop"
function Step($message) {
Write-Host "==> $message" -ForegroundColor Cyan
}
# Native tools write their progress to stderr, and with ErrorActionPreference
# set to Stop a single line there aborts the script. Redirecting stderr is not
# enough on its own: the preference has to come down for the call as well.
function Invoke-Native($exe, [string[]]$arguments) {
$previous = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
& $exe @arguments 2>&1 | ForEach-Object { Write-Host $_ }
}
finally {
$ErrorActionPreference = $previous
}
}
function Set-MachineVariable($name, $value) {
if ([Environment]::GetEnvironmentVariable($name, "Machine") -ne $value) {
[Environment]::SetEnvironmentVariable($name, $value, "Machine")
}
Set-Item -Path "Env:$name" -Value $value
}
$payload = (Get-Volume -FileSystemLabel WINVM).DriveLetter + ":"
# The disc has no Joliet names, so every hyphen in a filename arrives as an
# underscore.
if (-not (Get-Service -Name "QEMU-GA" -ErrorAction SilentlyContinue)) {
Step "Installing the virtio guest tools"
$tools = Get-Volume |
Where-Object DriveLetter |
ForEach-Object { Get-Item "$($_.DriveLetter):\virtio?win?gt?x64.msi" -ErrorAction SilentlyContinue } |
Select-Object -First 1
if (-not $tools) {
throw "the virtio-win disc is not attached, so there is no guest agent to install"
}
Start-Process msiexec.exe -Wait -ArgumentList @("/i", $tools.FullName, "/qn", "/norestart")
# The guest tools package installs the drivers but leaves the agent out.
$agent = Get-Item (Join-Path $tools.PSDrive.Root "guest?agent\qemu?ga?x86_64.msi")
Start-Process msiexec.exe -Wait -ArgumentList @("/i", $agent.FullName, "/qn", "/norestart")
}
# The guest tools usually carry a vdagent of their own. This stays because
# they have dropped it before, and a console you cannot paste into is miserable.
if (-not (Get-Service -Name "spice-agent", "vdservice" -ErrorAction SilentlyContinue)) {
Step "Installing the SPICE agent so the console has a clipboard"
Start-Process msiexec.exe -Wait -ArgumentList "/i `"$payload\spice-vdagent.msi`" /qn /norestart"
}
# Local accounts expire after 42 days by default, and every boot starts from
# this image, so once the image's password ages out autologon stops at the
# "password has expired" screen and the logon task never fires.
Step "Keeping the autologon password from expiring"
Set-LocalUser -Name "ci" -PasswordNeverExpires $true
Step "Installing the logon task"
New-Item -ItemType Directory -Force -Path "C:\windows-vm" | Out-Null
Copy-Item "$payload\bootstrap.ps1" "C:\windows-vm\bootstrap.ps1" -Force
# At logon as the interactive user, not at startup as SYSTEM: a job that opens a
# real window and a wgpu swapchain needs a desktop, and session 0 has none, where
# wgpu fails with "Invalid surface" before the first frame. The account autologs
# on, so this fires on every boot anyway.
Register-ScheduledTask -TaskName "windows-vm-bootstrap" -Force `
-Action (New-ScheduledTaskAction -Execute "powershell.exe" `
-Argument "-NoProfile -ExecutionPolicy Bypass -File C:\windows-vm\bootstrap.ps1") `
-Trigger (New-ScheduledTaskTrigger -AtLogOn -User "ci") `
-Principal (New-ScheduledTaskPrincipal -UserId "ci" -LogonType Interactive -RunLevel Highest) `
-Settings (New-ScheduledTaskSettingsSet -ExecutionTimeLimit 0 -RestartCount 3 `
-RestartInterval (New-TimeSpan -Minutes 1)) |
Out-Null
# -StateOnly, because the guest's own bootstrap may never return: on the runner
# guest it is the daemon.
# The disc the script itself came from moved with the other optical drives.
Step "Preparing the state disk"
& "C:\windows-vm\bootstrap.ps1" -StateOnly
$payload = (Get-Volume -FileSystemLabel WINVM).DriveLetter + ":"
Step "Pointing the toolchain caches at D:"
Set-MachineVariable "RUSTUP_HOME" "D:\rustup"
Set-MachineVariable "CARGO_HOME" "D:\cargo"
$machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine")
if ($machinePath -notlike "*D:\cargo\bin*") {
[Environment]::SetEnvironmentVariable("Path", "$machinePath;D:\cargo\bin", "Machine")
}
$env:Path = "$env:Path;D:\cargo\bin"
# winget is an AppX package, so it is missing or unusable depending on which
# account runs the script and whether the Store has updated App Installer.
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
function Get-Installer($url, $name) {
$path = Join-Path $env:TEMP $name
if (-not (Test-Path $path)) {
Invoke-WebRequest -Uri $url -OutFile $path -UseBasicParsing
}
return $path
}
if (-not (Test-Path "C:\Program Files\Git\cmd\git.exe")) {
Step "Installing Git for Windows"
$asset = (Invoke-RestMethod "https://api.github.com/repos/git-for-windows/git/releases/latest").assets |
Where-Object name -like "*-64-bit.exe" |
Select-Object -First 1
$installer = Get-Installer $asset.browser_download_url $asset.name
Invoke-Native $installer @("/VERYSILENT", "/NORESTART", "/NOCANCEL", "/SP-", "/SUPPRESSMSGBOXES")
}
$env:Path = "$env:Path;C:\Program Files\Git\cmd"
# The Forgejo runner executes JS actions (actions/checkout above all) with a node
# it finds on the host PATH; without one every job dies at its first step with
# "Cannot find: node in PATH" before any tool of ours runs.
if (-not (Test-Path "C:\Program Files\nodejs\node.exe")) {
Step "Installing Node.js"
$lts = (Invoke-RestMethod "https://nodejs.org/dist/index.json") |
Where-Object lts |
Select-Object -First 1
$installer = Get-Installer "https://nodejs.org/dist/$($lts.version)/node-$($lts.version)-x64.msi" "node-$($lts.version)-x64.msi"
Start-Process msiexec.exe -Wait -ArgumentList @("/i", $installer, "/qn", "/norestart")
}
$env:Path = "$env:Path;C:\Program Files\nodejs"
# The machine PATH already carries D:\cargo\bin, and letting rustup edit
# the user PATH would put the entry on C:, where a reset erases it.
if (-not (Test-Path "D:\cargo\bin\rustup.exe")) {
Step "Installing rustup with the nightly toolchain"
$installer = Get-Installer "https://static.rust-lang.org/rustup/dist/x86_64-pc-windows-msvc/rustup-init.exe" "rustup-init.exe"
Invoke-Native $installer @("-y", "--no-modify-path", "--default-toolchain", "nightly", "--profile", "default")
if ($LASTEXITCODE -ne 0) {
throw "rustup-init failed (exit $LASTEXITCODE)"
}
}
# 3010 means it wants a reboot, which is not a failure.
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
$hasVCTools = (Test-Path $vswhere) -and
(& $vswhere -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath)
if (-not $hasVCTools) {
Step "Installing the MSVC build tools (this takes a while)"
$installer = Get-Installer "https://aka.ms/vs/17/release/vs_BuildTools.exe" "vs_BuildTools.exe"
Invoke-Native $installer @("--quiet", "--wait", "--norestart", "--nocache",
"--add", "Microsoft.VisualStudio.Workload.VCTools", "--includeRecommended")
if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne 3010) {
throw "vs_BuildTools failed (exit $LASTEXITCODE)"
}
}
Step "Pinning nightly as the default toolchain"
Invoke-Native "D:\cargo\bin\rustup.exe" @("default", "nightly")
# The prebuilt binary takes seconds, where `cargo install` rebuilds
# nextest and everything under it from source.
if (-not (Test-Path "D:\cargo\bin\cargo-nextest.exe")) {
Step "Installing cargo-nextest"
$archive = Join-Path $env:TEMP "cargo-nextest.zip"
Invoke-WebRequest -Uri "https://get.nexte.st/latest/windows" -OutFile $archive
Expand-Archive -Path $archive -DestinationPath "D:\cargo\bin" -Force
Remove-Item $archive
}
# A reset throws away whatever Windows Update installed on a guest that resets,
# and a reboot taken mid-job is a failed job.
# NoAutoUpdate alone still lets the Update Orchestrator scan and download on
# its own tasks, the post-OOBE expedite among them, so the client is also
# pointed at a WSUS that does not exist.
Step "Keeping the guest awake and out of Windows Update's way"
$wu = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate"
New-Item -Path "$wu\AU" -Force | Out-Null
Set-ItemProperty -Path "$wu\AU" -Name "NoAutoUpdate" -Value 1 -Type DWord
Set-ItemProperty -Path $wu -Name "WUServer" -Value "http://127.0.0.1:1"
Set-ItemProperty -Path $wu -Name "WUStatusServer" -Value "http://127.0.0.1:1"
Set-ItemProperty -Path $wu -Name "DoNotConnectToWindowsUpdateInternetLocations" -Value 1 -Type DWord
Set-ItemProperty -Path "$wu\AU" -Name "UseWUServer" -Value 1 -Type DWord
$store = "HKLM:\SOFTWARE\Policies\Microsoft\WindowsStore"
New-Item -Path $store -Force | Out-Null
Set-ItemProperty -Path $store -Name "AutoDownload" -Value 2 -Type DWord
# Edge updates itself outside Windows Update, and its EdgeUpdate policies are
# honoured only on domain-joined or MDM-enrolled machines.
Get-ScheduledTask -TaskName "MicrosoftEdgeUpdateTask*" | Disable-ScheduledTask | Out-Null
Get-Service -Name "edgeupdate", "edgeupdatem" -ErrorAction SilentlyContinue |
Set-Service -StartupType Disabled
Invoke-Native powercfg @("/change", "standby-timeout-ac", "0")
Invoke-Native powercfg @("/change", "hibernate-timeout-ac", "0")
Invoke-Native powercfg @("/change", "monitor-timeout-ac", "0")
# Written into the account's own hive rather than through HKCU, because the
# script runs as SYSTEM when it is driven through the guest agent.
Step "Turning the desktop effects off"
$sid = (Get-CimInstance Win32_UserAccount -Filter "Name='ci' AND LocalAccount=True").SID
New-PSDrive -Name HKU -PSProvider Registry -Root HKEY_USERS -ErrorAction SilentlyContinue | Out-Null
if (-not (Test-Path "HKU:\$sid")) {
throw "ci is not logged on, so its hive is not loaded and there is nothing to write to"
}
function Set-UserValue($path, $name, $value, $type) {
$full = "HKU:\$sid\$path"
if (-not (Test-Path $full)) {
New-Item -Path $full -Force | Out-Null
}
Set-ItemProperty -Path $full -Name $name -Value $value -Type $type
}
# ClearType stays on. Unreadable text costs more than the cycles it saves.
Set-UserValue "Software\Microsoft\Windows\CurrentVersion\Explorer\VisualEffects" "VisualFXSetting" 2 DWord
Set-UserValue "Control Panel\Desktop" "UserPreferencesMask" ([byte[]](0x90, 0x12, 0x03, 0x80, 0x10, 0x00, 0x00, 0x00)) Binary
Set-UserValue "Control Panel\Desktop" "DragFullWindows" "0" String
Set-UserValue "Control Panel\Desktop" "MenuShowDelay" "0" String
Set-UserValue "Control Panel\Desktop\WindowMetrics" "MinAnimate" "0" String
Set-UserValue "Control Panel\Desktop" "FontSmoothing" "2" String
foreach ($name in "TaskbarAnimations", "ListviewAlphaSelect", "ListviewShadow") {
Set-UserValue "Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" $name 0 DWord
}
Set-UserValue "Software\Microsoft\Windows\DWM" "EnableAeroPeek" 0 DWord
Set-UserValue "Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" "EnableTransparency" 0 DWord
Step "Done"
bootstrap.ps1 runs at user logon to partition and format the state disk as
D: on first boot and remount optical drives. It then invokes
D:\bootstrap.ps1 if present, allowing guests to run custom startup tasks.
<#
Runs at every logon, from the copy the golden image carries at
C:\windows-vm\bootstrap.ps1.
#>
-StateOnly stops after D: is ready, for callers that have their own idea of
what comes next.
[CmdletBinding()]
param(
[switch]$StateOnly
)
$ErrorActionPreference = "Stop"
# The optical drives take D: and up before any disk gets a letter, so D: is not
# free until they move.
# Released first, because the letters they start on overlap the ones they end
# on, and asking for a letter another drive still holds fails with
# "Not available".
$optical = @(Get-CimInstance Win32_Volume -Filter "DriveType = 5")
$optical | ForEach-Object { $_ | Set-CimInstance -Property @{ DriveLetter = $null } }
$letter = 90
$optical | ForEach-Object {
$_ | Set-CimInstance -Property @{ DriveLetter = ([char]$letter + ":") }
$letter--
}
# Picking the disk by "not the one holding C:" rather than by RAW, because
# a half-finished attempt leaves the disk initialized but empty, and a RAW
# filter then matches nothing and silently skips the whole step. USB is out
# because the answer disk is one, and it is attached whenever the image
# itself is being worked on.
$state = Get-Volume | Where-Object FileSystemLabel -eq "state" | Select-Object -First 1
if (-not $state) {
$systemDisk = (Get-Partition -DriveLetter C).DiskNumber
$candidates = @(Get-Disk | Where-Object { $_.Number -ne $systemDisk -and $_.BusType -ne "USB" })
if ($candidates.Count -ne 1) {
throw "expected exactly one non-system disk, found $($candidates.Count)"
}
$disk = $candidates[0]
# Windows records the letter against a disk signature, and the image has
# never seen this guest's disk, so a guest other than the one the image was
# made on comes up with the state disk somewhere else.
if ($disk.PartitionStyle -eq "RAW") {
Initialize-Disk -Number $disk.Number -PartitionStyle GPT | Out-Null
}
Get-Partition -DiskNumber $disk.Number -ErrorAction SilentlyContinue |
Where-Object Type -ne "Reserved" |
Remove-Partition -Confirm:$false
New-Partition -DiskNumber $disk.Number -DriveLetter D -UseMaximumSize |
Format-Volume -FileSystem NTFS -NewFileSystemLabel state -Confirm:$false |
Out-Null
}
elseif ($state.DriveLetter -ne "D") {
Set-Partition -DriveLetter $state.DriveLetter -NewDriveLetter D
}
if (-not (Test-Path "D:\")) {
throw "D: is missing, so anything written there would land on C: and die with the next reset"
}
if (-not $StateOnly -and (Test-Path "D:\bootstrap.ps1")) {
& "D:\bootstrap.ps1"
}
3.3.7.2.9. Installing Windows#
To install the golden image, set installerIso to an official Windows
installation ISO, enable installer = true, and boot the guest.
sudo nixos-rebuild switch --flake .
virsh -c qemu:///system start windows
virt-viewer -c qemu:///system windows
Send a key press to satisfy the initial CD-ROM boot prompt. Setup will
automatically format the disk according to the answer file and execute
provision.ps1 on first logon; only the target disk needs to be selected
manually.
virsh -c qemu:///system send-key windows --holdtime 150 KEY_SPACE
virsh -c qemu:///system screenshot windows shot.ppm
Once installation finishes, disable installer and rebuild. The domain will
then boot from its overlay disk. Future updates to the golden image can be
made by enabling installer again and re-running provision.ps1.
3.3.8. Continuous Integration#
3.3.8.1. Forgejo Actions Runner#
My Forgejo instance dispatches Actions jobs to registered runners. Linux
hosts use nixpkgs' services.forgejo-runner; macOS has no such module, so I
add services.forgejo-runner for Darwin, driven by launchd and shaped like
the NixOS option so a runner reads the same on either platform.
I split this into three modules. The nix-darwin mechanism is neutral, so I
publish it as flake.darwinModules.forgejo-runner via
exports.nix. The my.services.forgejo-runner wrappers
bake my defaults (instance URL, labels, niks3 client) and live in the
internal flake.modules.{darwin,nixos} registry. A host enables a wrapper
and supplies its own registration token. The Windows
guest hangs off the same directory, and is imported by hand because a
directory with a default.nix is one module.
{ ... }:
let
<<forgejo-runner-wrapper-shared>>
<<forgejo-runner-wrappers>>
in
{
imports = [ ./windows ];
flake.modules.darwin.forgejo-runner = darwinWrapper;
flake.modules.nixos.forgejo-runner = nixosWrapper;
}
3.3.8.1.1. nix-darwin module#
This module carries nothing personal — just services.forgejo-runner,
mirroring the instance schema nixpkgs' services.gitea-actions-runner used
(token=/=tokenFile, labels, freeform settings). Each enabled instance
becomes a launchd job that registers, then execs the daemon. I record a hash
of token plus labels in .registration-state so registration re-runs
when either changes; the token is reusable, so re-registering is fine.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.forgejo-runner;
settingsFormat = pkgs.formats.yaml { };
<<forgejo-runner-instance-submodule>>
enabledInstances = lib.filterAttrs (_: i: i.enable) cfg.instances;
in
{
<<forgejo-runner-darwin-options>>
<<forgejo-runner-darwin-config>>
}
- Instance options
An instance carries its registration details and the tools its jobs need on
PATH. Exactly one oftokenortokenFilesupplies the token, matching the NixOS module. Host-executed jobs inherit only the daemon's environment, so every tool a workflow expects must be listed inhostPackages.<<forgejo-runner-instance-submodule>>instanceModule = { name, ... }: { options = { enable = lib.mkEnableOption "this runner instance"; name = lib.mkOption { type = lib.types.str; default = name; defaultText = lib.literalExpression "the attribute name"; description = "Name the runner registers under."; }; url = lib.mkOption { type = lib.types.str; example = "https://git.example.com"; description = "Address of the Forgejo instance the runner registers with."; }; token = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = '' Registration token as a plaintext string. Mutually exclusive with {option}`tokenFile`. Prefer {option}`tokenFile`: a token set here ends up in the world-readable launchd plist and the Nix store. ''; }; tokenFile = lib.mkOption { type = lib.types.nullOr (lib.types.either lib.types.str lib.types.path); default = null; description = '' Path to an environment file holding the registration token as `TOKEN=...`. Mutually exclusive with {option}`token`. The token is read on first registration and whenever it or {option}`labels` change; otherwise the persisted {file}`.runner` credential in {option}`stateDir` is used. ''; }; labels = lib.mkOption { type = lib.types.listOf lib.types.str; example = [ "aarch64-darwin:host" ]; description = "Labels the runner advertises."; }; settings = lib.mkOption { type = settingsFormat.type; default = { }; description = '' Runner configuration written to {file}`config.yaml` and passed to the daemon with `--config`. ''; }; hostPackages = lib.mkOption { type = lib.types.listOf lib.types.package; default = with pkgs; [ bash coreutils curl gawk gitMinimal gnused nodejs wget ]; defaultText = lib.literalExpression '' with pkgs; [ bash coreutils curl gawk gitMinimal gnused nodejs wget ] ''; description = '' Packages put on PATH for host-executed jobs. Host jobs inherit only the daemon's PATH, so every tool a workflow expects must be listed. ''; }; stateDir = lib.mkOption { type = lib.types.str; default = "/var/lib/forgejo-runner/${name}"; defaultText = lib.literalExpression ''"/var/lib/forgejo-runner/<name>"''; description = "Directory holding the runner credential and working files."; }; }; }; - Module options
The daemons run as a dedicated unprivileged user, not root: a root daemon is a trusted-user client of the nix daemon, whose post-build-hook push path the multi-user daemon ignores — so nothing reaches my binary cache. An untrusted user takes the store-scan push path that actually uploads. I may revisit making it a trusted user once the post-build-hook path works for me.
<<forgejo-runner-darwin-options>>options.services.forgejo-runner = { package = lib.mkPackageOption pkgs "forgejo-runner" { }; user = lib.mkOption { type = lib.types.str; default = "_forgejo-runner"; description = "Dedicated unprivileged user the runner daemons execute as."; }; group = lib.mkOption { type = lib.types.str; default = "_forgejo-runner"; description = "Primary group for the runner user."; }; uid = lib.mkOption { type = lib.types.int; default = 530; description = "UID for the runner user (free system-range id on the host)."; }; gid = lib.mkOption { type = lib.types.int; default = 530; description = "GID for the runner group."; }; instances = lib.mkOption { type = lib.types.attrsOf (lib.types.submodule instanceModule); default = { }; description = "Runner instances; each enabled one becomes a launchd daemon."; }; }; - 設定
<<forgejo-runner-darwin-config>>config = lib.mkIf (enabledInstances != { }) { assertions = lib.mapAttrsToList (_: instance: { assertion = (instance.token == null) != (instance.tokenFile == null); message = "services.forgejo-runner.instances.${instance.name}: set exactly one of token or tokenFile."; }) enabledInstances; <<forgejo-runner-daemons>> <<forgejo-runner-user>> <<forgejo-runner-ownership>> };- Launchd daemons
One launchd job per enabled instance. launchd has no
EnvironmentFile, so atokenFileinstance sources it in-script while an inlinetokenrides onEnvironmentVariables; either way the script sees$TOKEN. I prependcoreutilstoPATHso the script's ownsha256sum=/=cutresolve even if a host trimshostPackages. Two launchd quirks shape each:WorkingDirectorycannot be the state directory: launchd chdir()s into it before exec, so on first boot the spawn aborts withEX_CONFIG(78) before the script can create it. The script sets its own cwd instead.- The logs live in the user-owned state directory, not
/var/log: launchd opens them asUserName, and an unprivileged daemon cannot create a root-owned file under/var/log, so it would fail to spawn — silently, before any log line.
<<forgejo-runner-daemons>>launchd.daemons = lib.mapAttrs' ( _: instance: let configFile = settingsFormat.generate "config.yaml" instance.settings; in lib.nameValuePair "forgejo-runner-${instance.name}" { script = '' set -eu mkdir -p ${lib.escapeShellArg instance.stateDir} cd ${lib.escapeShellArg instance.stateDir} ${lib.optionalString (instance.tokenFile != null) '' set -a . ${lib.escapeShellArg (toString instance.tokenFile)} set +a ''} labels=${lib.escapeShellArg (lib.concatStringsSep "," instance.labels)} state="$(printf '%s' "$TOKEN" | sha256sum | cut -d' ' -f1) $labels" if [ ! -f .runner ] || [ "$(cat .registration-state 2>/dev/null)" != "$state" ]; then rm -f .runner ${lib.getExe cfg.package} register \ --no-interactive \ --instance ${lib.escapeShellArg instance.url} \ --token "$TOKEN" \ --name ${lib.escapeShellArg instance.name} \ --labels "$labels" \ --config ${configFile} printf '%s' "$state" > .registration-state fi exec ${lib.getExe cfg.package} daemon --config ${configFile} ''; serviceConfig = { UserName = cfg.user; GroupName = cfg.group; KeepAlive = true; RunAtLoad = true; ProcessType = "Background"; EnvironmentVariables = { HOME = instance.stateDir; PATH = "${ lib.makeBinPath ([ pkgs.coreutils ] ++ instance.hostPackages) }:/usr/bin:/bin:/usr/sbin:/sbin"; } // lib.optionalAttrs (instance.token != null) { TOKEN = instance.token; }; StandardOutPath = "${instance.stateDir}/forgejo-runner.log"; StandardErrorPath = "${instance.stateDir}/forgejo-runner.err.log"; }; } ) enabledInstances; - Runner user
nix-darwin only manages accounts listed in
users.knownUsers/users.knownGroups, so I add the entries.I leave
createHomeoff: it runscreatehomedir, which resolves the/varfirmlink and records the home as/private/var/…, then nix-darwin string-compares that against the configuredhomeand aborts activation on the mismatch. The daemon uses its state directory asHOMEanyway, created and owned below.<<forgejo-runner-user>>users.users.${cfg.user} = { uid = cfg.uid; gid = cfg.gid; isHidden = true; description = "Forgejo Actions runner"; }; users.groups.${cfg.group}.gid = cfg.gid; users.knownUsers = [ cfg.user ]; users.knownGroups = [ cfg.group ]; - State ownership
Each state directory must exist and belong to the runner user before its daemon starts. With
createHomeoff nothing else creates it, so activation makes and chowns itmkBeforethe launchd step that loads the daemons.<<forgejo-runner-ownership>>system.activationScripts.launchd.text = lib.mkBefore ( lib.concatMapStringsSep "\n" (instance: '' mkdir -p ${lib.escapeShellArg instance.stateDir} chown -R ${lib.escapeShellArg cfg.user}:${lib.escapeShellArg cfg.group} ${lib.escapeShellArg instance.stateDir} '') (lib.attrValues enabledInstances) );
- Launchd daemons
3.3.8.1.2. Opinionated wrappers#
The two platforms drive different modules under the same
services.forgejo-runner name. My my.services.forgejo-runner options give
enable, url (defaulting to my Forgejo instance), labels, and a
tokenFile path holding a registration token. The host owns each secret and
hands only its path. The nix-darwin module registers itself with that token,
and mkInstance builds its instance.
The NixOS module no longer registers. It takes the uuid and runner token
of an existing registration, so the NixOS wrapper adds uuid and
runnerTokenFile. On kilimanjaro both come from the .runner file the old
module wrote. The registration tokenFile stays for the Windows guest,
which still registers on first boot. Each wrapper also sets the labels
default, hostPackages, and, on NixOS, the package and Docker for container
jobs. Both push to my binary cache, so each hostPackages adds nix and
the niks3 client. A host label names the Nix system its jobs run on
(x86_64-linux, aarch64-darwin, x86_64-windows), so a workflow's
runs-on states the platform it needs.
systemd's DynamicUser makes /var/lib/forgejo-runner a symlink to
/var/lib/private/forgejo-runner. actions/checkout configures git
credentials with an includeIf.gitdir rule matching the symlinked path,
while git resolves the working directory to the real path, so the rule
never matches and git push fails to authenticate. Setting
host.workdir_parent to the real path keeps both sides on the same path.
wrapperOptions =
{ lib, ... }:
{
options.my.services.forgejo-runner = {
enable = lib.mkEnableOption "Forgejo Actions runner";
url = lib.mkOption {
type = lib.types.str;
default = "https://git.natsukium.com";
description = "Forgejo instance the runner registers with.";
};
tokenFile = lib.mkOption {
type = lib.types.path;
description = ''
Path to an environment file holding the registration token as
`TOKEN=...`. The host supplies this — typically a secret-manager path —
so the feature stays free of any secret-store assumption.
'';
};
labels = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Labels the runner advertises; each platform sets a default.";
};
};
};
mkInstance = config: hostPackages: {
enable = true;
name = config.networking.hostName;
url = config.my.services.forgejo-runner.url;
tokenFile = config.my.services.forgejo-runner.tokenFile;
labels = config.my.services.forgejo-runner.labels;
inherit hostPackages;
};
darwinWrapper =
{
config,
lib,
pkgs,
inputs,
...
}:
{
imports = [
./darwin-module.nix
wrapperOptions
];
config = lib.mkIf config.my.services.forgejo-runner.enable {
my.services.forgejo-runner.labels = lib.mkDefault [
"aarch64-darwin:host"
];
services.forgejo-runner.instances.default = mkInstance config (
with pkgs;
[
bash
curl
git
nix
nodejs
toybox
inputs.niks3.packages.${pkgs.stdenv.hostPlatform.system}.niks3
]
);
};
};
nixosWrapper =
{
config,
lib,
pkgs,
inputs,
...
}:
let
cfg = config.my.services.forgejo-runner;
in
{
imports = [ wrapperOptions ];
options.my.services.forgejo-runner = {
uuid = lib.mkOption {
type = lib.types.str;
description = "UUID of the runner as registered with Forgejo.";
};
runnerTokenFile = lib.mkOption {
type = lib.types.path;
description = "Path to a file holding only the runner token paired with {option}`uuid`.";
};
};
config = lib.mkIf cfg.enable {
my.services.forgejo-runner.labels = lib.mkDefault [
"ubuntu-latest:docker://node:22-bookworm"
"x86_64-linux:host"
];
services.forgejo-runner = {
package = pkgs.forgejo-runner;
instances.default = {
enable = true;
settings = {
runner.labels = cfg.labels;
host.workdir_parent = "/var/lib/private/forgejo-runner/default/.cache/act";
server.connections.default = {
inherit (cfg) url uuid;
};
};
secrets.server.connections.default.token_url = cfg.runnerTokenFile;
hostPackages = with pkgs; [
bash
(lib.hiPrio gnutar)
coreutils
busybox
curl
git
nix
nodejs
inputs.niks3.packages.${pkgs.stdenv.hostPlatform.system}.niks3
];
};
};
virtualisation.docker.enable = true;
};
};
3.3.8.1.3. Windows guest#
Declares a Forgejo Actions runner hosted on a Windows guest, reusing the golden image from the Windows VM module.
The guest boots from an ephemeral overlay recreated on each VM start via a libvirt hook. A persistent secondary disk (D:) preserves the runner credentials, toolchains, and cargo caches across resets.
{ inputs, ... }:
{
flake.modules.nixos.forgejo-runner-windows =
{
config,
lib,
pkgs,
...
}:
let
nixvirt = inputs.nixvirt.lib;
runner = config.my.services.forgejo-runner;
cfg = runner.windows;
windows = config.my.services.windows-vm;
<<forgejo-windows-disks>>
<<forgejo-windows-payload>>
<<forgejo-windows-domain>>
in
{
options.my.services.forgejo-runner.windows = {
<<forgejo-windows-options>>
};
# The guest is an overlay of that feature's golden image, so it cannot be
# had without it.
config = lib.mkIf cfg.enable {
my.services.windows-vm.enable = true;
my.virtualisation.libvirt.dhcpHosts = [
{
mac = guestMac;
name = "windows-ci";
ip = "192.168.122.11";
}
];
<<forgejo-windows-libvirt>>
<<forgejo-windows-images>>
<<forgejo-windows-seed>>
<<forgejo-windows-reset>>
};
};
}
- オプション
Runner identity and token settings are inherited from the parent Forgejo runner configuration. Resource allocations default to half of host threads and a quarter of RAM to avoid starving other host services during builds.
<<forgejo-windows-options>>enable = lib.mkEnableOption "a Windows guest registered as a Forgejo Actions runner"; name = lib.mkOption { type = lib.types.str; default = "${config.networking.hostName}-windows"; defaultText = lib.literalExpression ''"''${config.networking.hostName}-windows"''; description = "Name the runner registers under."; }; labels = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ "x86_64-windows:host" ]; description = '' Labels the runner advertises. Only `host` labels make sense here: a `docker` label would need Windows containers, which means Docker Desktop and its licence. ''; }; vcpu = lib.mkOption { type = lib.types.ints.positive; default = 6; description = "Threads given to the guest."; }; memory = lib.mkOption { type = lib.types.ints.positive; default = 16; description = "Memory given to the guest, in GiB."; }; - Disks
Configures guest disk paths and MAC address. The state disk persists the runner registration and toolchain caches across resets. NVRAM variables are cloned from the base Windows guest to retain EFI boot entries.
<<forgejo-windows-disks>>baseImage = windows.baseImage; systemImage = "${windows.stateDir}/ci-system.qcow2"; stateImage = "${windows.stateDir}/ci-state.qcow2"; nvram = "${windows.stateDir}/ci-nvram.fd"; seedIso = "${windows.stateDir}/ci-seed.iso"; guestMac = "52:54:00:c9:a0:e5"; - The runner disc
Builds an ISO containing the Windows runner binary and setup scripts. The runner is cross-compiled with Go rather than
pkgsCross.mingwW64, whose build hooks fail on mingw dependencies.Copying the binary to
D:\runneron each boot ensures that updates pinned in the flake take effect even though D: survives VM resets.<<forgejo-windows-payload>>forgejoRunnerExe = pkgs.forgejo-runner.overrideAttrs (old: { env = old.env // { GOOS = "windows"; GOARCH = "amd64"; CGO_ENABLED = 0; }; postInstall = ""; doCheck = false; doInstallCheck = false; }); registerScript = pkgs.replaceVars ./register.ps1 { instanceUrl = runner.url; runnerName = cfg.name; labels = lib.concatStringsSep "," cfg.labels; }; runnerIso = pkgs.runCommand "windows-ci-runner.iso" { nativeBuildInputs = [ pkgs.xorriso ]; } '' mkdir runner cp ${forgejoRunnerExe}/bin/windows_amd64/runner.exe runner/forgejo-runner.exe cp ${registerScript} runner/register.ps1 cp ${./bootstrap.ps1} runner/bootstrap.ps1 xorriso -as mkisofs -J -r -V WINRUN -o $out runner ''; - The domain
Extends the Windows NixVirt template with the runner, payload, and seed ISOs, plus the persistent state disk.
<<forgejo-windows-domain>># null keeps the virtio framebuffer without 3D acceleration. A CI guest # renders nothing, so SPICE's OpenGL path only adds a failure mode. template = nixvirt.domain.templates.windows { name = "windows-ci"; uuid = "5cca2b52-0eb3-4a43-8ec8-778ebeaa3fcc"; vcpu = { count = cfg.vcpu; }; memory = { count = cfg.memory; unit = "GiB"; }; storage_vol = systemImage; backing_vol = baseImage; nvram_path = nvram; net_iface_mac = guestMac; virtio_net = true; virtio_drive = true; virtio_video = null; }; # Per-device boot order rather than an os-level `boot dev='cdrom'`, which # with three CDROMs attached drops OVMF into its boot manager on every start. # libvirt rejects the two forms together. domain = template // { os = template.os // { boot = [ ]; }; devices = template.devices // { memballoon = { model = "virtio"; freePageReporting = true; }; # Without the guest agent channel `virsh shutdown` reaches nothing and the # guest can only be killed. channel = template.devices.channel ++ [ { type = "unix"; target = { type = "virtio"; name = "org.qemu.guest_agent.0"; }; } ]; # The template's own two, in order: system disk and the installer CDROM it # emits empty. disk = [ ( builtins.elemAt template.devices.disk 0 // { boot.order = 1; } ) (builtins.elemAt template.devices.disk 1) { type = "file"; device = "cdrom"; driver = { name = "qemu"; type = "raw"; }; source.file = "${windows.payloadIso}"; target = { dev = "hde"; bus = "sata"; }; readonly = true; } { type = "file"; device = "cdrom"; driver = { name = "qemu"; type = "raw"; }; source.file = "${runnerIso}"; target = { dev = "hdf"; bus = "sata"; }; readonly = true; } { type = "file"; device = "cdrom"; driver = { name = "qemu"; type = "raw"; }; source = { file = seedIso; startupPolicy = "optional"; }; target = { dev = "hdg"; bus = "sata"; }; readonly = true; } { type = "file"; device = "disk"; driver = { name = "qemu"; type = "qcow2"; cache = "none"; discard = "unmap"; }; source.file = stateImage; target = { dev = "vdb"; bus = "virtio"; }; } ]; }; };Started automatically on activation so the runner remains available.
<<forgejo-windows-libvirt>>virtualisation.libvirt.connections."qemu:///system".domains = [ { definition = nixvirt.domain.writeXML domain; active = true; } ]; - Images
Creates the persistent state disk if missing and seeds NVRAM firmware variables from the base Windows guest.
<<forgejo-windows-images>>systemd.services.windows-ci-images = { description = "Provision disk images for the Windows CI guest"; wantedBy = [ "multi-user.target" ]; before = [ "libvirtd.service" "nixvirt.service" ]; after = [ "windows-vm-images.service" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; path = [ pkgs.qemu-utils ]; script = '' test -e ${stateImage} || qemu-img create -f qcow2 ${stateImage} 32G test ! -e ${windows.stateDir}/nvram.fd || test -e ${nvram} || \ cp ${windows.stateDir}/nvram.fd ${nvram} ''; }; - Seed disc
Generates a root-only ISO containing the registration token on activation, keeping credentials out of the Nix store and guest command history.
<<forgejo-windows-seed>>systemd.services.windows-ci-seed = { description = "Hand the Forgejo registration token to the Windows CI guest"; wantedBy = [ "multi-user.target" ]; before = [ "libvirtd.service" "nixvirt.service" ]; after = [ "windows-ci-images.service" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; path = [ pkgs.xorriso ]; script = '' dir=$(mktemp -d) trap 'rm -rf "$dir"' EXIT sed -n 's/^TOKEN=//p' ${runner.tokenFile} > "$dir/token.txt" xorriso -as mkisofs -J -r -V WINCISEED -o ${seedIso}.new "$dir" chmod 0400 ${seedIso}.new mv ${seedIso}.new ${seedIso} ''; }; - Reset
A libvirt QEMU hook recreates the system overlay before the VM starts, ensuring each run begins from a clean state. Guest-initiated reboots retain the overlay; a domain stop and start via libvirt is required to reset it.
<<forgejo-windows-reset>>virtualisation.libvirtd.hooks.qemu.windows-ci = pkgs.writeShellScript "windows-ci-reset-system-disk" '' if [ "$1" != windows-ci ] || [ "$2" != prepare ] || [ "$3" != begin ]; then exit 0 fi rm -f ${systemImage} ${pkgs.qemu-utils}/bin/qemu-img create -f qcow2 -F qcow2 -b ${baseImage} ${systemImage} ''; - Registering the runner
Initializes a new state disk by running
provision.ps1to install toolchains, followed byregister.ps1to register the runner using the token from the seed disc.powershell$payload = (Get-Volume -FileSystemLabel WINVM).DriveLetter + ":" powershell -ExecutionPolicy Bypass -File "$payload\provision.ps1" $runner = (Get-Volume -FileSystemLabel WINRUN).DriveLetter + ":" powershell -ExecutionPolicy Bypass -File "$runner\register.ps1"#Requires -RunAsAdministrator <# Makes this guest the Forgejo Actions runner. Run once from the runner disc: powershell -ExecutionPolicy Bypass -File X:\register.ps1 #> Everything it writes goes to D:, which outlives the reset of C: on every start. [CmdletBinding()] param( [string]$RunnerToken, [string]$InstanceUrl = "@instanceUrl@", [string]$RunnerName = "@runnerName@", [string]$Labels = "@labels@" ) $ErrorActionPreference = "Stop" function Step($message) { Write-Host "==> $message" -ForegroundColor Cyan } # Native tools write their progress to stderr, and with ErrorActionPreference # set to Stop a single line there aborts the script. Redirecting stderr is not # enough on its own: the preference has to come down for the call as well. function Invoke-Native($exe, [string[]]$arguments) { $previous = $ErrorActionPreference $ErrorActionPreference = "Continue" try { & $exe @arguments 2>&1 | ForEach-Object { Write-Host $_ } } finally { $ErrorActionPreference = $previous } } $runner = (Get-Volume -FileSystemLabel WINRUN).DriveLetter + ":" if (-not (Test-Path "D:\")) { throw "D: is missing, so the runner would land on C: and die with the next reset" } Step "Installing the startup script" New-Item -ItemType Directory -Force -Path "D:\runner" | Out-Null Copy-Item "$runner\bootstrap.ps1" "D:\bootstrap.ps1" -Force if (-not $RunnerToken) { $seed = Get-Volume -FileSystemLabel WINCISEED -ErrorAction SilentlyContinue if ($seed) { $RunnerToken = (Get-Content "$($seed.DriveLetter):\token.txt" -Raw).Trim() } } # The token stays valid after use, so registering again does not replace # the runner, it adds a second one that Forgejo then lists forever. # Registration writes .runner next to the working directory, so it has to # run from D: for the credential to survive a reset. if (Test-Path "D:\runner\.runner") { Step "The runner is already registered" } elseif ($RunnerToken) { Step "Registering the runner" Copy-Item "$runner\forgejo-runner.exe" "D:\runner\forgejo-runner.exe" -Force Push-Location "D:\runner" try { Invoke-Native "D:\runner\forgejo-runner.exe" @("register", "--no-interactive", "--instance", $InstanceUrl, "--token", $RunnerToken, "--name", $RunnerName, "--labels", $Labels) } finally { Pop-Location } } else { throw "no token given and no seed disc found, so there is nothing to register with" } Step "Done"Leaves
D:\bootstrap.ps1, which the logon task executes on each boot to run the runner daemon.#> <# Runs at every logon, from the copy register.ps1 left at D:\bootstrap.ps1. $ErrorActionPreference = "Stop" # register.ps1 runs only once, so without this a new version of this script # never reaches D:. $runner = (Get-Volume -FileSystemLabel WINRUN).DriveLetter + ":" Copy-Item "$runner\forgejo-runner.exe" "D:\runner\forgejo-runner.exe" -Force Copy-Item "$runner\bootstrap.ps1" "D:\bootstrap.ps1" -Force # The daemon exits when Forgejo is unreachable, as it is when logon beats the # network, and nothing else starts it again. cmd does the redirection because # PowerShell would turn the daemon's stderr into errors. Set-Location "D:\runner" $ErrorActionPreference = "Continue" while ($true) { cmd /c "D:\runner\forgejo-runner.exe daemon >> D:\runner\daemon.log 2>&1" Start-Sleep -Seconds 10 }
3.3.9. Devices#
Peripherals that move between machines belong here, not in
hardware-configuration, which describes a host's fixed internals. Each
device is a switch under my.devices.*, exposed through the
flake.modules.nixos.devices registry; a host enables only the ones it has
plugged in.
3.3.9.1. Audio#
3.3.9.1.1. AT2040 microphone#
The AT2040 is a USB microphone that also exposes a headphone
output. WirePlumber gives that output a higher default priority (1109) than
the HDMI sink (1000), so plugging the mic in silently hijacks the default
output. I demote the AT2040 sink to the lowest priority so the host's
existing output stays selected; the media.class guard holds back the sink
alone, leaving the mic input at its own high priority to still become the
default source.
The rule matches on alsa.card_name rather than the serial-bearing
node.name, so it survives re-plugging and would apply to a replacement
unit.
# Internal registry: hosts enable my.devices.*.
{ ... }:
{
flake.modules.nixos.devices =
{
config,
lib,
...
}:
let
cfg = config.my.devices;
in
{
options.my.devices.audio.at2040.enable =
lib.mkEnableOption "AT2040 USB mic sink demotion so it does not steal the default output";
config = lib.mkIf cfg.audio.at2040.enable {
services.pipewire.wireplumber.extraConfig."51-at2040-demote-sink" = {
"monitor.alsa.rules" = [
{
matches = [
{
"media.class" = "Audio/Sink";
"alsa.card_name" = "AT2040USB";
}
];
actions.update-props = {
"priority.driver" = 0;
"priority.session" = 0;
};
}
];
};
};
};
}
3.3.10. Authentication#
3.3.10.1. Ente Auth#
Ente Auth holds my TOTP secrets and syncs them with end-to-end encryption between Android and every machine here, so a lost phone is an inconvenience rather than a lockout. Client and server are both open source, so I can read what happens to the secrets and self-host the backend later without re-enrolling anything.
Before this I used Google Authenticator and then Microsoft Authenticator, out of inertia more than choice. Neither could hand its secrets to a new device at the time, so replacing a phone meant re-enrolling every account by hand.
A password manager would solve the sync problem too (Bitwarden sells TOTP on a paid plan, and a self-hosted Vaultwarden gives it away), but I keep the two apart deliberately. Storing the codes in the vault means anyone who gets into the vault has already cleared the second factor, which leaves me with one factor wearing two hats.
nixpkgs builds ente-auth for Linux only. The app is written in Flutter,
and the Flutter tooling in nixpkgs can only produce Linux desktop bundles; a
macOS bundle requires Xcode, which a sandboxed Nix build cannot use. So
darwin installs the upstream cask through brew-nix instead.
# nixpkgs marks ente-auth as Linux-only, so darwin falls back to the
# upstream cask exposed by the brew-nix overlay.
{ ... }:
{
flake.modules.homeManager.ente-auth =
{
config,
lib,
pkgs,
...
}:
let
cfg = config.my.programs.ente-auth;
in
{
options.my.programs.ente-auth = {
enable = lib.mkEnableOption "Ente Auth";
package = lib.mkOption {
type = lib.types.package;
default = if pkgs.stdenv.hostPlatform.isDarwin then pkgs.brewCasks.ente-auth else pkgs.ente-auth;
defaultText = lib.literalExpression "pkgs.ente-auth";
description = "The Ente Auth package to install.";
};
};
config = lib.mkIf cfg.enable {
home.packages = [ cfg.package ];
};
};
}
3.3.11. ブラウザー#
Compared with Chromium, Firefox lets me declare far more of its configuration (search engines and extensions, to name two), and that plays straight into home-manager: every machine gets the same browser from the same expression, with no sync service involved. That is why I stay on the Firefox side of the fence. I also simply want to run Gecko rather than yet another Blink browser.
For about a year and a half that Firefox was Zen Browser, a fork with better UI/UX, though I always had some reservations about it. While I was using it, stock Firefox kept shipping, and eventually almost everything I wanted landed upstream:
- Vertical tabs (Firefox 136). I keep a hundred tabs open at all times, so a horizontal tab strip is out of the question.
- Configurable keyboard shortcuts (Firefox 147). Firefox finally lets me rebind them.
- Split view (Firefox 149). Not an everyday feature, but comparing two pages side by side comes up more often than I expected.
What I liked about Zen beyond that turned out to need nothing more than CSS:
- Compact mode. A browser, like almost every program, has no need for bars; Zen hides the toolbar and sidebar by default.
- Floating URL bar. Zen's URL bar searches like any other, but the input floats in the middle of the window. URL bars are normally pinned to the top or bottom edge, so one of the most heavily used components demands the longest eye movement. And as compact mode shows, I don't want a bar there in the first place.
Neither needs a fork; a userChrome.css theme is enough. With
parfait I get everything I wanted
from Zen on stock Firefox, and it is far from the only such theme;
natsumi-browser was the
first one I came across.
With the UI gap closed, what remains are my reservations: Zen's stability, its prospects as a project, and how well it is maintained in nixpkgs. So I switched to Firefox and removed Zen from the configuration entirely.
{ ... }:
let
sharedSearch = import ./search.nix;
sharedExtensions = import ./extensions.nix;
in
{
<<firefox-module>>
}
3.3.11.1. Firefox#
Stock Firefox is dressed in parfait, a chrome theme written entirely in
userChrome.css (no extension, no userChrome.js loader), so it lives
inside the profile home-manager already writes.
On Linux, home-manager still defaults the profile directory to
~/.mozilla/firefox for home.stateVersion below 26.05 and warns on every
evaluation until the choice is made explicit.
flake.modules.homeManager.firefox =
{
config,
lib,
pkgs,
...
}:
let
cfg = config.my.programs.firefox;
parfait = pkgs.callPackage ./parfait.nix { };
workContainerId = 1;
in
{
options.my.programs.firefox = {
enable = lib.mkEnableOption "Firefox";
};
config = lib.mkIf cfg.enable {
programs.firefox = {
enable = true;
configPath = lib.mkIf pkgs.stdenv.hostPlatform.isLinux "${config.xdg.configHome}/mozilla/firefox";
profiles.natsukium = {
search = sharedSearch { inherit pkgs; };
extensions = sharedExtensions { inherit pkgs; } // {
<<firefox-containerise-rules>>
};
<<firefox-containers>>
<<firefox-settings>>
};
};
<<firefox-parfait-tree>>
};
};
3.3.11.1.1. 設定#
extensions.autoDisableScopes is 0 in both profiles: by default Firefox
asks for confirmation of each extension the profile installs, so the first
launch after a rebuild would mean approving every one by hand.
I keep my passwords in Bitwarden, so I turn off signon.rememberSignons.
parfait's stylesheets rely on -moz-pref() queries, so its preferences must
be present in Firefox's user.js; an unset preference falls back to
stylesheet defaults rather than the intended theme styling. Because
home-manager generates user.js from settings, upstream's user.js is
parsed at evaluation time so the baseline preferences track the pinned theme
version.
The source is fetched via fetchTarball rather than read from the parfait
derivation: reading a derivation output during evaluation is an
import-from-derivation, which nix flake check --no-build in CI
rejects. parfait.nix uses fetchFromGitHub because update-nix-hash,
which rehashes Renovate's version bumps, only understands that form. Both
fetchers take the same URL and hash and resolve to the same store path, so
the source is downloaded once.
Setting sidebar.verticalTabs and sidebar.visibility = "hide-sidebar"
puts parfait into its floating layout: the navigation bar slides off screen
and the sidebar hovers over the page. parfaitOverrides customizes the
theme with a backdrop filter behind the floating sidebar and the find bar
(parfait.theme.blur.enabled). Evaluation fails if an override is no longer
defined in upstream's user.js, catching renamed preferences that would
otherwise silently stop applying.
settings =
let
parfaitSrc = fetchTarball {
inherit (parfait) url;
sha256 = parfait.outputHash;
};
parfaitDefaults = lib.pipe "${parfaitSrc}/user.js" [
builtins.readFile
(lib.splitString "\n")
(map (builtins.match ''user_pref\("([^"]+)", (.*)\);''))
(lib.filter (m: m != null))
(map (m: lib.nameValuePair (lib.elemAt m 0) (builtins.fromJSON (lib.elemAt m 1))))
lib.listToAttrs
];
parfaitOverrides = {
"parfait.theme.blur.enabled" = true;
};
staleOverrides = lib.attrNames (removeAttrs parfaitOverrides (lib.attrNames parfaitDefaults));
in
lib.throwIf (staleOverrides != [ ])
"parfait's user.js no longer defines ${lib.concatStringsSep ", " staleOverrides}"
(
parfaitDefaults
// parfaitOverrides
// {
"extensions.autoDisableScopes" = 0;
"sidebar.verticalTabs" = true;
"sidebar.visibility" = "hide-sidebar";
"browser.translations.automaticallyPopup" = false;
"layout.spellcheckDefault" = 0;
"signon.rememberSignons" = false;
}
);
3.3.11.1.2. Theme files#
parfait's repository is already shaped like a profile's chrome/ directory,
so the whole tree is linked in as-is. profilesPath is an internal option,
but it is the only thing that knows where the profile directory lands, and
that differs between darwin and Linux.
home.file."${config.programs.firefox.profilesPath}/natsukium/chrome".source = parfait;
3.3.11.1.3. Containers#
Declaring the container pins its ID so routing rules can address it by
firefox-container-1. containersForce allows home-manager to write
containers.json, which Firefox overwrites on launch.
containers.work = {
id = workContainerId;
color = "red";
icon = "briefcase";
};
containersForce = true;
Mozilla's Multi-Account Containers only matches on
domain. containerise matches
paths, routing only attmcojp URLs into the work container.
Rules only route inbound: routing non-work paths back out breaks sign-in, as container switches reopen tabs and drop POST bodies (such as SAML assertions and two-factor forms).
settings."[email protected]" = {
force = true;
settings =
let
work = host: {
inherit host;
cookieStoreId = "firefox-container-${toString workContainerId}";
containerName = "work";
enabled = true;
};
in
{
"map=github.com/orgs/attmcojp" = work "github.com/orgs/attmcojp";
"map=github.com/attmcojp" = work "github.com/attmcojp";
};
};
3.3.11.2. 検索エンジン#
Custom search engines for the package registries and documentation I search
daily; a short alias in the address bar (@np and friends) beats navigating
to each site first.
3.3.11.2.1. Nixパッケージ#
The icon comes from nixos-icons in nixpkgs rather than an external URL
that could move or vanish.
nix-packages = {
name = "Nix Packages";
urls = [
{
template = "https://search.nixos.org/packages";
params = [
{
name = "type";
value = "packages";
}
{
name = "query";
value = "{searchTerms}";
}
];
}
];
icon = "${pkgs.nixos-icons}/share/icons/hicolor/scalable/apps/nix-snowflake.svg";
definedAliases = [ "@np" ];
};
3.3.11.2.2. NixOS Wiki#
nixos-wiki = {
name = "NixOS Wiki";
urls = [ { template = "https://wiki.nixos.org/w/index.php?search={searchTerms}"; } ];
icon = "https://wiki.nixos.org/favicon.ico";
definedAliases = [ "@nw" ];
};
3.3.11.2.3. noogle#
noogle is a Nix function search engine, the equivalent of Hoogle for Haskell.
noogle = {
name = "noogle";
urls = [ { template = "https://noogle.dev/q?term={searchTerms}"; } ];
icon = "https://noogle.dev/favicon.png";
definedAliases = [ "@noogle" ];
};
3.3.11.2.4. crates.io#
crates-io = {
name = "crates.io";
urls = [ { template = "https://crates.io/search?q={searchTerms}"; } ];
icon = "https://crates.io/favicon.ico";
definedAliases = [ "@crates" ];
};
3.3.11.2.5. npm#
npm = {
name = "npm";
urls = [ { template = "https://www.npmjs.com/search?q={searchTerms}"; } ];
icon = "https://www.google.com/s2/favicons?domain=npmjs.com&sz=64";
definedAliases = [ "@npm" ];
};
3.3.11.2.6. PyPI#
pypi = {
name = "PyPI";
urls = [ { template = "https://pypi.org/search/?q={searchTerms}"; } ];
icon = "https://pypi.org/favicon.ico";
definedAliases = [ "@pypi" ];
};
3.3.11.3. 拡張機能#
Both profiles take the same extension list. The packages come from two
overlay sets: firefox-addons repackages the NUR firefox-addons repository,
and my-firefox-addons carries the ones upstream does not have.
{ pkgs }:
{
packages =
(with pkgs.firefox-addons; [
bitwarden
containerise
instapaper-official
keepa
onepassword-password-manager
refined-github
vimium
violentmonkey
wayback-machine
zotero-connector
])
++ (with pkgs.my-firefox-addons; [
adguard-adblocker
calilay
kiseppe-price-chart-kindle
]);
}
3.3.12. エディター#
3.3.12.1. Neovim#
Neovim is the editor I spend the most time in, primarily for coding — though
the gravitational pull of org-mode is slowly dragging me over to Emacs.
Everything Neovim-related lives under features/editor/neovim/: the wrapped
derivation (package.nix) and the lua configuration. The flake-parts module
pulls double duty from a single file:
perSystem.packages.neovimmakesnix run .#neovimwork on every supported system.flake.modules.homeManager.neovimregisters the module in the internal registry — not the publichomeManagerModulesexport, since themy.*options make it opinionated — and lets a home-manager config opt in withmy.programs.neovim.enable, which installs the wrapped Neovim plusneovim-remoteand exportsEDITOR=nvim.
{ ... }:
{
flake.modules.homeManager.neovim =
{
config,
lib,
pkgs,
...
}:
{
options.my.programs.neovim.enable = lib.mkEnableOption "neovim";
config = lib.mkIf config.my.programs.neovim.enable {
home.packages = [
(pkgs.callPackage ./package.nix { })
pkgs.neovim-remote
];
home.sessionVariables.EDITOR = "nvim";
};
};
perSystem =
{ pkgs, ... }:
{
packages.neovim = pkgs.callPackage ./package.nix { };
};
}
3.3.12.2. Emacs#
Emacs has been my second-most-used editor since early 2026. The draw is
org-mode — the workflows built around org-capture and org-agenda are
slowly winning me over, and a widening set of daily inputs (email, RSS
feeds, and more) now lives inside emacs, where the ongoing experiment is
wiring each of them back into org.
The feature lives alongside Neovim under features/editor/emacs/ and pulls
the same double duty:
perSystem.packages.emacsexposes a wrapped emacs as a flake output: a launcher that seeds a writable directory underXDG_CACHE_HOMEwith the tangled init files and starts emacs with--init-directory.nix run .#emacstherefore shows this config as-is, even on a machine whose owner has their own~/.config/emacs.flake.modules.homeManager.emacsregisters the module in the internal registry — not the publichomeManagerModulesexport, since themy.*options make it opinionated — and addsmy.programs.emacs.enablefor home-manager hosts, wiring upservices.emacs(daemon), the tangled~/.config/emacs/{init,early-init}.el, and the encrypted~/.authinfo.age.
# Requires: inputs.emacs-overlay
{ lib, inputs, ... }:
let
withEmacsOverlay = pkgs: pkgs.extend inputs.emacs-overlay.overlays.default;
emacsUnwrapped =
pkgs:
let
epkgs = withEmacsOverlay pkgs;
in
if pkgs.stdenv.hostPlatform.isDarwin then epkgs.emacs31-plus else epkgs.emacs31-pgtk;
# Pass target-file to org-babel-tangle-file so blocks without an explicit
# :tangle header still get tangled; plain org-babel-tangle would tangle
# 0 blocks here.
#
# Tangling writes no file header, so the lexical-binding cookie is prepended
# here; without it Emacs loads the config under dynamic binding.
tangle =
pkgs:
{
name,
org,
}:
pkgs.runCommand name { nativeBuildInputs = [ (emacsUnwrapped pkgs) ]; } ''
cp ${org} tmp.org
emacs -Q --batch --eval \
"(progn
(require 'ob-tangle)
(org-babel-tangle-file \"tmp.org\" \"emacs-lisp\"))"
{
echo ";;; -*- lexical-binding: t; -*-"
cat emacs-lisp
} > $out
'';
tangleEl =
pkgs: org:
let
stem = name: lib.head (lib.splitString "." name);
in
tangle pkgs {
name = "${stem (baseNameOf (toString org))}.el";
inherit org;
};
mkEmacs =
pkgs:
(withEmacsOverlay pkgs).callPackage ./package.nix {
org-clickup-src = inputs.org-clickup;
};
# `nix run .#emacs` should start this config even for someone who has their
# own init files. Bundling the config as default.el cannot deliver that:
# default.el loads after the user's init, so a guest gets both configs mixed,
# and with the home-manager-managed init.el the whole config was evaluated
# twice per startup. A writable --init-directory sidesteps both: the guest's
# ~/.config/emacs is never read, and state (elpa, eln-cache, savehist) lands
# outside the read-only store.
mkStandalone =
pkgs:
let
emacs = mkEmacs pkgs;
emacsBin =
if pkgs.stdenv.hostPlatform.isDarwin then
"${emacs}/Applications/Emacs.app/Contents/MacOS/Emacs"
else
"${emacs}/bin/emacs";
in
pkgs.writeShellScriptBin "emacs" ''
dir="''${XDG_CACHE_HOME:-$HOME/.cache}/natsukium-emacs"
mkdir -p "$dir"
ln -sf ${tangleEl pkgs ./init.org} "$dir/init.el"
ln -sf ${tangleEl pkgs ./early-init.org} "$dir/early-init.el"
exec ${emacsBin} --init-directory "$dir" "$@"
'';
in
{
flake.modules.homeManager.emacs =
{
config,
lib,
pkgs,
...
}:
{
options.my.programs.emacs.enable = lib.mkEnableOption "emacs";
config = lib.mkIf config.my.programs.emacs.enable {
programs.emacs = {
enable = true;
package = mkEmacs pkgs;
};
services.emacs = {
enable = true;
client.enable = true;
};
<<emacs-daemon-path>>
xdg.configFile."emacs/init.el".source = tangleEl pkgs ./init.org;
xdg.configFile."emacs/early-init.el".source = tangleEl pkgs ./early-init.org;
home.file.".authinfo.age".source = ./authinfo.age;
home.shellAliases = lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin {
emacs = "${config.programs.emacs.package}/Applications/Emacs.app/Contents/MacOS/Emacs";
};
};
};
perSystem =
{ pkgs, ... }:
{
packages.emacs = mkStandalone pkgs;
};
}
{
stdenv,
age,
beancount,
beancount-language-server,
difftastic,
emacs31-pgtk,
emacs31-plus,
emacsWithPackagesFromUsePackage,
gettext,
nixd,
nixfmt,
terraform-ls,
yaml-language-server,
org-clickup-src,
}:
let
emacs-unwrapped = if stdenv.hostPlatform.isDarwin then emacs31-plus else emacs31-pgtk;
in
emacsWithPackagesFromUsePackage {
package = emacs-unwrapped;
config = ./init.org;
alwaysTangle = true;
override =
epkgs:
epkgs
// {
org-clickup = epkgs.melpaBuild {
pname = "org-clickup";
ename = "org-clickup";
version = builtins.substring 0 8 (org-clickup-src.lastModifiedDate or "00000000");
commit = org-clickup-src.shortRev or "unknown";
files = ''("lisp/*.el")'';
src = org-clickup-src;
};
};
extraEmacsPackages = epkgs: [
epkgs.treesit-grammars.with-all-grammars
age
beancount
beancount-language-server
difftastic
gettext
nixd
nixfmt
yaml-language-server
terraform-ls
];
}
3.3.12.2.1. Daemon PATH#
launchd hands its agents only the system PATH, and init.org repairs
PATH with exec-path-from-shell only when window-system is non-nil,
which a daemon start never satisfies. So the daemon came up with no Nix
profile in sight, died during package initialization, and KeepAlive
restarted it every ten seconds — each start reaching /usr/bin/gcc for
native compilation, which on a machine without Xcode is the stub that opens
the Command Line Tools installer. I hand the agent a PATH of its own.
launchd.agents = lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin {
emacs.config.EnvironmentVariables.PATH = lib.concatStringsSep ":" [
"${config.home.profileDirectory}/bin"
"/run/current-system/sw/bin"
"/nix/var/nix/profiles/default/bin"
"/usr/bin"
"/bin"
"/usr/sbin"
"/sbin"
];
};
3.3.12.2.2. early-init.org#
- disable some bars
The tool bar duplicates functionality already accessible via keybindings and the menu bar, while the vertical scroll bar wastes horizontal space without providing useful navigation—line numbers and modeline indicators are more precise. Setting these in
default-frame-alistrather than callingtool-bar-mode/scroll-bar-modeavoids a brief flash of the bars during startup before the modes disable them.elisp(push '(tool-bar-lines . 0) default-frame-alist) (push '(vertical-scroll-bars) default-frame-alist) - turn off the annoying bell
The default audible bell is distracting and provides no actionable information. Replacing it with
ignoresilences it entirely; a visual bell (visible-bell) was not chosen because the screen flash is equally disruptive.elisp(setq ring-bell-function 'ignore) - disable backup files
Emacs already skips backup files for version-controlled files by default (
vc-make-backup-filesisnil), but files outside of a repository still produce backups. Since virtually all editing happens in Git-managed trees, the remaining cases are rare enough that the clutter outweighs the safety net. Auto-save files are likewise redundant when unsaved work can be recovered through Git stashes or reflog.elisp(setq make-backup-files nil) (setq auto-save-default nil) - disable lock files
Lock files (
.#filename) prevent concurrent editing across multiple Emacs instances, but in a single-instance workflow they only clutter the working directory and interfere with file watchers and build tools.elisp(setq create-lockfiles nil)
3.3.12.2.3. init.org#
- basic
elisp(add-to-list 'default-frame-alist '(undecorated-round . t)) (use-package doom-themes :ensure t :config (load-theme 'doom-nord :no-confirm))Enable visual-line-mode globally instead of using auto-fill-mode here to preserve logical line structure while still wrapping long lines visually for better readability without modifying the actual file.
elisp(visual-line-mode 1)Highlight the cursor line.
elisp(global-hl-line-mode 1)Accept single-character
y/nresponses instead of requiring the full wordyes/no.elisp(setq use-short-answers t)- Customize file
Emacs defaults
custom-filetoinit.el, but hereinit.elis a read-only symlink into the Nix store, so anything Customize tries to persist would fail to write. Stateful files like this must live outside Nix management to stay mutable: pointcustom-fileat a writable path underuser-emacs-directory(the directory itself is writable; only the symlinked files are not) and load it so the saved values take effect.elisp(setq custom-file (locate-user-emacs-file "custom.el")) (when (file-exists-p custom-file) (load custom-file nil t)) - Font
Moralerspace is a monaspace-based composite font family that pairs a Latin programming typeface with Japanese glyphs. Each variant—Argon, Krypton, Radon, Xenon—has its own typographic character, so assigning a different variant to each face produces visually distinct bold, italic, and bold-italic rendering without relying on Emacs's synthetic transformations.
:weight 'normaland:slant 'normalprevent Emacs from further synthesizing bold or italic on top of the variant's own design.font-lock-comment-faceuses Radon (the italic variant) to give code comments a softer, visually distinct appearance.elisp(set-face-attribute 'default nil :family "Moralerspace Argon HW" :height 140) (set-face-attribute 'bold nil :family "Moralerspace Krypton HW" :weight 'normal) (set-face-attribute 'italic nil :family "Moralerspace Radon HW" :slant 'normal) (set-face-attribute 'bold-italic nil :family "Moralerspace Xenon HW" :weight 'normal :slant 'normal) (set-face-attribute 'font-lock-comment-face nil :family "Moralerspace Radon HW" :slant 'normal)Without an explicit
set-language-environment, Emacs may select a Chinese font for CJK characters based on locale order, causing Japanese text to render with Chinese glyphs.elisp(set-language-environment "Japanese") - Nix wrapper paths
The Nix Emacs wrapper (
extraEmacsPackages) adds binaries toexec-paththat are absent from the shell'sPATH. Both exec-path-from-shell and envrc replaceexec-pathduring operation, losing these entries. Capturing them once at init allows both packages to merge them back.elisp(setq my/nix-exec-path exec-path) - Darwin
On macOS, Emacs cannot inherit environment variables (such as
$PATH) from the shell, so exec-path-from-shell is needed as a workaround. emacs-plus injects PATH into Emacs's Info.plist, so applying that patch might eliminate the need for this package.exec-path-from-shell-initializereplacesexec-pathwith the login shell'sPATH. Mergingmy/nix-exec-pathback after initialization preserves wrapper-provided paths.When launched from an application launcher (Dock, Spotlight), Emacs inherits only the minimal launchd environment, which lacks Nix-related variables. Without these,
direnv exportmay produce incomplete results. (envrc#92)Separately, fish sources
conf.d/*.fisheven for non-interactive invocations (fish -c "..."). nix-darwin places environment setup scripts there that re-initialize PATH from system profiles when guard variables (__fish_nixos_env_preinit_sourced,__NIX_DARWIN_SET_ENVIRONMENT_DONE,__HM_SESS_VARS_SOURCED) are absent. Without propagating these guards, subprocesses spawned bycompileorshell-commandlose the buffer-local PATH that envrc constructed—even though envrc itself applied the correct environment.elisp(use-package exec-path-from-shell :ensure t :config (when (memq window-system '(mac ns x)) (dolist (var '("SSH_AUTH_SOCK" "SSH_AGENT_PID" "GPG_AGENT_INFO" "LANG" "LC_CTYPE" "NIX_SSL_CERT_FILE" "NIX_PATH" "__fish_nixos_env_preinit_sourced" "__NIX_DARWIN_SET_ENVIRONMENT_DONE" "__HM_SESS_VARS_SOURCED")) (add-to-list 'exec-path-from-shell-variables var)) (exec-path-from-shell-initialize) (setq exec-path (delete-dups (append exec-path my/nix-exec-path))))) - envrc
envrc.el provides buffer-local environment variables based on direnv. This enables Emacs to recognize project-specific development environments managed by direnv.
Unlike direnv.el which sets environment variables globally, envrc.el keeps them buffer-local. This is essential when working on multiple projects simultaneously, each with its own .envrc.
The mode must be enabled early (via after-init hook) so that other packages can inherit the correct environment.
When envrc updates a buffer's environment, it replaces
exec-pathwith values fromdirenv export, losing the Nix wrapper paths. (envrc#9) Mergingmy/nix-exec-pathback after each update preserves access to wrapper-provided executables (e.g. yaml-language-server) inside direnv-managed buffers.By default envrc blocks Emacs until
direnv exportfinishes, so the first file I open in a project freezes for as long as the devshell takes to load. This was a long-standing pain point of envrc, and asynchronous direnv invocation finally landed in envrc#130. I setenvrc-asyncso direnv runs in the background and the buffer is editable immediately; the environment is applied once direnv completes.Running asynchronously breaks the guarantee that a buffer's environment is in place before anything uses it.
lsp-deferredstarts the server from an idle timer right after the file is shown, which would usually be before direnv finishes, so lsp-mode would look the server up in the globalPATHand either fail or pick a different version than the devshell provides. I hold backlspwhile envrc is still running in that buffer and hand it back tolsp-deferredonce direnv has finished, so the visibility check still holds. If direnv fails, lsp starts with the global environment, as it did when envrc ran synchronously.elisp(use-package envrc :ensure t :hook (after-init . envrc-global-mode) :custom (envrc-async t) :config (defvar-local my/envrc-pending-lsp nil) (advice-add 'lsp :around (defun my/lsp-wait-for-envrc (orig &rest args) (if (and (bound-and-true-p envrc-mode) envrc--running) (setq-local my/envrc-pending-lsp t) (apply orig args)))) (advice-add 'envrc--apply :after (defun my/envrc-after-apply (buf _result) (when (buffer-live-p buf) (with-current-buffer buf (when (local-variable-p 'exec-path) (setq-local exec-path (delete-dups (append exec-path my/nix-exec-path)))) (when (and my/envrc-pending-lsp (not envrc--running)) (setq-local my/envrc-pending-lsp nil) (lsp-deferred))))))) - TRAMP
TRAMP opens files on remote machines over ssh. The commands those buffers trigger run there rather than locally: magit over TRAMP invokes the remote
git. How Tramp finds and uses programs on the remote host describes how it locates them.The search is limited to
tramp-remote-path, which defaults togetconf PATHand a set of conventional directories. Nix installs into none of them, so magit finds either nogitat all or the one macOS ships. I addtramp-own-remote-pathto that list, which makes TRAMP read the remote login shell'sPATHinstead. That is the only place the nix profiles show up.elisp(use-package tramp :defer t :config (add-to-list 'tramp-remote-path 'tramp-own-remote-path))
- Customize file
- UI
- mode-line
elisp(use-package moody :ensure t :config (moody-replace-mode-line-front-space) (moody-replace-mode-line-buffer-identification) (moody-replace-vc-mode)) - headerline
elisp(use-package breadcrumb :ensure t :config (breadcrumb-mode)) - indent-bars
indent-bars displays configurable vertical guide bars at each indentation level. Tree-sitter integration is available for scope-aware highlighting, where bars outside the current scope are de-emphasized.
elisp(use-package indent-bars :ensure t :hook (prog-mode . indent-bars-mode))
- mode-line
- minibuffer
Referring to https://protesilaos.com/codelog/2024-11-28-basic-emacs-configuration/
elisp(use-package vertico :ensure t :hook (after-init . vertico-mode))elisp(use-package marginalia :ensure t :hook (after-init . marginalia-mode))elisp(use-package orderless :ensure t :config (setq completion-styles '(orderless basic)) (setq completion-category-defaults nil) (setq completion-category-overrides nil))The built-in savehist package keeps a record of user inputs and stores them across sessions. Thus, the user will always see their latest choices closer to the top (such as with M-x).
elisp(use-package savehist :ensure nil ; it is built-in :hook (after-init . savehist-mode))elisp(use-package corfu :ensure t :hook (after-init . global-corfu-mode) :bind (:map corfu-map ("<tab>" . corfu-complete)) :config (setq tab-always-indent 'complete) (setq corfu-auto t) (setq corfu-auto-prefix 1) (setq corfu-preview-current nil) (setq corfu-min-width 20) (setq corfu-popupinfo-delay '(1.25 . 0.5)) (corfu-popupinfo-mode 1) ; shows documentation after `corfu-popupinfo-delay' ;; Sort by input history (no need to modify `corfu-sort-function'). (with-eval-after-load 'savehist (corfu-history-mode 1) (add-to-list 'savehist-additional-variables 'corfu-history)))- embark
elisp(use-package embark :ensure t :bind (("C-." . embark-act) ;; pick some comfortable binding ("C-;" . embark-dwim) ;; good alternative: M-. ("C-h B" . embark-bindings)) ;; alternative for `describe-bindings' :init ;; Optionally replace the key help with a completing-read interface (setq prefix-help-command #'embark-prefix-help-command) ;; Show the Embark target at point via Eldoc. You may adjust the ;; Eldoc strategy, if you want to see the documentation from ;; multiple providers. Beware that using this can be a little ;; jarring since the message shown in the minibuffer can be more ;; than one line, causing the modeline to move up and down: ;; (add-hook 'eldoc-documentation-functions #'embark-eldoc-first-target) ;; (setq eldoc-documentation-strategy #'eldoc-documentation-compose-eagerly) ;; Add Embark to the mouse context menu. Also enable `context-menu-mode'. ;; (context-menu-mode 1) ;; (add-hook 'context-menu-functions #'embark-context-menu 100) :config ;; Hide the mode line of the Embark live/completions buffers (add-to-list 'display-buffer-alist '("\\`\\*Embark Collect \\(Live\\|Completions\\)\\*" nil (window-parameters (mode-line-format . none)))))
- embark
- version control system
- git
elisp(use-package magit :ensure t :bind (("C-x g" . magit-status))) (use-package diff-hl :ensure t :config (global-diff-hl-mode) (diff-hl-flydiff-mode) (add-hook 'dired-mode-hook 'diff-hl-dired-mode) (add-hook 'magit-post-refresh-hook 'diff-hl-magit-post-refresh))- difftastic.el
I already use difftastic for git outside Emacs, and difftastic.el brings the same structural diff to magit.
difftastic-bindings-modeaddsM-dandM-cto themagit-difftransient, so magit's own diff stays the default and difftastic is one key away.elisp(use-package difftastic :ensure t :after magit :config (difftastic-bindings-mode)) - forge
Forge extends Magit to work with GitHub, GitLab, and other forges directly from Emacs—browsing issues, reviewing pull requests, and managing notifications without leaving the editor.
Forge authenticates via
ghub, which reads credentials fromauth-sources. Theauthinfo.agefile should contain a GitHub API token entry:machine api.github.com login <username>^forge password <token>
The token must be a classic personal access token, not a fine-grained one, because the
/notificationsREST endpoints only accept classic tokens. A 6-month expiration is set so the token must be regenerated periodically.Required scopes:
repo— issues and pull requestsnotifications— read the notification inbox behindforge-list-notificationsuser— resolve the authenticated user's profileread:org— enumerate organization repositories and teams
C-c gopens the notification list.forge-list-notificationsrenders only what the local database already holds, so I pull afterwards rather than before. The pull refreshes whichever buffer was current when it started, and running it second makes that buffer the list itself.elisp(use-package forge :ensure t :after magit) (defun my/forge-notifications () "Display GitHub notifications, then pull new ones from the forge." (interactive) (forge-list-notifications) (forge-pull-notifications)) (global-set-key (kbd "C-c g") #'my/forge-notifications) - consult-gh
consult-gh drives the
ghCLI through a consult interface, so searching repositories, issues, pull requests, and code across GitHub happens from the minibuffer with live preview.Forge already covers issues and pull requests for repositories tracked locally in magit. consult-gh extends that reach to arbitrary repositories: scanning an upstream's recent issues, opening another project's README, or finding a snippet by code search—without first cloning or visiting the repo in magit.
elisp(use-package consult-gh :ensure t :defer t :config (consult-gh-enable-default-keybindings))consult-gh-embark-modeadds embark actions on results (clone, browse, copy URL, …).elisp(use-package consult-gh-embark :ensure t :after (consult-gh embark) :config (consult-gh-embark-mode +1))consult-gh-forge-moderoutes issue and PR viewing through forge buffers, keeping the editing surface consistent with the existing magit/forge workflow.elisp(use-package consult-gh-forge :ensure t :after (consult-gh forge) :config (consult-gh-forge-mode +1))
- difftastic.el
- git
- LSP
lsp-mode is a Language Server Protocol client for Emacs. It provides IDE-like features (completion, diagnostics, navigation) by communicating with external language servers.
Eglot is built into Emacs 29+ as a lighter alternative, but it assumes one server per major mode, making it cumbersome to run multiple servers simultaneously on the same buffer (e.g. a language server alongside a linter or formatter). lsp-mode handles multiple concurrent servers natively and provides more granular control over LSP behavior.
Using
lsp-deferredinstead oflspto delay server startup until the buffer is visible, avoiding unnecessary processes for buffers opened in the background.Disabling
lsp-headerline-breadcrumb-modeto avoid conflict with the existingbreadcrumbpackage in the header line.elisp(use-package lsp-mode :ensure t :commands (lsp lsp-deferred) :init (setq lsp-keymap-prefix "C-c l") :config (setq lsp-headerline-breadcrumb-enable nil)) - language support
- tree-sitter
Emacs 31 can remap files from traditional major modes to their tree-sitter counterparts. I enable all of the built-in remappings; modes supplied by packages are configured explicitly in their own sections.
I also use the maximum font-lock level for tree-sitter modes.
elisp(setopt treesit-enabled-modes t treesit-font-lock-level 4) - treesit-fold
treesit-fold provides code folding driven by the tree-sitter syntax tree, so foldable regions follow real syntactic nodes (functions, blocks, comments) rather than indentation heuristics.
global-treesit-fold-modeactivates it automatically in every supported tree-sitter mode.TAB is rebound to a wrapper that mimics
org-cycle: when point sits on a foldable node, the fold is toggled; otherwise the originalindent-for-tab-commandruns, so ordinary indentation in code keeps working.Language-specific fold rules (e.g. Nix
let ... inblocks) live with their respective language sections rather than here, so the catalogue of foldable nodes per language stays next to the rest of that mode's setup.elisp(use-package treesit-fold :ensure t :hook (after-init . global-treesit-fold-mode) :preface (defun my/treesit-fold-toggle-or-indent () "Toggle the fold at point when on a foldable node; otherwise indent." (interactive) (if (treesit-fold--foldable-node-at-pos) (treesit-fold-toggle) (indent-for-tab-command))) :bind (:map treesit-fold-mode-map ("TAB" . my/treesit-fold-toggle-or-indent))) - Beancount
beancount-mode is the major mode bundled with Beancount. LSP integration uses beancount-language-server via lsp-mode's bundled
lsp-beancountclient.Upstream only auto-registers
.beancount, so.beanis added explicitly here for the common short extension.elisp(use-package beancount :ensure t :mode ("\\.bean\\(count\\)?\\'" . beancount-mode) :hook (beancount-mode . lsp-deferred)) - CSV
csv-mode provides a major mode for editing csv and tsv files.
elisp(use-package csv-mode :ensure t) - Markdown
Emacs 31 ships
markdown-ts-mode, a tree-sitter major mode for Markdown. Both grammars it needs are provided bytreesit-grammars.with-all-grammars.elisp(use-package markdown-ts-mode :ensure nil :mode ("\\.md\\'" . markdown-ts-mode) :config (setq markdown-ts-fontify-code-blocks-natively t) (dolist (face-height '((markdown-ts-heading-1 . 2.0) (markdown-ts-heading-2 . 1.7) (markdown-ts-heading-3 . 1.4) (markdown-ts-heading-4 . 1.1) (markdown-ts-heading-5 . 1.0) (markdown-ts-heading-6 . 1.0))) (set-face-attribute (car face-height) nil :height (cdr face-height)))) - Nix
https://github.com/nix-community/nix-ts-mode
Tree-sitter based major mode for Nix expressions.
nixd (configured via lsp-mode) provides IDE features for Nix expressions by interoperating with the C++ Nix evaluator. Unlike nil which relies on static analysis, nixd performs actual Nix evaluation, enabling accurate completion for attribute paths (e.g.
pkgs.), NixOS options, and flake inputs.The bundled
treesit-fold-parsers-nixonly knows about attrsets, interpolations, lists, and comments. Two extra rules are registered here solet ... inblocks and indented (''...'') multi-line strings fold as well.elisp(use-package nix-ts-mode :ensure t :mode "\\.nix\\'" :hook (nix-ts-mode . lsp-deferred) :init ;; org-src-lang-modes is defined in org-src, which may not be loaded yet at init time (with-eval-after-load 'org-src (add-to-list 'org-src-lang-modes '("nix" . nix-ts))) :config (setq lsp-nix-nixd-formatting-command ["nixfmt"]) (with-eval-after-load 'treesit-fold (setf (alist-get 'nix-ts-mode treesit-fold-range-alist) (append (alist-get 'nix-ts-mode treesit-fold-range-alist) `((let_expression . ,(lambda (node offset) (treesit-fold-range-markers node offset "let" "in"))) (indented_string_expression . ,(lambda (node offset) (treesit-fold-range-markers node offset "''" "''")))))))) - Terraform
terraform-mode provides syntax highlighting and indentation for Terraform (HCL) files.
No tree-sitter variant is available in MELPA, so using the traditional major mode.
terraform-ls (configured via lsp-mode) provides IDE features such as completion, diagnostics via
terraform validate, and go-to-definition for Terraform configurations.elisp(use-package terraform-mode :ensure t :hook (terraform-mode . lsp-deferred)) - PO
po-mode is Emacs's major mode for editing GNU gettext PO (Portable Object) files. PO files store translations for software internationalization.
Editing PO files as plain text is error-prone because the format has strict requirements for escaping and structure. po-mode provides structured navigation between entries, automatic validation, and prevents common formatting errors.
- Common Operations
PO mode is not derived from text mode. The buffer is read-only and has its own keymap, so standard text editing commands do not work directly. Translations must be edited through the subedit buffer (
RET).- Main Commands
Commands for file operations, validation, and general PO mode management.
Key Function Description _po-undoUndo last modification qpo-confirm-and-quitQuit with confirmation ?hpo-helpShow help about PO mode Use or
qto quit instead ofC-x k(kill-buffer), as they properly handle unsaved changes and warn about untranslated entries.See Main PO mode Commands for more details.
- Entry Positioning
Commands for navigating between entries in the PO file.
Key Function Description npo-next-entryMove to next entry ppo-previous-entryMove to previous entry <po-first-entryMove to first entry >po-last-entryMove to last entry See Entry Positioning for more details.
- Modifying Translations
Commands for editing translation strings. Press
RETto open a subedit buffer where standard Emacs editing works normally.Key Function Description RETpo-edit-msgstrOpen subedit buffer for editing C-c C-cpo-subedit-exitFinish editing and apply changes C-c C-kpo-subedit-abortAbort editing and discard changes DELpo-fade-out-entryDelete the translation See Modifying Translations for more details.
- Main Commands
- Configuration
elisp(use-package po-mode :ensure t)
- Common Operations
- Protocol Buffers
protobuf-ts-mode is a tree-sitter-based major mode for editing proto3 files.
The mode auto-registers
.protofiles when theprotogrammar is available.elisp(use-package protobuf-ts-mode :ensure t) - Justfile
just-ts-mode is a tree-sitter-based major mode for editing just command runner files.
C-c 'opens a dedicated editing buffer for the recipe body at point, with automatic shebang-based language detection.elisp(use-package just-ts-mode :ensure t) - YAML
yaml-ts-modeis a built-in tree-sitter-based major mode for YAML files. The tree-sitter grammar is already available viatreesit-grammars.with-all-grammars, so the mode activates automatically when the grammar is present.yaml-language-server (configured via lsp-mode) provides schema validation using SchemaStore. For example, files under
.github/workflows/are automatically matched to the GitHub Actions workflow schema, enabling completion and diagnostics for workflow definitions.elisp(add-hook 'yaml-ts-mode-hook #'lsp-deferred)
- tree-sitter
- org
- Semantic Line Breaks
Semantic Line Breaks (SemBr) is a writing convention where line breaks are placed at logical boundaries in sentences, such as after punctuation marks or between phrases. This makes diffs more meaningful in version control and improves readability without affecting the rendered output.
The recommended line length is around 80 characters. I set this as an upper limit in the editor to prevent lines from becoming unnecessarily long.
elisp(add-hook 'text-mode-hook (lambda () (auto-fill-mode 1) (setq fill-column 80))) - org-capture
elisp(global-set-key (kbd "C-c c") 'org-capture) (global-set-key (kbd "C-c l") 'org-store-link) (setq org-root "~/dropbox/org/") (setq org-capture-templates `(("t" "Todo" entry (file+headline ,(concat org-root "todo.org") "Tasks") "* TODO %?\n %i\n %a") ("j" "Journal" entry (file+olp+datetree ,(concat org-root "journal.org")) "* %U\n%?\n %i\n %a") ("f" "Fleeting" entry (file ,(concat org-root "fleeting.org")) "* %?\n %U\n %i\n %a"))) - org-agenda
elisp(global-set-key (kbd "C-c a") 'org-agenda) (setq org-agenda-files '("~/dropbox/org"))A todo captured with
C-c c tcarries no date, so it never surfaces in the daily agenda and I rediscover it only by scrolling throughtodo.org. Theuview lists exactly those entries: TODOs with no SCHEDULED, no DEADLINE, and no active timestamp. It is the queue I work through when I sit down to plan the week.elisp(setq org-agenda-custom-commands '(("u" "Unscheduled tasks" alltodo "" ((org-agenda-overriding-header "Unscheduled") (org-agenda-skip-function '(org-agenda-skip-entry-if 'scheduled 'deadline 'timestamp)))))) - org-super-agenda
org-super-agenda puts agenda items under named headers instead of listing them flat.
elisp(use-package org-super-agenda :ensure t :hook (org-agenda-mode . org-super-agenda-mode))gasks the same question asufrom the other side. Every TODO stays in the view, grouped by whether it carries a date, so the undated pile sits above the planned work instead of replacing it.utells me what still needs a date.gtells me whether there is room for any of it.elisp(add-to-list 'org-agenda-custom-commands '("g" "TODOs grouped by schedule" alltodo "" ((org-super-agenda-groups '((:name "No date" :and (:scheduled nil :deadline nil)) (:name "Deadline" :deadline t) (:name "Scheduled" :scheduled t)))))) - org-habit
Some of my todos are things I want to keep doing rather than finish once.
org-habitdraws a consistency graph next to those entries in the agenda, so a repeating task shows whether I actually kept it up instead of only when it is next due. Marking an entry as a habit is described in Tracking your habits.Org ships the module but does not load it by default. I require the feature after Org loads rather than adding it to
org-modules, because that option is read once while Org starts up and is easy to set too late.elisp(with-eval-after-load 'org (require 'org-habit)) - org-gcal
org-gcal provides bidirectional sync between Google Calendar and Org files via the native Google Calendar API.
elisp(use-package org-gcal :ensure t :defer t)Prerequisites for this configuration to function:
- On the personal Cloud Project, the Google Calendar API is enabled
and
https://www.googleapis.com/auth/calendaris added to the OAuth consent screen scopes. ~/.authinfo.agecontains entries:machine org-gcal login client-id password <client-id> machine org-gcal login client-secret password <client-secret>
org-gcal registers an
oauth2-autoprovider entry at package load time only when bothorg-gcal-client-idandorg-gcal-client-secretare already set; otherwise it skips registration and warns. Since these variables are populated after the package loads,org-gcal-reload-client-id-secretmust be called explicitly to perform the registration with the populated values.elisp(with-eval-after-load 'org-gcal (setq org-gcal-client-id (auth-source-pick-first-password :host "org-gcal" :user "client-id")) (setq org-gcal-client-secret (auth-source-pick-first-password :host "org-gcal" :user "client-secret")) (org-gcal-reload-client-id-secret))org-gcal supports only one global OAuth client. To sync calendars from multiple accounts, share the secondary calendars to the authorized account via Google Calendar's sharing UI with "Make changes to events" permission.
elisp(with-eval-after-load 'org-gcal (setq org-gcal-fetch-file-alist `(("[email protected]" . ,(concat org-root "gcal-personal.org")) ("[email protected]" . ,(concat org-root "gcal-work.org")))))I have no use for cancelled events, so I let org-gcal drop their headlines without asking.
elisp(with-eval-after-load 'org-gcal (setq org-gcal-remove-api-cancelled-events t))The plstore holding the OAuth tokens is encrypted symmetrically, so pinentry pops several times per sync unless something caches the passphrase.
plstore-cache-passphrase-for-symmetric-encryptionkeeps it in an Emacs variable for the session, but only under loopback pinentry: that is the one mode in which epg runs the passphrase callback plstore installs, instead of leaving the prompt to gpg-agent and receiving the plaintext alone.Caching in gpg-agent rather than in Emacs would survive Emacs restarts, which loopback does not. It also means dropping
no-symkey-cachefrom home-manager'sprograms.gpgdefaults, loosening the cache policy of every GPG operation on the machine for the sake of one package. plstore's own recommendation, public-key encryption viaplstore-encrypt-to, fits even worse: my encryption subkey lives on a smartcard, so a sync would fail whenever the card is not plugged in.Loopback keeps the change inside Emacs, and although the mode is global to epg, plstore is the only epg caller left here now that authinfo is decrypted by age.el and magit signs through git's own gpg invocation. In exchange the passphrase sits in a variable in cleartext for as long as Emacs runs, and the confirmation prompt is gone, so a typo while recreating the plstore silently becomes the new passphrase.
elisp(setq epg-pinentry-mode 'loopback) (setq plstore-cache-passphrase-for-symmetric-encryption t) - On the personal Cloud Project, the Google Calendar API is enabled
and
- org-clickup
org-clickup is a personal package that bidirectionally syncs ClickUp tasks with org-mode TODO entries. Work tasks live in ClickUp, but the authoritative planning surface is org-mode (agenda, capture, refile) so daily triage stays in Emacs instead of switching to a browser tab.
The API token must live in
~/.authinfo.ageas:machine api.clickup.com login <workspace-id> password <token>
elisp(use-package org-clickup :ensure t :defer t) - org-roam
elisp(use-package org-roam :ensure t :custom (org-roam-directory "~/dropbox/org-roam") (org-roam-db-location "~/.local/share/org-roam.db") :bind (("C-c n l" . org-roam-buffer-toggle) ("C-c n f" . org-roam-node-find) ("C-c n g" . org-roam-graph) ("C-c n i" . org-roam-node-insert) ("C-c n c" . org-roam-capture) ("C-c n j" . org-roam-dailies-capture-today)) :config (setq org-roam-capture-templates '(("p" "permanent" plain "%?" :target (file+head "permanent/${slug}.org" "#+title: ${title}\n") :unnarrowed t) ("l" "literature" plain "%?" :target (file+head "literature/${title}.org" "#+title: ${title}\n") :unnarrowed t))) (setq org-roam-node-display-template (concat "${title:*} " (propertize "${tags:10}" 'face 'org-tag))) (org-roam-db-autosync-mode) (require 'org-roam-protocol) ) - htmlize
Used when converting Org files to HTML with syntax highlighting for code blocks.
C-c C-e h hexports the current Org buffer to HTML.elisp(use-package htmlize :ensure t)
- Semantic Line Breaks
- document
- pdf-tools
Emacs's built-in DocView mode renders PDFs as rasterized images page-by-page, resulting in blurry text at most zoom levels and lacking interactive features like text selection, incremental search, and annotation.
pdf-tools replaces DocView with a viewer powered by
poppler, providing sharp rendering, isearch integration, annotation support, and SyncTeX for LaTeX workflows.Using
pdf-loader-installinstead ofpdf-tools-installto defer initialization until a PDF is actually opened. With Nix's pre-builtepdfinfobinary, both functions behave identically, but the loader variant avoids unnecessary work at startup.elisp(use-package pdf-tools :ensure t :mode ("\\.pdf\\'" . pdf-view-mode) :config (pdf-loader-install))
- pdf-tools
- RSS
elfeed is a web feed reader for Emacs. Using it with elfeed-protocol to read feeds from Miniflux via the Fever API. This keeps feed management centralized in Miniflux and avoids duplicating the subscription list across devices.
The Fever API must be enabled in Miniflux (Settings -> Integrations -> Fever API) before this configuration will work.
Credentials are stored in
~/.authinfo.age.machine rss.home.natsukium.com login natsukium password <fever-password>"
elisp(use-package elfeed :ensure t :bind ("C-x w" . elfeed)) (use-package elfeed-protocol :ensure t :after elfeed :config (setq elfeed-use-curl t) (setq elfeed-feeds '(("fever+http://[email protected]" :api-url "http://rss.home.natsukium.com/fever/" :use-authinfo t))) (setq elfeed-protocol-enabled-protocols '(fever)) (elfeed-protocol-enable))- Catching up in one keystroke
A Fever update asks for a fixed window of article ids past the last one it saw, so a single fetch never brings in more than
elfeed-protocol-fever-maxsizeentries. I read feeds on several machines and follow daily news through them, so whichever machine I open first has usually fallen days behind, with a backlog of hundreds of articles. Catching up used to mean pressingGover and over until nothing new arrived, somy/elfeed-update-until-donedoes the repeating for me, chaining fetches until the server returns nothing.elisp(defvar my/elfeed-update-max-rounds 20 "Maximum number of fetch rounds `my/elfeed-update-until-done' will run.") (defvar my/elfeed--update-rounds (make-hash-table :test #'equal) "Remaining rounds per protocol feed url while a catch-up run is in flight.") (defun my/elfeed-update-until-done () "Fetch every protocol feed repeatedly until the server has nothing new." (interactive) (dolist (feed-url (elfeed-protocol-feed-list)) (my/elfeed--update-round feed-url my/elfeed-update-max-rounds))) (defun my/elfeed--update-round (feed-url rounds) "Fetch one batch from FEED-URL with ROUNDS fetches left in the budget." (let ((update-func (elfeed-protocol-update-func (elfeed-protocol-type feed-url)))) (puthash feed-url rounds my/elfeed--update-rounds) (funcall update-func (elfeed-protocol-url feed-url) (lambda (entries) (my/elfeed--update-continue feed-url entries))))) (defun my/elfeed--update-continue (feed-url entries) "Decide whether to fetch FEED-URL again after a round returned ENTRIES." ;; The initial sync splits its request into several batches and runs the ;; callback once per batch. Clearing the budget before deciding makes the ;; later callbacks no-ops instead of forking the chain. (when-let* ((rounds (gethash feed-url my/elfeed--update-rounds))) (remhash feed-url my/elfeed--update-rounds) ;; Each feed's chain ends asynchronously in one of the first two branches, ;; so there is no single "all feeds done" point where expiry could run ;; once. Running it per finished chain is redundant but idempotent. (cond ((null entries) (message "elfeed: %s is up to date" feed-url) (my/elfeed-expire-entries)) ((<= rounds 1) (message "elfeed: %s still had entries after %d rounds" feed-url my/elfeed-update-max-rounds) (my/elfeed-expire-entries)) (t (message "elfeed: fetched %d entries from %s" (length entries) feed-url) ;; The pause is not politeness toward Miniflux; it moves the next ;; request out of the curl process filter that delivered this response. (run-at-time 1 nil #'my/elfeed--update-round feed-url (1- rounds)))))) (with-eval-after-load 'elfeed-search (keymap-set elfeed-search-mode-map "G" #'my/elfeed-update-until-done)) - Gaps in Fever article ids
That fixed window poses a sharper problem than merely slowing down catch-up. The window is a literal list of ids rather than a request for everything newer, and Miniflux deletes old entries, so the ids on the server are not contiguous. A machine that has been away long enough for every id in its window to have been deleted gets an empty answer, never advances its stored last id, and stops receiving articles for good. Nothing is logged, because an empty result looks exactly like being up to date, and the catch-up loop above cannot help either: every round recomputes the same dead window.
One of my machines fell six months behind and landed in front of a run of 318 deleted ids, far beyond the reach of the default 50-id window. A 500-id window crosses a gap of that size in one fetch. That is a wider margin rather than a permanent fix. A longer run of deleted ids would strand a machine again, and recovering from it requires manually setting the mark past the gap with
elfeed-protocol-fever-set-update-mark.elisp(setq elfeed-protocol-fever-maxsize 500) - Expiring news
News articles arrive in volume and are worth reading the week they appear and never again, so keeping them around only bloats the local database. Entries tagged
newsare deleted once they are older than a month, at the end of every catch-up run.elisp(defvar my/elfeed-expire-tag 'news "Tag whose entries `my/elfeed-expire-entries' retires.") (defvar my/elfeed-expire-age "1 month" "Age past which entries tagged `my/elfeed-expire-tag' are removed.") (defun my/elfeed-expire-entries () "Remove aged out entries tagged `my/elfeed-expire-tag' from the database." (interactive) (let ((threshold (- (float-time) (elfeed-time-duration my/elfeed-expire-age))) (expired ())) ;; Collect before deleting; `elfeed-db-visit' walks the same tree the ;; deletion mutates. (with-elfeed-db-visit (entry) (when (and (elfeed-tagged-p my/elfeed-expire-tag entry) (< (elfeed-entry-date entry) threshold)) (push entry expired))) (when expired ;; Untag before deleting so elfeed-protocol pushes the read state to ;; Miniflux; an entry deleted while still unread would leave the server ;; counting an article that no longer exists locally. (elfeed-untag expired 'unread) (elfeed-db-delete expired) (when (get-buffer "*elfeed-search*") (elfeed-search-update :force)) (message "elfeed: expired %d %s entries" (length expired) my/elfeed-expire-tag))))
- Catching up in one keystroke
- mail
notmuch is a tag-based email indexer and searcher. notmuch.el provides an Emacs interface for reading and organizing email. It reads mail from a local maildir synced by mbsync.
notmuch was chosen over mu4e because the notmuch indexer is already configured for all email accounts. Adding notmuch.el only requires the Emacs frontend, avoiding a second indexer for the same maildir.
notmuch has no built-in delete operation; it manages metadata (tags) rather than maildir folder placement. Pressing
dtags the message with+deletedand removesinbox. The actual deletion happens on the nextnotmuch new: a post-new hook moves tagged files to Gmail's Trash maildir folder after indexing, so the move is synced to the server on the next mbsync run.Dreverses the operation, restoringinboxand removingdeleted. This only works before the nextnotmuch newmoves the file; once mbsync has synced the move, the message is in Gmail's Trash.shr-use-colorsis disabled so sender HTML colors don't clash with the dark theme. Inline CID images ship with the mail and are enabled, butshr-blocked-imagesis set to.(matches every URL) to block remote images, which marketing platforms use as tracking pixels — Gmail's web UI proxies them, but notmuch fetches directly.Irefreshes the current message with blocking disabled when a sender is trusted.notmuch-show-part-button-default-actiondefaults to saving an attachment to disk; I switch it tonotmuch-show-view-partso the part opens with its mailcap viewer instead, letting me press the button on a PDF to open it directly.elisp(use-package notmuch :ensure nil :bind ("C-c M" . notmuch) :custom (notmuch-fcc-dirs nil) (notmuch-search-oldest-first nil) (notmuch-show-part-button-default-action 'notmuch-show-view-part) (notmuch-saved-searches '((:name "inbox" :query "tag:inbox" :key "i") (:name "unread" :query "tag:unread" :key "u") (:name "action" :query "tag:github::action-required" :key "a") (:name "attmcojp" :query "tag:github::attmcojp" :key "w") (:name "github" :query "tag:github and not tag:github::action-required" :key "g") (:name "all" :query "*" :key "A"))) (shr-use-colors nil) (mm-inline-text-html-with-images t) (shr-blocked-images ".") :config (keymap-set notmuch-search-mode-map "d" (lambda () (interactive) (notmuch-search-tag '("+deleted" "-inbox")) (notmuch-search-next-thread))) (keymap-set notmuch-show-mode-map "d" (lambda () (interactive) (notmuch-show-tag '("+deleted" "-inbox")))) (keymap-set notmuch-search-mode-map "D" (lambda () (interactive) (notmuch-search-tag '("-deleted" "+inbox")) (notmuch-search-next-thread))) (keymap-set notmuch-show-mode-map "D" (lambda () (interactive) (notmuch-show-tag '("-deleted" "+inbox")))) (keymap-set notmuch-show-mode-map "I" (lambda () (interactive) (let ((shr-blocked-images nil)) (notmuch-show-refresh-view t)))))ol-notmuch integrates notmuch with Org mode's link system. Calling
org-store-link(C-c l) in a notmuch buffer stores a link to the current message or thread, which can then be inserted into org-capture templates via%a.elisp(use-package ol-notmuch :ensure t :after (notmuch org)) - terminal
- vterm
emacs-libvterm is a fully-fledged terminal emulator based on libvterm. It provides better performance and compatibility than pure Emacs Lisp alternatives, making it suitable for running interactive CLI tools like Claude Code.
elisp(use-package vterm :ensure t)
- vterm
- encryption
age.el provides transparent encryption and decryption of
.agefiles in Emacs using the age encryption tool.Using SSH keys as identity/recipient so that no separate age keypair is needed.
elisp(use-package age :ensure t :custom (age-default-identity "~/.ssh/id_ed25519") (age-default-recipient "~/.ssh/id_ed25519.pub") :config (age-file-enable) (setq auth-sources '("~/.authinfo.age")))sops.el decrypts sops-encrypted files on
find-fileand re-encrypts them on save, so I can edit the secrets in this repository without runningsopsin a terminal. Backups and auto-save are suppressed for these buffers, so the plaintext never reaches disk.elisp(use-package sops :ensure t :init (global-sops-mode 1)) - AI
- gptel
gptel turns any buffer into a chat with an LLM backend.
Authenticate once with
gptel-openai-oauth-login(ChatGPT device flow); the token is cached under.cache/gptel-openai/inuser-emacs-directory.elisp(use-package gptel :ensure t :config (setq gptel-model 'gpt-6-luna gptel-backend (gptel-make-openai-oauth "ChatGPT")))
- gptel
- misc
- vundo
elisp(use-package vundo :ensure t :bind (("C-x u" . vundo)) :config (setq vundo-glyph-alist vundo-unicode-symbols)) - consult
elisp;; Example configuration for Consult (use-package consult :ensure t ;; Replace bindings. Lazily loaded by `use-package'. :bind (;; C-c bindings in `mode-specific-map' ("C-c M-x" . consult-mode-command) ("C-c h" . consult-history) ("C-c k" . consult-kmacro) ("C-c m" . consult-man) ("C-c i" . consult-info) ([remap Info-search] . consult-info) ;; C-x bindings in `ctl-x-map' ("C-x M-:" . consult-complex-command) ;; orig. repeat-complex-command ("C-x b" . consult-buffer) ;; orig. switch-to-buffer ("C-x 4 b" . consult-buffer-other-window) ;; orig. switch-to-buffer-other-window ("C-x 5 b" . consult-buffer-other-frame) ;; orig. switch-to-buffer-other-frame ("C-x t b" . consult-buffer-other-tab) ;; orig. switch-to-buffer-other-tab ("C-x r b" . consult-bookmark) ;; orig. bookmark-jump ("C-x p b" . consult-project-buffer) ;; orig. project-switch-to-buffer ;; Custom M-# bindings for fast register access ("M-#" . consult-register-load) ("M-'" . consult-register-store) ;; orig. abbrev-prefix-mark (unrelated) ("C-M-#" . consult-register) ;; Other custom bindings ("M-y" . consult-yank-pop) ;; orig. yank-pop ;; M-g bindings in `goto-map' ("M-g e" . consult-compile-error) ("M-g f" . consult-flymake) ;; Alternative: consult-flycheck ("M-g g" . consult-goto-line) ;; orig. goto-line ("M-g M-g" . consult-goto-line) ;; orig. goto-line ("M-g o" . consult-outline) ;; Alternative: consult-org-heading ("M-g m" . consult-mark) ("M-g k" . consult-global-mark) ("M-g i" . consult-imenu) ("M-g I" . consult-imenu-multi) ;; M-s bindings in `search-map' ("M-s d" . consult-find) ;; Alternative: consult-fd ("M-s c" . consult-locate) ("M-s g" . consult-grep) ("M-s G" . consult-git-grep) ("M-s r" . consult-ripgrep) ("M-s l" . consult-line) ("M-s L" . consult-line-multi) ("M-s k" . consult-keep-lines) ("M-s u" . consult-focus-lines) ;; Isearch integration ("M-s e" . consult-isearch-history) :map isearch-mode-map ("M-e" . consult-isearch-history) ;; orig. isearch-edit-string ("M-s e" . consult-isearch-history) ;; orig. isearch-edit-string ("M-s l" . consult-line) ;; needed by consult-line to detect isearch ("M-s L" . consult-line-multi) ;; needed by consult-line to detect isearch ;; Minibuffer history :map minibuffer-local-map ("M-s" . consult-history) ;; orig. next-matching-history-element ("M-r" . consult-history)) ;; orig. previous-matching-history-element ;; The :init configuration is always executed (Not lazy) :init ;; Tweak the register preview for `consult-register-load', ;; `consult-register-store' and the built-in commands. This improves the ;; register formatting, adds thin separator lines, register sorting and hides ;; the window mode line. (advice-add #'register-preview :override #'consult-register-window) (setq register-preview-delay 0.5) ;; Use Consult to select xref locations with preview (setq xref-show-xrefs-function #'consult-xref xref-show-definitions-function #'consult-xref) ;; Configure other variables and modes in the :config section, ;; after lazily loading the package. :config ;; Optionally configure preview. The default value ;; is 'any, such that any key triggers the preview. ;; (setq consult-preview-key 'any) ;; (setq consult-preview-key "M-.") ;; (setq consult-preview-key '("S-<down>" "S-<up>")) ;; For some commands and buffer sources it is useful to configure the ;; :preview-key on a per-command basis using the `consult-customize' macro. (consult-customize consult-theme :preview-key '(:debounce 0.2 any) consult-ripgrep consult-git-grep consult-grep consult-man consult-bookmark consult-recent-file consult-xref consult-source-bookmark consult-source-file-register consult-source-recent-file consult-source-project-recent-file ;; :preview-key "M-." :preview-key '(:debounce 0.4 any)) ;; Optionally configure the narrowing key. ;; Both < and C-+ work reasonably well. (setq consult-narrow-key "<") ;; "C-+" ;; Optionally make narrowing help available in the minibuffer. ;; You may want to use `embark-prefix-help-command' or which-key instead. ;; (keymap-set consult-narrow-map (concat consult-narrow-key " ?") #'consult-narrow-help) )elisp(use-package embark-consult :ensure t :after (consult embark)) - compilation
The compilation buffer does not inherit from comint-mode, so ANSI escape sequences are displayed as raw text by default. Adding
ansi-color-compilation-filterto the compilation filter hook interprets these sequences and renders them as colors.elisp(add-hook 'compilation-filter-hook 'ansi-color-compilation-filter) - copy-region-reference
Copy the absolute path and line range of the selected region in
file:start-endformat (e.g./path/to/file.el:10-20). Pasting the result into a coding agent's prompt gives it an unambiguous file reference to work with.elisp(defun my/copy-region-reference (start end) "Copy the file path and line range of the current region to the clipboard. The format is \"/path/to/file:START-END\"." (interactive "r") (let* ((file (buffer-file-name)) (line-start (line-number-at-pos start)) (line-end (line-number-at-pos (1- end))) (ref (format "%s:%d-%d" file line-start line-end))) (kill-new ref) (message "%s" ref))) (global-set-key (kbd "C-c r") #'my/copy-region-reference) - Others
elisp(which-key-mode) (setq-default indent-tabs-mode nil) ;; org-tempo provides the "<s TAB" snippet expansion. At top level, both this ;; require and org-babel-do-load-languages (autoloaded from org.el) pull all ;; of Org into startup (~1s), so they wait here until something loads Org. (with-eval-after-load 'org (require 'org-tempo) (org-babel-do-load-languages 'org-babel-load-languages '((shell . t)))) (setq org-src-preserve-indentation t)- project.el
Register all repositories cloned by ghq (https://github.com/x-motemen/ghq) as projects.
Specifically, directories under
~/src/$ACCOUNT/$VCS_HOST/$OWNER/$REPO.elisp(defun my/sync-project-list () "Find all projects under ~/src and synchronize the project-list-file." (interactive) (let* (;; 1. Retrieve directory list as a string using find command (command (format "find %s -mindepth 4 -maxdepth 4 -type d" (expand-file-name "~/src"))) (dir-list-string (shell-command-to-string command)) ;; 2. Split string by newlines and exclude empty lines to create a list (dirs (split-string dir-list-string "\n" t))) ;; 3. Build file contents in a temporary buffer (with-temp-buffer (insert ";;; -*- lisp-data -*-\n") (insert "(\n") (dolist (dir dirs) (insert (format " (\"%s/\")\n" dir))) (insert ")\n") ;; 4. Write the built contents to file (write-file project-list-file)))) (my/sync-project-list)Sort the project list by modification time, placing recently updated projects first.
Using advice instead of sorting in
my/sync-project-listensures that projects are sorted by their current mtime at selection time, not at file generation time. This keeps the order fresh even during long Emacs sessions.elisp(defun my/sort-projects-by-mtime (projects) "Sort PROJECTS by modification time, most recent first." (sort projects (lambda (a b) (let ((time-a (file-attribute-modification-time (file-attributes a))) (time-b (file-attribute-modification-time (file-attributes b)))) (time-less-p time-b time-a))))) (advice-add 'project-known-project-roots :filter-return #'my/sort-projects-by-mtime)
- project.el
- vundo
3.3.13. Speech to Text#
3.3.13.1. Handy#
Handy is an offline speech-to-text app: press a
hotkey, speak, and the transcription is typed into the focused field. The
binary is in nixpkgs (pkgs.handy, Linux and macOS), but neither nixpkgs
nor home-manager ships a module, and the upstream flake's own modules are
thin and Linux-only. So I wrote my own, driven from one switch across every
desktop.
Almost everything is per-user and cross-platform, so it lives in a
home-manager module: the package plus an autostart agent, a systemd user
service on Linux and a launchd agent on macOS. Each platform's block is
guarded with mkIf so it only references options that exist there, and to
avoid an attribute-name-versus-pkgs evaluation cycle under
useGlobalPkgs.
The one piece home-manager cannot express is system-level. Handy reads its
global hotkey straight from /dev/input/event* via evdev, which only the
input group may read, and group membership is a NixOS setting. So a thin
flake.modules.nixos.handy adds the user to that group, sharing the
my.programs.handy.enable switch with the home module. (/dev/uinput, used
to type the result back, is already granted to the session user by uaccess,
so no udev rule is needed.) macOS has no evdev and no group to join; there
the hotkey and microphone need Accessibility and Microphone permissions,
granted once by hand, since TCC has no declarative path.
The desktop profile enables it by default, setting the home switch on every
desktop and, on NixOS only, adding the input group through its nixos
block.
{ ... }:
let
homeModule =
{
config,
lib,
pkgs,
...
}:
let
cfg = config.my.programs.handy;
inherit (config.lib) appIdentity;
app = appIdentity.stabilizeApp pkgs.handy;
in
{
options.my.programs.handy = {
enable = lib.mkEnableOption "Handy offline speech-to-text";
autostart = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Start Handy on login.";
};
};
config = lib.mkIf cfg.enable (
lib.mkMerge [
(lib.mkIf pkgs.stdenv.hostPlatform.isLinux {
home.packages = [ pkgs.handy ];
systemd.user.services.handy = lib.mkIf cfg.autostart {
Unit = {
Description = "Handy speech-to-text";
PartOf = [ "graphical-session.target" ];
After = [ "graphical-session.target" ];
};
Service = {
ExecStart = lib.getExe pkgs.handy;
Restart = "on-failure";
RestartSec = 5;
};
Install.WantedBy = [ "graphical-session.target" ];
};
})
(lib.mkIf pkgs.stdenv.hostPlatform.isDarwin {
targets.darwin.appIdentity.apps = [ pkgs.handy ];
# Relaunch only on a crash, not on a clean quit, so closing the
# settings window does not immediately resurrect Handy.
launchd.agents.handy = lib.mkIf cfg.autostart {
enable = true;
config = {
ProgramArguments = [
"${app}/Applications/Handy.app/Contents/MacOS/handy"
];
RunAtLoad = true;
KeepAlive.SuccessfulExit = false;
};
};
})
]
);
};
nixosModule =
{
config,
lib,
...
}:
{
options.my.programs.handy.enable = lib.mkEnableOption "Handy offline speech-to-text";
# Handy reads /dev/input/event* via evdev for its global hotkey, which is
# only readable by the input group; home-manager cannot grant it.
config = lib.mkIf config.my.programs.handy.enable {
users.users.${config.my.username}.extraGroups = [ "input" ];
};
};
in
{
flake.modules.homeManager.handy = homeModule;
flake.modules.nixos.handy = nixosModule;
}
3.3.14. Launcher#
3.3.14.1. Vicinae#
Vicinae is a cross-platform launcher for Linux and macOS, designed to run extensions from Raycast, the popular macOS launcher. I use both Linux and macOS day to day, so having the same experience on both platforms matters a lot to me.
On macOS I disable Spotlight in favor of Vicinae, as described later.
# Requires: inputs.vicinae, inputs.vicinae-extensions
{ inputs, ... }:
{
flake.modules.homeManager.vicinae =
{
config,
lib,
pkgs,
...
}:
{
imports = [ inputs.vicinae.homeManagerModules.default ];
options.my.programs.vicinae.enable = lib.mkEnableOption "vicinae launcher";
config = lib.mkIf config.my.programs.vicinae.enable {
programs.vicinae = {
enable = true;
systemd.enable = true;
launchd = {
enable = true;
environment.PATH = "${lib.makeBinPath [ pkgs.rbw ]}:/usr/bin:/bin:/usr/sbin:/sbin";
};
extensions = [
inputs.vicinae-extensions.packages.${pkgs.stdenv.hostPlatform.system}.bitwarden
];
settings = {
keybinding = "emacs";
theme.dark.name = "nord";
};
};
};
};
}
3.3.15. Terminal#
3.3.15.1. Felis#
felis is a daemon/client terminal whose defining feature is session persistence across hosts. Apart from the client set up on desktops, deploying the daemon in every environment keeps a session running stably even over SSH.
The client names a separate Moralerspace flavor per text style — Neon, Xenon, Radon, Krypton. felis derives bold and italic from a family's own faces, but these flavors are separate families rather than weights of one, so naming them per style is the only way to reach them.
One keymap entry works around a problem outside felis. On a JIS keyboard the
yen key still types ¥ though macOS is set to emit \, because winit drops
the substituted character unless an IME preedit is active; rebinding it here
avoids waiting for a winit release, and the entry goes away once winit
catches up.
The built-in switch_session binding only steps one session at a time.
felis-switch (Ctrl+Shift+p, via the run action) instead pipes felis
sessions list through fzf, previewing each session's grid in colour, and
switches to the pick. Inside the picker, Ctrl+x kills the marked sessions
and Alt+w narrows the list to those whose program reports, over OSC 7501,
that it is blocked on me, done, or failed, so I can find the agents that are
waiting on me without leaving the picker. It and its two siblings read the
list as --format json: felis promises that shape and explicitly reserves
the right to move the human table's columns, which is what broke them the
last time the CLI changed.
felis makes only OSC 8 hyperlinks clickable, not the plain-text URLs that
fill real output, leaving that heuristic to an external tool as kitty does.
felis-hints (Ctrl+Shift+o) is felis' side: the pipe action ships the
visible grid to
pouncer, my own hint
picker, which overlays single-key labels and opens the pick.
felis-nix-log (Ctrl+Shift+l, ported from kitty) scans the grid for a
nix log <drv> line and pages that build log. It uses grep + fzf rather
than a label overlay because a hint picker can't suppress its built-in path
matchers and the point here is matching only the .drv; a lone drv, the
usual failed-build case, opens with no picker at all.
Desktop notifications now come from felis' own home-manager module, not a
helper of mine: notifications.enable runs a relay that streams felis
notifications subscribe and forwards each alert to terminal-notifier
(macOS) or notify-send (Linux), surfacing only detached sessions since an
attached one already flashes its own window. It replaces the subscribe loop
I used to hand-roll here.
{ inputs, ... }:
let
daemon =
{
config,
lib,
pkgs,
...
}:
{
options.my.programs.felis.enable = lib.mkEnableOption "felis";
config = lib.mkIf config.my.programs.felis.enable {
environment.systemPackages = [
inputs.felis.packages.${pkgs.stdenv.hostPlatform.system}.default
];
};
};
in
{
flake.modules.nixos.felis = daemon;
flake.modules.darwin.felis = daemon;
# The configured Neovim, reused as a read-only scrollback viewer.
# My standalone hint picker; it links felis' own VT parser and cell
# grid, so its label overlay lands on exactly the columns felis drew.
flake.modules.homeManager.felis =
{
config,
lib,
pkgs,
...
}:
let
inherit (config.colorScheme) palette;
inherit (config.lib) appIdentity;
package =
let
felis = inputs.felis.packages.${pkgs.stdenv.hostPlatform.system}.default;
in
if pkgs.stdenv.hostPlatform.isDarwin then appIdentity.stabilizeApp felis else felis;
neovim = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.neovim;
pouncer = inputs.pouncer.packages.${pkgs.stdenv.hostPlatform.system}.default;
# The pickers and hints live in a sibling file so this module
# reads as configuration, not writeShellApplication bodies.
inherit
(import ./utilities.nix {
inherit
lib
pkgs
package
neovim
pouncer
;
})
felis-switch
felis-kill
felis-grep
felis-scrollback
felis-hints
felis-nix-log
;
# Moralerspace ships each style as its own flavor, not weights of
# one family, so each style names its flavor.
moralerspace = flavor: "Moralerspace ${flavor} HW";
font-features = [
"calt"
"liga"
"ss01"
"ss02"
"ss03"
"ss05"
"ss09"
];
in
{
imports = [ inputs.felis.homeManagerModules.felis ];
options.my.programs.felis.enable = lib.mkEnableOption "felis";
config = lib.mkIf config.my.programs.felis.enable {
my.programs.coding-agents.skills.felis = "${inputs.felis}/skills/felis";
home.packages = [
felis-switch
felis-kill
felis-grep
];
programs.felis = {
enable = true;
inherit package;
# Runs the module's relay (systemd user unit on Linux, launchd
# agent on macOS) forwarding alerts to the platform notifier.
notifications.enable = true;
# Each flavor is a distinct family, so felis' bold/italic
# derivation from the primary can't reach them; name them.
settings = {
font = {
family = moralerspace "Neon";
size_px = 14.0;
features = font-features;
bold.family = moralerspace "Xenon";
italic.family = moralerspace "Radon";
bold_italic.family = moralerspace "Krypton";
};
# extended base16 colors
theme = {
foreground = "#${palette.base05}";
background = "#${palette.base00}";
palette = {
black = "#${palette.base00}";
red = "#${palette.base08}";
green = "#${palette.base0B}";
yellow = "#${palette.base0A}";
blue = "#${palette.base0D}";
magenta = "#${palette.base0E}";
cyan = "#${palette.base0C}";
white = "#${palette.base05}";
bright_black = "#${palette.base03}";
bright_red = "#${palette.base08}";
bright_green = "#${palette.base0B}";
bright_yellow = "#${palette.base0A}";
bright_blue = "#${palette.base0D}";
bright_magenta = "#${palette.base0E}";
bright_cyan = "#${palette.base0C}";
bright_white = "#${palette.base07}";
indexed = {
"16" = "#${palette.base09}";
"17" = "#${palette.base0F}";
"18" = "#${palette.base01}";
"19" = "#${palette.base02}";
"20" = "#${palette.base04}";
"21" = "#${palette.base06}";
};
};
};
cursor.color = "#${palette.base05}";
window = {
decorations = false;
macos_option_as_alt = true;
};
# `run` launches the picker in a transient session over the
# live grid; an absolute store path keeps it independent of
# the daemon's minimal PATH.
# The default +h binding keeps the pager; this routes the
# same scrollback region into Neovim instead.
# Hints over the visible grid. The SGR that `ansi` keeps
# cannot break the URL match: pouncer parses the region
# through felis' VT engine and matches on cells, so it
# only colours the repaint.
# The kitty Ctrl+Shift+l port: page the build log of a
# `nix log <drv>` shown on screen.
keymap = {
"¥" = {
kind = "send_string";
text = "\\";
escapes = "none";
};
"ctrl+]" = {
kind = "switch_session";
to = "next";
};
"ctrl+[" = {
kind = "switch_session";
to = "previous";
};
"ctrl+shift+n" = {
kind = "new_session";
};
"ctrl+shift+p" = {
kind = "run";
command = [ "${felis-switch}/bin/felis-switch" ];
};
"ctrl+shift+d" = {
kind = "run";
command = [ "${felis-kill}/bin/felis-kill" ];
};
"ctrl+shift+g" = {
kind = "run";
command = [ "${felis-grep}/bin/felis-grep" ];
};
"ctrl+shift+e" = {
kind = "pipe";
source = "scrollback";
target.command = [ "${felis-scrollback}/bin/felis-scrollback" ];
};
"ctrl+shift+o" = {
kind = "pipe";
source = "visible";
ansi = true;
target.command = [ "${felis-hints}/bin/felis-hints" ];
};
"ctrl+shift+l" = {
kind = "pipe";
source = "visible";
target.command = [ "${felis-nix-log}/bin/felis-nix-log" ];
};
};
mouse.scroll_multiplier = 5.0;
};
};
};
};
}
3.3.15.1.1. Session and hint helpers#
The keymap points at six shell programs, none touching the module system;
their writeShellApplication bodies would bury the configuration if they
sat in default.nix, so they live in a sibling utilities.nix handed the
felis client, pouncer, and Neovim. Besides felis-switch and the two hints
above, it holds felis-kill, the multi-select cousin that tears sessions
down; felis-grep, a cross-session scrollback search the built-in
per-session facet can't do; and felis-scrollback, which opens the
scrollback pipe region read-only in Neovim so editor motions replace the
pager.
{
lib,
pkgs,
package,
neovim,
pouncer,
}:
let
opener = if pkgs.stdenv.hostPlatform.isDarwin then "open" else "xdg-open";
# The roster every picker below works from. `--format json` is the
# only stable shape felis promises: the human table's columns are
# explicitly free to move, and they did. `list` is a point verb, so
# the whole roster arrives as one `.sessions` array rather than as
# JSON Lines. Each output row is an id and a display line separated by
# a tab, so fzf can hide the id in field 1 while the preview still
# addresses the session by it.
#
# An `exited` session is a corpse in the post-exit grace: attachable
# for a last look, but never a place to route work.
#
# Neither idle nor geometry: `idle_seconds` counts from the last read
# and the preview reads whatever it lands on, so that column would
# measure the picker; every window opens at the same size.
#
# Padding rather than further tab-separated fields: fzf renders tabs
# at a single `--tabstop`, so every column would round up to the
# widest one's stop. Only the title carries wide characters, and it is
# last, so padding by `length` holds.
#
# The status column folds a session's OSC 7501 records into the one
# that most needs me, so a blocked subtask is not hidden behind a
# working root. `roster waiting` keeps only sessions whose program
# has stopped on me: blocked on a question, finished, or failed.
| jq -r --arg mode "''${1:-all}" ' |
| select(.exited != true) |
| ([.program_status[]?.state]) as $states |
| (["blocked", "error", "working", "done", "idle"] |
| map(select(. as $s | $states | index($s))) |
| first // "-") as $status |
| select($mode != "waiting" |
| [ |
| if . == "" then "-" |
| split("/") |
| last |
| @tsv' |
roster = ''
roster() {
felis sessions list --format json \
.sessions[]
or ($status | IN("blocked", "error", "done")))
.id,
((.short_id + " ")[:9]
+ ($status + " ")[:9]
+ (((.cwd // "" | sub("^file://[^/]*"; ""))
else sub("^/(Users|home)/[^/]+"; "~")
end)
+ " ")[:15]
+ ((.foreground // "-") + " ")[:16]
+ (.title // ""))
]
}
'';
# Wraps list/switch in an fzf picker: the built-in switch_session
# only steps in daemon order, so jumping to a known session
# otherwise means retyping a 32-hex id. The preview shows each
# session's live grid (capture --ansi), so the choice is by content.
#
# Stacked rather than side by side: half a window cuts off both the
# grid and the title. fzf's `<N(…)` compares the preview's own width
# in the primary layout, not the terminal's.
#
# ctrl-x kills the marked (or current) sessions in place and alt-w
# narrows to sessions waiting on me; both refill the list by running
# this script again with `--list <mode>`, since fzf's reload needs a
# command and `roster` is only a function here. The prompt doubles as
# the mode, so a kill reloads whichever view is showing. fzf runs
# bind commands through $SHELL, which is fish inside felis, so
# `--with-shell` pins bash.
# A `run` chord hands the picker its own transient session, so
# FELIS_SESSION_ID names the picker, not the window's session;
# FELIS_ORIGIN_SESSION_ID names where the window came from and
# will return to. Both are dead ends as switch targets.
felis-switch = pkgs.writeShellApplication {
name = "felis-switch";
runtimeInputs = [
package
pkgs.fzf
pkgs.jq
];
text = ''
${roster}
candidates() {
roster "$1" | grep -v -e "''${FELIS_ORIGIN_SESSION_ID:-none}" -e "''${FELIS_SESSION_ID:-none}" || true
}
if [ "''${1:-}" = --list ]; then
candidates "''${2:-all}"
exit 0
fi
list=$(candidates all)
if [ -z "$list" ]; then
echo "no sessions" >&2
exit 0
fi
| fzf --ansi \ |
self=$(printf %q "$0")
selection=$(
printf '%s\n' "$list" \
--multi \
--delimiter='\t' \
--with-nth=2.. \
--with-shell="${pkgs.runtimeShell} -c" \
--prompt='all> ' \
--header='enter: switch / ctrl-x: kill / alt-w: toggle waiting' \
--preview='felis sessions capture {1} --ansi' \
--preview-window='right,50%,<80(up,50%)' \
--bind="ctrl-x:execute-silent(for id in {+1}; do felis sessions kill \"\$id\"; done)+reload($self --list \"\''${FZF_PROMPT%> }\")" \
--bind="alt-w:transform:if [ \"\$FZF_PROMPT\" = 'waiting> ' ]; then echo 'change-prompt(all> )+reload($self --list all)'; else echo 'change-prompt(waiting> )+reload($self --list waiting)'; fi"
) || exit 0
id=''${selection%%$'\t'*}
[ -n "$id" ] && felis sessions switch "$id"
'';
};
# Multi-select cousin of felis-switch for tearing sessions down.
# The same `capture --ansi` preview identifies a parked session by
# its screen before it is killed.
# Only the picker's own transient is filtered out; killing the
# session the window came from is a legitimate pick.
felis-kill = pkgs.writeShellApplication {
name = "felis-kill";
runtimeInputs = [
package
pkgs.fzf
pkgs.gawk
pkgs.jq
];
text = ''
${roster}
list=$(roster | grep -v "''${FELIS_SESSION_ID:-none}" || true)
if [ -z "$list" ]; then
echo "no sessions" >&2
exit 0
fi
| fzf --ansi \ |
selection=$(
printf '%s\n' "$list" \
--multi \
--delimiter='\t' \
--with-nth=2.. \
--prompt='kill session(s)> ' \
--preview='felis sessions capture {1} --ansi' \
--preview-window='right,50%,<80(up,50%)'
) || exit 0
| awk -F'\t' 'NF {print $1}' \ |
| while read -r id; do |
printf '%s\n' "$selection" \
felis sessions kill "$id"
done
'';
};
# Cross-session scrollback grep; the built-in ctrl+shift+f facet
# searches only the focused session. Dump every session's
# scrollback, fuzzy-filter, then switch to the chosen line's
# session. A session that refuses a capture is skipped, not fatal.
| awk -F'\t' 'NF {print $1}' \ |
| while read -r id; do |
| awk -v id="$id" 'NF {print id"\t"$0}' |
felis-grep = pkgs.writeShellApplication {
name = "felis-grep";
runtimeInputs = [
package
pkgs.fzf
pkgs.gawk
pkgs.jq
];
text = ''
${roster}
matches=$(
roster \
felis sessions capture "$id" --source scrollback 2>/dev/null \
done
)
if [ -z "$matches" ]; then
echo "no scrollback" >&2
exit 0
fi
| fzf --delimiter='\t' --with-nth=2.. --prompt='grep scrollback> ' |
selection=$(
printf '%s\n' "$matches" \
) || exit 0
id=$(printf '%s' "$selection" | awk -F'\t' '{print $1; exit}')
[ -n "$id" ] && felis sessions switch "$id"
'';
};
# Target for the scrollback `pipe` keybind. felis writes the region
# to a temp file and passes the path as the last argv slot (stdin is
# the transient session's tty), so open it read-only in Neovim for
# editor motions instead of a pager. `-R` plus noswapfile keeps it a
# throwaway view; `G` lands at the newest line.
felis-scrollback = pkgs.writeShellApplication {
name = "felis-scrollback";
runtimeInputs = [ neovim ];
text = ''
exec nvim -R \
-c 'setlocal noswapfile' \
-c 'normal! G' \
"''${1:?no region file}"
'';
};
# kitty-style URL hints for the plain-text URLs felis won't make
# clickable (only OSC 8 links are), plus the file names a coding
# agent prints for what it just wrote, whose OSC 8 link the plain
# region has already dropped. Only .html and .pdf: every further
# extension is one more label competing for the short keys, and the
# rest of a screen's dotted words open in Neovim anyway. pouncer reads
# the visible region from the temp-file path in the last argv slot,
# keeping stdin free for its /dev/tty overlay.
# The pick comes back on stdout rather than through `--action open`
# because a ~-relative name needs a shell to expand it, and the
# opener would receive it as a literal argument. A relative name
# resolves against the focused session's OSC 7 cwd, which felis
# gives the transient session.
# The tilde here is the literal character a display path carries,
# which is what SC2088 assumes is a mistake.
# shellcheck disable=SC2088
felis-hints = pkgs.writeShellApplication {
name = "felis-hints";
runtimeInputs = [
pouncer
]
++ lib.optional pkgs.stdenv.hostPlatform.isLinux pkgs.xdg-utils;
text = ''
picked=$(pouncer \
--preset url \
--regex '[\w.~/@+-]+\.(?i:html?|pdf)\b' \
"''${1:?no region file}") || exit 0
[ -n "$picked" ] || exit 0
case $picked in
"~/"*) picked=$HOME/''${picked#"~/"} ;;
esac
exec ${opener} "$picked"
'';
};
# felis port of the kitty `nix log` hint: pick a `nix log <drv>` on
# screen and page its build log. grep + fzf rather than a label
# overlay because a hint picker can't disable its built-in path
# matchers and the point is matching *only* the drv. A single
# failed-build drv skips the picker entirely.
| sed 's/^nix log //' \ |
| awk '!seen[$0]++' |
felis-nix-log = pkgs.writeShellApplication {
name = "felis-nix-log";
runtimeInputs = [
pkgs.nix
pkgs.fzf
pkgs.gnused
pkgs.gawk
pkgs.less
];
text = ''
mapfile -t drvs < <(
grep -oE 'nix log /nix/store/[a-z0-9]{32}-[^ ]+\.drv' "''${1:?no region file}" \
)
case ''${#drvs[@]} in
0) echo "no 'nix log <drv>' on screen" >&2; exit 0 ;;
1) drv=''${drvs[0]} ;;
*) drv=$(printf '%s\n' "''${drvs[@]}" | fzf --prompt='nix log> ') || exit 0 ;;
esac
[ -n "$drv" ] && nix log "$drv" | less -R
'';
};
in
{
inherit
felis-switch
felis-kill
felis-grep
felis-scrollback
felis-hints
felis-nix-log
;
}
3.3.16. Darwin#
3.3.16.1. Spotlight#
I don't use Spotlight search, yet its indexer (mdworker) keeps consuming
CPU, disk I/O, and disk space in the background, so I turn indexing off by
default. Hosts that want it back can set enableIndex = true. nix-darwin
has no option for this, so the module drives mdutil from an activation
script.
{ ... }:
{
flake.modules.darwin.spotlight =
{ config, lib, ... }:
let
cfg = config.my.services.spotlight;
in
{
<<spotlight-options>>
<<spotlight-config>>
};
}
3.3.16.1.1. オプション#
options.my.services.spotlight = {
enableIndex = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether Spotlight indexes the volumes on this machine.";
};
};
3.3.16.1.2. 設定#
mdutil targets all volumes with -a rather than just /: modern macOS
splits the boot disk into a read-only System volume (/) and a writable
Data volume, so mdutil -i off / silently leaves the Data volume
indexing. When disabling, mdutil -E also erases the orphaned index stores
to reclaim disk space.
config = {
system.activationScripts.extraActivation.text =
if cfg.enableIndex then
''
echo "enabling spotlight indexing..."
mdutil -i on -a > /dev/null || true
''
else
''
echo "disabling spotlight indexing..."
mdutil -i off -d -a > /dev/null || true
mdutil -E -a > /dev/null || true
'';
};
3.4. スクリプト#
flake derivation、pre-commitフック、Makefileレシピで使用されるスクリプトです。適切なシンタックスハイライトと単独実行を可能にするため、別ファイルに分離しています。
org-to-html.el drives the documentation export. Four choices shape
it. First, htmlize emits org-* class names rather than inline colors, so
the palette lives in assets/org-html.css and
the dark theme can restyle code; that stylesheet and the script beside it
(assets/org-html.js, which adds a breadcrumb
bar, an on-this-page rail, and per-block copy buttons) are inlined into each
page, so an exported page makes no external request. Second, tree-sitter
fontifies Nix blocks, which is why nix-ts-mode and its grammar are loaded
before export.
Third, every code block that tangles is headed by a link to the file it
produces. Literate configuration only pays off if a reader can get from the
prose to the real artifact, and nothing in an exported block says where it
lands. Recovering that is harder than it looks: #+INCLUDE splices the
child documents into this one and discards where each block came from, while
:tangle paths are written relative to the document that declares them, and
the candidate paths that remain are not always distinguishable by which one
exists. So the export reads each document on its own, where its directory is
still known, and keys the result on the block body.
Fourth, noweb references link to the block that defines them, and a
referenced block is headed by its own name. A tangled file is assembled from
expansions scattered across the whole document, so following one by reading
is otherwise a search. The reference is swapped for a placeholder before
fontification and restored afterwards, because nix-ts-mode reads a
reference as a search path and scatters it over syntax spans that no
rewriting of the fontified markup can reliably put back together.
(require 'cl-lib)
(require 'org)
(require 'ox-html)
(require 'htmlize)
(require 'nix-ts-mode)
(add-to-list 'org-src-lang-modes '("nix" . nix-ts))
(setq treesit-font-lock-level 4)
;; Emit `org-*' class names on syntax spans instead of inline color styles, so
;; assets/org-html.css owns the palette and can restyle code for dark mode.
;; Inline colors would freeze one theme into the markup.
(setq org-html-htmlize-output-type 'css)
;; Export five headline levels as real headings (default is 3). Deeper sections
;; would otherwise become plain lists with no anchor, and the sticky breadcrumb
;; only pins actual headings; five keeps every level within valid h2..h6 tags.
(setq org-export-headline-levels 5)
;; The frame styling is my own; drop Org's default <style> and scripts.
(setq org-html-head-include-default-style nil
org-html-head-include-scripts nil)
;; Don't abort the whole documentation build over one dangling link. The
;; Japanese document is generated by po4a, so a paragraph that is still
;; untranslated falls back to English while its target heading is already
;; localized, leaving a fuzzy link with no matching anchor. Export it as plain
;; text instead of erroring so translation lag never takes the site down.
(setq org-export-with-broken-links t)
;; Inline the stylesheet and script rather than <link>/<script src>-ing them, so
;; each exported file (index.html and ja/index.html live at different depths) is
;; self-contained and needs no path juggling or extra request. The script only
;; enhances (breadcrumb bar, on-this-page rail, copy buttons); the page is
;; readable without it.
(defun dotfiles-slurp (path)
(with-temp-buffer
(insert-file-contents path)
(buffer-string)))
(setq org-html-head
(concat "<style>\n" (dotfiles-slurp "assets/org-html.css") "</style>\n"
"<script>\n" (dotfiles-slurp "assets/org-html.js") "</script>"))
;; Fold the table of contents: rewrite Org's #table-of-contents <div> into a
;; native <details> so the long, deeply nested TOC does not push the body down.
;; The block contains only list markup, so the first "</div></div>" reliably
;; closes it. The title capture keeps this language-agnostic (en/ja).
(defun dotfiles-html-collapse-toc (output _backend _info)
(replace-regexp-in-string
(concat "<div id=\"table-of-contents\"[^>]*>\n"
"<h2>\\([^<]*\\)</h2>\n"
"<div id=\"text-table-of-contents\"[^>]*>\n"
"\\(\\(?:.\\|\n\\)*?\\)\n"
"</div>\n</div>")
(concat "<details id=\"table-of-contents\" class=\"toc\" open>\n"
"<summary>\\1</summary>\n"
"<div id=\"text-table-of-contents\" role=\"doc-toc\">\n"
"\\2\n</div>\n</details>")
output t))
#'dotfiles-html-collapse-toc)
(add-to-list 'org-export-filter-final-output-functions
;; Head every code block with the file it tangles to, linked to GitHub.
;;
;; The target cannot be read off the block at export time. `#+INCLUDE' splices
;; the child documents into configuration.org and leaves no trace of where each
;; block came from, while `:tangle' paths are relative to the document that
;; declares them (modules/configuration.org writes "features/nix.nix" for
;; modules/features/nix.nix). Resolving by "which candidate path exists" is
;; ambiguous: overlays/configuration.org tangles to "default.nix", and both
;; overlays/default.nix and modules/default.nix are real files. So read each
;; document separately, while its directory is still known, and key on the block
;; body, which #+INCLUDE copies verbatim.
(defconst dotfiles-source-url "https://github.com/natsukium/dotfiles/blob/main/")
;; Documents that declare `:tangle' targets, and the prefix their paths are
;; relative to; mirrors the addprefix calls in the Makefile's tangle rules.
;; modules/features/emacs/{early-init,init}.org are left out on purpose: their
;; blocks carry no `:tangle' header, and Nix assembles them into init.el at
;; build time, so no file in the repository corresponds to them.
(defconst dotfiles-tangle-sources
'(("configuration" . "")
("modules/configuration" . "modules/")
("overlays/configuration" . "overlays/")))
(defvar dotfiles-tangle-map nil)
(defun dotfiles-src-block-target (src-block prefix)
"Repo-relative file SRC-BLOCK tangles to, or nil when it tangles nowhere."
(let ((params (or (org-element-property :parameters src-block) "")))
;; `\s-' rather than a literal space on purpose: the Makefile finds what to
;; tangle by grepping this document for a `:tangle' header followed by a
;; space, so a space here would offer it this regexp as a file to build.
(when (string-match ":tangle\\s-+\\([^ ]+\\)" params)
;; Capture before the next `string-match' overwrites the match data.
(let ((target (match-string 1 params)))
(unless (or (equal target "no")
;; Never exported, so never rendered. Skipping these also
;; keeps the boilerplate stubs, which repeat verbatim across
;; documents, from colliding in the map.
(string-match ":exports\\s-+none" params))
(concat prefix target))))))
(defun dotfiles-build-tangle-map (suffix)
"Map each exported block body to the file it tangles to.
SUFFIX is \"\" for the English document and \".ja\" for the translated one. The
map is rebuilt per language because po4a translates comments inside code, which
changes the body it is keyed on."
(let ((map (make-hash-table :test 'equal)))
(dolist (source dotfiles-tangle-sources map)
(with-current-buffer (find-file-noselect (concat (car source) suffix ".org"))
(org-element-map (org-element-parse-buffer) 'src-block
(lambda (src-block)
(let ((target (dotfiles-src-block-target src-block (cdr source))))
(when target
(let* ((body (org-element-property :value src-block))
(previous (gethash body map)))
;; Keying on the body is only sound while no two exported
;; blocks share one. Fail loudly rather than link to the wrong
;; file if that ever stops holding.
(when (and previous (not (equal previous target)))
(error "Blocks tangling to %s and %s have identical bodies"
previous target))
(puthash body target map))))))))))
;; Turn noweb references into links to the block that defines them, so the
;; expansions a tangled file is assembled from can be followed by reading.
(defvar dotfiles-noweb-names nil
"Block names that unambiguously identify one block in the current export.")
(defun dotfiles-collect-noweb-names (tree _backend _info)
"Record every block name defined exactly once in TREE.
A name used twice cannot be anchored: names are scoped to the document that
declares them, but #+INCLUDE merges every document into one page, where an id
has to be unique. nix-config, defined by both configuration.org and
modules/configuration.org, is the only one here, and stays unlinked."
(let ((counts (make-hash-table :test 'equal)))
(org-element-map tree 'src-block
(lambda (src-block)
(let ((name (org-element-property :name src-block)))
(when name (puthash name (1+ (gethash name counts 0)) counts)))))
(setq dotfiles-noweb-names (make-hash-table :test 'equal))
(maphash (lambda (name count)
(when (= count 1) (puthash name t dotfiles-noweb-names)))
counts))
tree)
#'dotfiles-collect-noweb-names)
(add-to-list 'org-export-filter-parse-tree-functions
(defconst dotfiles-noweb-token "nowebref0%s0"
"Stand-in for a noweb reference while its block is fontified.")
(defun dotfiles-noweb-anchor (name)
"Element id for the block that defines noweb reference NAME.
Namespaced because a block is usually named after the section documenting it,
so its name and that heading's slug are the same string. Headings win the bare
name: theirs are the URLs worth sharing."
(concat "noweb-" name))
(defun dotfiles-noweb-html (name)
"Rendered reference to NAME, linked when NAME is anchored."
(if (gethash name dotfiles-noweb-names)
(format "<a class=\"noweb-ref\" href=\"#%s\"><<%s>></a>"
(dotfiles-noweb-anchor name) name)
(format "<<%s>>" name)))
(defun dotfiles-fontify-noweb (original code lang &rest args)
"Swap noweb references out of CODE, fontify, then restore them as links.
Rewriting the fontified markup instead is not viable: nix-ts-mode reads a
reference as a search path and splits it across spans seven different ways
depending on the surrounding syntax, and three of those straddle span
boundaries, so replacing them would unbalance the markup. A bare identifier is
a single token in any grammar, so it survives fontification in one piece and
can be swapped back without touching a tag."
(let (names)
(setq code
(replace-regexp-in-string
"<<\\([a-zA-Z0-9_-]+\\)>>"
(lambda (match)
(let ((name (match-string 1 match)))
(push name names)
(format dotfiles-noweb-token name)))
code t t))
(let ((html (apply original code lang args)))
(dolist (name (delete-dups names) html)
(setq html (replace-regexp-in-string
(regexp-quote (format dotfiles-noweb-token name))
(dotfiles-noweb-html name)
html t t))))))
(advice-add 'org-html-fontify-code :around #'dotfiles-fontify-noweb)
(defconst dotfiles-src-lang-names '(("emacs-lisp" . "elisp"))
"Header text for languages whose Org name reads poorly.")
(defconst dotfiles-src-container "<div class=\"org-src-container\">\n")
(defun dotfiles-html-src-block-head (original src-block contents info)
"Prepend a header naming what SRC-BLOCK produces.
A block that tangles links to its file, a block that noweb references point at
shows and anchors that name, and the rest name their language."
(let ((html (funcall original src-block contents info)))
(if (not (string-prefix-p dotfiles-src-container html))
html
(let* ((target (gethash (org-element-property :value src-block)
dotfiles-tangle-map))
(name (org-element-property :name src-block))
(anchor (and name (gethash name dotfiles-noweb-names) name))
(language (org-element-property :language src-block))
(label
(cond
(target (format "<a class=\"src-path\" href=\"%s%s\">%s</a>"
dotfiles-source-url target
(org-html-encode-plain-text target)))
(anchor (format "<span class=\"src-noweb\"><<%s>></span>"
(org-html-encode-plain-text anchor)))
(t (format "<span class=\"src-lang\">%s</span>"
(org-html-encode-plain-text
(or (cdr (assoc language dotfiles-src-lang-names))
language "")))))))
(concat dotfiles-src-container
(if anchor
(format "<div class=\"src-head\" id=\"%s\">"
(dotfiles-noweb-anchor anchor))
"<div class=\"src-head\">")
label "</div>\n"
(substring html (length dotfiles-src-container)))))))
(advice-add 'org-html-src-block :around #'dotfiles-html-src-block-head)
;; Anchor headings by a slug of their title instead of the generated org<hash>.
;; Those hashes are derived from buffer positions, so every edit above a heading
;; renames it and any URL anyone saved stops working. A slug also says where it
;; lands. Org routes heading ids, the table of contents and internal [[*Heading]]
;; links through `org-export-get-reference', so overriding it moves all three
;; together and nothing is left pointing at the old id.
(defvar dotfiles-slug-by-heading nil)
(defvar dotfiles-slug-taken nil)
(defun dotfiles-slugify (text)
"Lowercase TEXT with runs of non-alphanumerics turned into single hyphens.
Japanese headings survive this: `[:alnum:]' matches CJK, so the translated
document gets readable anchors of its own rather than empty ones."
(let ((slug (downcase text)))
(setq slug (replace-regexp-in-string "[^[:alnum:]]+" "-" slug))
(replace-regexp-in-string "^-+\\|-+$" "" slug)))
(defun dotfiles-title-slug (headline)
"Slug of HEADLINE's own title, ignoring where it sits in the outline."
(let ((slug (dotfiles-slugify
(substring-no-properties
(org-element-interpret-data
(org-element-property :title headline))))))
(if (string-empty-p slug) "section" slug)))
(defun dotfiles-heading-slug (headline)
"Slug for HEADLINE, qualified by its parent.
Every heading is named parent-then-self rather than only the ones that would
otherwise clash. Sixteen titles repeat in this document, and naming a heading
after itself alone means whether it keeps that name depends on what else exists:
adding a second \"Configuration\" earlier renames the one already published.
Including the parent makes a heading's URL a function of its own title and its
parent's, so an edit elsewhere in the document cannot rename it. It also empties
the collision set outright, leaving the numeric suffix below unreachable in
practice — it stays for the case of two same-named siblings."
(let* ((self (dotfiles-title-slug headline))
(parent (org-element-parent headline))
(base (if (and parent (eq (org-element-type parent) 'headline))
(concat (dotfiles-title-slug parent) "-" self)
self))
(slug base)
(suffix 2))
(while (gethash slug dotfiles-slug-taken)
(setq slug (format "%s-%d" base suffix))
(setq suffix (1+ suffix)))
(puthash slug t dotfiles-slug-taken)
slug))
;; Slugs are assigned here, in one pass over the finished tree, rather than when
;; Org first asks for a reference. Org resolves references lazily, so a link
;; transcoded early can claim a name ahead of the heading that reads best with
;; it: left to that order a sixth-level "Configuration" took the bare
;; `configuration' and the top-level chapter became `configuration-2'. Walking
;; the tree in document order makes the result depend on the document alone.
(defvar dotfiles-english-headings nil
"The English document's headings as (SLUG . LEVEL), in document order.")
(defvar dotfiles-translating nil
"Non-nil while exporting an edition that should reuse the English anchors.")
(defun dotfiles-assign-heading-slugs (tree _backend _info)
"Give every heading in TREE its slug, outermost and earliest first.
A translation is anchored by the English slugs rather than its own, so the same
fragment addresses the same section in both editions and switching language is a
matter of inserting the language into the path. po4a translates the document in
place and cannot add or drop a heading, which is what makes matching them up by
position sound — and is checked below, because a shifted match would silently
point every anchor at the wrong section."
(setq dotfiles-slug-by-heading (make-hash-table :test 'eq))
(setq dotfiles-slug-taken (make-hash-table :test 'equal))
(let ((headings (org-element-map tree 'headline #'identity)))
(if (not dotfiles-translating)
(progn
(dolist (headline headings)
(let ((slug (or
;; An explicit :CUSTOM_ID: is the author pinning a URL.
(org-element-property :CUSTOM_ID headline)
(dotfiles-heading-slug headline))))
(puthash headline slug dotfiles-slug-by-heading)))
(setq dotfiles-english-headings
(mapcar (lambda (headline)
(cons (gethash headline dotfiles-slug-by-heading)
(org-element-property :level headline)))
headings)))
(unless (= (length headings) (length dotfiles-english-headings))
(error "Translation has %d headings, English has %d"
(length headings) (length dotfiles-english-headings)))
(cl-loop for headline in headings
for (slug . level) in dotfiles-english-headings
do (unless (= (org-element-property :level headline) level)
(error "Translated outline diverges at %s" slug))
do (puthash headline slug dotfiles-slug-by-heading))))
tree)
#'dotfiles-assign-heading-slugs)
(add-to-list 'org-export-filter-parse-tree-functions
(defun dotfiles-export-get-reference (original datum info)
"Give headings a slug reference, leaving every other element to ORIGINAL."
(or (and (eq (org-element-type datum) 'headline)
(gethash datum dotfiles-slug-by-heading))
(funcall original datum info)))
(advice-add 'org-export-get-reference :around #'dotfiles-export-get-reference)
;; A heading is only linkable if the reader can get at its URL, so each one ends
;; with a link to itself. It is styled to appear on hover or keyboard focus.
(defun dotfiles-html-headline-anchor (original headline contents info)
"Append a self link to HEADLINE's heading tag.
The heading tag is found by search rather than at the start of the string,
because Org wraps it in an outline-container div."
(let ((html (funcall original headline contents info)))
(if (not (string-match "<h\\([1-6]\\) id=\"\\([^\"]+\\)\">" html))
html
(let* ((level (match-string 1 html))
(id (match-string 2 html))
(closing (concat "</h" level ">"))
(position (string-search closing html)))
(if (not position)
html
(concat (substring html 0 position)
"<a class=\"heading-anchor\" href=\"#" id "\""
" aria-label=\"Link to this section\">#</a>"
(substring html position)))))))
(advice-add 'org-html-headline :around #'dotfiles-html-headline-anchor)
(defun dotfiles-export-html (lang)
"Export the LANG edition of the configuration document."
;; po4a names the translations configuration.<lang>.org; English is the
;; untranslated original and carries no infix.
(let ((suffix (if (equal lang "en") "" (concat "." lang))))
(setq dotfiles-translating (not (equal lang "en")))
(when (and dotfiles-translating (null dotfiles-english-headings))
(error "Export English before %s: its anchors come from the original" lang))
(setq dotfiles-tangle-map (dotfiles-build-tangle-map suffix))
(find-file (concat "configuration" suffix ".org"))
(org-html-export-to-html)))
;; Which editions to export, set by scripts/build-html.sh. Building English
;; alone is the fast path while iterating on prose or styling: it skips both the
;; second export and the po4a run that would have to precede it.
(dolist (lang (split-string (or (getenv "DOTFILES_HTML_LANGS") "en ja")))
(dotfiles-export-html lang))
build-html.sh wraps that export so the same command produces the published
site and a local preview. The build used to live in the Nix derivation,
which made previewing a stylesheet edit cost a full nix build: around a
minute of sandbox setup and source copying to wrap an export that takes
seven seconds. Now the derivation only calls this script, and iterating
locally runs it directly.
Emacs writes each page beside its source document, so the script moves the results into an output directory and deletes what Emacs left behind; kept where they land, they would show up as untracked files after every build.
set -euo pipefail
output=build
langs="en ja"
while [ $# -gt 0 ]; do
case $1 in
-o | --output)
output=$2
shift 2
;;
-l | --langs)
langs=$2
shift 2
;;
*)
echo "usage: build-html.sh [--output DIR] [--langs 'en ja']" >&2
exit 1
;;
esac
done
cd "$(dirname "$0")/.."
# Translations are anchored by the English slugs, so English is always built and
# always built first. Asking for a translation alone would otherwise fail.
translations=$(tr ' ' '\n' <<<"$langs" | grep -vx -e en -e '' | tr '\n' ' ' || true)
langs="en $translations"
# po4a regenerates the translated Org documents from po/ja.po, so only a build
# that includes a translation needs it. Skipping it is what lets an
# English-only build run outside the dev shell, the only place po4a is
# installed.
if [ -n "$translations" ]; then
po4a po4a.cfg
fi
DOTFILES_HTML_LANGS="$langs" emacs --batch -l scripts/org-to-html.el
# mkdir then install, rather than install -D, because BSD install has no -D
# and this runs on macOS as well as in the Linux sandbox.
for lang in $langs; do
if [ "$lang" = en ]; then
exported=configuration.html
published=$output/index.html
else
exported=configuration.$lang.html
published=$output/$lang/index.html
fi
mkdir -p "$(dirname "$published")"
install -m644 "$exported" "$published"
rm -f "$exported"
done
# Usage: check-git-changes <message> [git-diff-args...]
# Exits 1 if git diff finds changes, with instructions to stage them.
set -euo pipefail
message="$1"
shift
changed=$(git diff --name-only "$@")
if [ -n "$changed" ]; then
echo "$message"
echo "Changed files:"
echo "$changed"
echo ""
echo "Please stage the changes and commit again:"
echo " git add $changed"
exit 1
fi
set -euo pipefail
po4a po4a.cfg
check-git-changes "po4a updated translation files." -- po/ '*.ja.org'
set -euo pipefail
make -B tangle -j
check-git-changes "Org files were out of sync and have been auto-tangled."
(require 'ox-md)
(re-search-forward "^\\* Philosophy")
(org-md-export-to-markdown nil t)
org-export-with-author 設定は明示的に無効にしています。=configuration.org= には #+AUTHOR:
キーワードがないため、Org modeはEmacsの変数 user-full-name
にフォールバックします。macOSではこの変数がシステムディレクトリサービス(=dscl= / getpwuid=)から設定され、不要な
=#+author:
行が出力されます。Linuxでは、特にCI環境では値が通常空のため、この行は省略されます。この設定を無効にすることで、プラットフォーム間で一貫した出力を保証します。
(require 'ox-org)
(defun export-if-changed (destination)
;; Exporting directly rewrites the README even when its content is unchanged.
;; po4a uses mtimes to decide whether its source needs regeneration, so keep
;; the destination's mtime stable when the exported bytes are identical.
(let ((temporary (make-temp-file "org-export-")))
(unwind-protect
(progn
(org-export-to-file 'org temporary)
(if (and (file-exists-p destination)
(with-temp-buffer
(insert-file-contents-literally destination)
(let ((destination-content (buffer-string)))
(erase-buffer)
(insert-file-contents-literally temporary)
(equal destination-content (buffer-string)))))
(delete-file temporary)
(set-file-modes temporary (if (file-exists-p destination) (file-modes destination) #o644))
(rename-file temporary destination t)
(setq temporary nil)))
(when (and temporary (file-exists-p temporary))
(delete-file temporary)))))
(let ((org-export-select-tags (list "readme"))
(org-export-with-author nil)
(org-export-with-tags nil)
(org-export-time-stamp-file nil))
(export-if-changed export-readme-dest))
4. 開発#
このリポジトリは設定作業に必要なすべてのツールを備えたNix開発シェルを提供します。以下のコマンドでシェルに入れます:
nix develop
このシェルにはインフラツール(Terraform、sops、ssh-to-age)、翻訳ツール(po4a、gettext)、ビルドユーティリティ(nix-fast-build)が含まれています。シェルに入ると自動的にpre-commitフックのセットアップ、MCPサーバーの設定、文芸的ソースからの
AGENTS.md 同期が行われます。
The shell is exposed as a check as well. The build target walks the flake's
checks, so a tool that stops building fails there instead of on the next
nix develop.
{ ... }:
{
perSystem =
{
config,
lib,
pkgs,
...
}:
let
terraform' = pkgs.terraform.withPlugins (p: [
p.carlpett_sops
p.cloudflare_cloudflare
p.determinatesystems_hydra
p.hashicorp_aws
p.hashicorp_external
p.hashicorp_null
p.integrations_github
p.oracle_oci
]);
in
{
devShells = {
default = pkgs.mkShell {
packages = with pkgs; [
aws-vault
nix-fast-build
sops
ssh-to-age
terraform'
<<translation-packages>>
];
shellHook =
config.pre-commit.installationScript
+ config.mcp-servers.shellHook
+ ''
if ${lib.getExe pkgs.rbw} unlocked >/dev/null 2>&1; then
export MCP_GRAFANA_TOKEN="$(${lib.getExe pkgs.rbw} get mcp-grafana)"
fi
echo "Syncing AGENTS.md..."
make AGENTS.md >/dev/null 2>&1 || echo "Warning: Failed to generate AGENTS.md"
'';
};
terraform = pkgs.mkShell {
packages = [ terraform' ];
};
};
checks.devShell = config.devShells.default;
};
}
4.1. Pre-commit hooks#
Hooks run by git-hooks.nix on
every commit. prek is used as the runner because it is the
actively-maintained Rust port of pre-commit, with a faster cold start and
parallel hook execution out of the box.
check-org-tangle is the only hook with priority = 0 because it must run
before everything else: if Org files are out of sync, formatters and linters
downstream would otherwise fail against stale tangle output. Every other
hook keeps the default priority and runs in parallel under prek.
{ inputs, ... }:
{
imports = [ inputs.git-hooks.flakeModule ];
# Ensure this hook runs before all other hooks
perSystem =
{ pkgs, ... }:
{
pre-commit = {
check.enable = true;
settings = {
package = pkgs.prek;
src = ../..;
hooks =
let
check-git-changes = pkgs.writeShellApplication {
name = "check-git-changes";
runtimeInputs = [ pkgs.git ];
text = builtins.readFile ../../scripts/check-git-changes.sh;
};
emacs-with-org = (pkgs.emacsPackagesFor pkgs.emacs).emacsWithPackages (epkgs: [ epkgs.org ]);
in
{
actionlint = {
enable = true;
priority = 10;
};
oxlint = {
enable = true;
priority = 10;
};
lua-ls = {
enable = false;
priority = 10;
};
nil = {
enable = true;
priority = 10;
};
ruff = {
enable = true;
priority = 10;
};
shellcheck = {
enable = true;
priority = 10;
};
treefmt = {
enable = true;
priority = 10;
};
typos = {
enable = true;
priority = 10;
excludes = [
".sops.yaml"
"homes/shared/gpg/keys.txt"
"secrets.yaml"
"secrets/default.yaml"
"hosts/nixos/tarangire/facter.json"
"systems/shared/hercules-ci/binary-caches.json"
];
settings.configPath = "typos.toml";
};
yamllint = {
enable = true;
priority = 10;
excludes = [
"secrets/default.yaml"
"secrets.yaml"
];
settings.configData = "{rules: {document-start: {present: false}}}";
};
po4a = {
enable = true;
name = "po4a";
description = "Update translations with po4a";
priority = 10;
entry = pkgs.lib.getExe (
pkgs.writeShellApplication {
name = "check-po4a";
runtimeInputs = [
pkgs.po4a
pkgs.gettext
check-git-changes
];
text = builtins.readFile ../../scripts/check-po4a.sh;
}
);
files = "(\\.org|po/.*\\.po)$";
pass_filenames = false;
};
"check-org-tangle" = {
enable = true;
name = "check-org-tangle";
description = "Verify org files are tangled and synchronized";
priority = 0;
entry = pkgs.lib.getExe (
pkgs.writeShellApplication {
name = "check-org-tangle";
runtimeInputs = [
emacs-with-org
pkgs.gnumake
check-git-changes
];
text = builtins.readFile ../../scripts/check-org-tangle.sh;
}
);
always_run = true;
pass_filenames = false;
};
};
};
};
};
}
4.2. Formatting#
Formatters are aggregated via
treefmt-nix and invoked from the
treefmt pre-commit hook, so a single declaration covers both editor
integrations and the commit-time check.
{ inputs, ... }:
{
imports = [ inputs.treefmt-nix.flakeModule ];
# Match nixfmt's width instead of ruff's black-inherited default of 88.
perSystem = _: {
treefmt = {
projectRootFile = "flake.nix";
programs = {
oxfmt.enable = true;
nixfmt.enable = true;
ruff-format = {
enable = true;
lineLength = 100;
};
shfmt.enable = true;
stylua.enable = true;
taplo.enable = true;
terraform.enable = true;
yamlfmt.enable = true;
};
};
};
}
4.3. MCPサーバー#
開発シェルで有効化されるmcp-servers-nixの設定です。=flavors.claude-code=
プリセットにより、Claude Codeの期待するフォーマットと互換性のある .mcp.json ファイルが生成されます。
nixos: NixOSパッケージ/オプション検索とHome Managerのドキュメントterraform: Terraformレジストリのプロバイダー、モジュール、ポリシー検索grafana: ホームサーバーのGrafanaインスタンスからダッシュボード、データソース、メトリクスを照会
有効なサーバー:
{ inputs, ... }:
{
imports = [ inputs.mcp-servers.flakeModule ];
perSystem = _: {
mcp-servers = {
flavors.claude-code.enable = true;
programs = {
nixos.enable = true;
terraform.enable = true;
grafana = {
enable = true;
type = "http";
url = "http://mcp.home.natsukium.com/mcp";
headers.Authorization = "Bearer \${MCP_GRAFANA_TOKEN}";
};
};
};
};
}
4.4. 翻訳#
このプロジェクトでは翻訳にpo4aを使っています。
4.4.1. 必要なソフトウェア#
必要なパッケージは開発シェルに含まれています。
gettext
po4a
gettext: msgfmtやその他の国際化ユーティリティを提供po4a: Org modeサポートにはpo4a >= 0.74が必要です。
4.4.2. 翻訳作業#
4.4.2.1. po4aを設定する#
対象となる言語、生成するpoファイルを置くディレクトリ、それから翻訳対象のドキュメントを以下のように設定します。=-k 0=というオプションは翻訳が不完全な場合でも翻訳されたファイルを出力するものです。(デフォルトでは80%以上翻訳されているときのみ出力されます。)
[po4a_langs] ja
[po4a_paths] po/dotfiles.pot $lang:po/$lang.po
[type: org] configuration.org $lang:configuration.$lang.org opt:"-k 0"
[type: org] .github/README.org $lang:.github/README.$lang.org opt:"-k 0"
[type: org] modules/features/editor/emacs/init.org $lang:modules/features/emacs/init.$lang.org opt:"-k 0"
[type: org] modules/features/editor/emacs/early-init.org $lang:modules/features/emacs/early-init.$lang.org opt:"-k 0"
[type: org] overlays/configuration.org $lang:overlays/configuration.$lang.org opt:"-k 0"
[type: org] modules/configuration.org $lang:modules/configuration.$lang.org opt:"-k 0"
=po4a.cfg=について、詳しくは=man po4a=を参照してください。
4.4.2.2. poを生成/更新する#
ドキュメントを更新したら、次のコマンドでpoファイルを更新する必要があります。このコマンドはテンプレート(pot)と各言語に対応したpoを=po4a.cfg=で設定したパスに生成します。
po4a --no-translations po4a.cfg
4.4.2.3. 翻訳する#
対象となる言語のpoをpoエディタで編集します。Emacsのpo-modeやpoedit、GNOMEのGtranslator、KDEのLokalizeが有名です。
4.4.2.4. 翻訳ファイルを生成/更新する#
翻訳が終わったら次のコマンドでファイルを生成します。このときpoも更新されるため、実運用上はこのコマンドを実行するだけで良いでしょう。
po4a po4a.cfg