Compare commits
29 Commits
main
...
0cb63b06f5
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cb63b06f5 | |||
| 60b436f590 | |||
| 0abbf29c00 | |||
| c00eed81e9 | |||
| 56c1ebd98a | |||
| ea62786b25 | |||
| 8a38d0768c | |||
| 731ef46975 | |||
| c06b6eea38 | |||
| 2c6befda52 | |||
| a5f19b4822 | |||
| e5c42f5d13 | |||
| 0718098c13 | |||
| 5791903b46 | |||
| 516245cded | |||
| f2ec07887a | |||
| 10b3420bec | |||
| 484b52b7ef | |||
| 848ea428c0 | |||
| 658d2bc3ea | |||
| a61ece2582 | |||
| 96f2de54ca | |||
| 6c1ebfdd5f | |||
| 657e9f01cf | |||
| dcd5fdf614 | |||
| 4bffa8dede | |||
| e53d2d2194 | |||
| 451c02f798 | |||
| f8a084e111 |
33
.claude/CONTEXT.md
Normal file
33
.claude/CONTEXT.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# NixOS Dotfiles
|
||||
|
||||
A single flake that builds every machine the user owns — laptop, desktop, and three servers — from one shared, modular configuration.
|
||||
|
||||
## Language
|
||||
|
||||
**Host**:
|
||||
One physical machine the flake builds a NixOS configuration for. Each Host has a directory under `hosts/` holding its machine-specific `hardware-configuration.nix` and its choice of enabled Modules.
|
||||
_Avoid_: machine, node, system, box
|
||||
|
||||
**Module**:
|
||||
A single `.nix` feature file under `modules/` that declares an `enable` option and the configuration it turns on. Every Module is always imported but stays inert until a Host enables it.
|
||||
_Avoid_: component, package, plugin
|
||||
|
||||
**Skeleton**:
|
||||
The flake's plumbing — the Auto-loader, the helper lib, the flake inputs/overlays, and the shared base config — as distinct from the Modules that sit on top of it.
|
||||
_Avoid_: framework, core, base, scaffolding
|
||||
|
||||
**Auto-loader**:
|
||||
The lib code that recursively discovers and imports every Module under `modules/` (and every Host under `hosts/`) so new files wire themselves in without manual `imports` edits.
|
||||
_Avoid_: loader, importer, scanner
|
||||
|
||||
**Enable convention**:
|
||||
The rule that every Module is imported unconditionally and guards its own body with `mkIf config.modules.<path>.enable`, so a Host reads as a checklist of `enable = true` flags.
|
||||
_Avoid_: feature flag, toggle, opt-in
|
||||
|
||||
**unstable overlay**:
|
||||
The overlay exposing `nixpkgs-unstable` packages as `unstable.<name>`, used to pull an individual package fresher than the `nixos-unstable` base.
|
||||
_Avoid_: bleeding-edge, latest
|
||||
|
||||
**stable overlay**:
|
||||
The overlay exposing the latest stable release (`nixos-26.05`) as `stable.<name>`, used to pin an individual package to the rock-solid release from the `nixos-unstable` base.
|
||||
_Avoid_: LTS, release channel
|
||||
10
.claude/adr/0001-sops-nix-for-secrets.md
Normal file
10
.claude/adr/0001-sops-nix-for-secrets.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Use sops-nix for secrets
|
||||
|
||||
The repo is public, so no secret — including password hashes and the WireGuard/ProtonVPN key — may be committed in plaintext. We manage all secrets with **sops-nix**: encrypted into the repo and decrypted per-host at activation via an age key derived from each machine's SSH host key.
|
||||
|
||||
We chose sops-nix over agenix for its multi-recipient encryption (one secret readable by both a host and the admin laptop) and its grouped-file editing workflow, which scale better across the planned five hosts with a mix of shared and per-host secrets. The cost is slightly more upfront machinery than agenix's one-file-per-secret model.
|
||||
|
||||
## Consequences
|
||||
|
||||
- User/root passwords use `hashedPasswordFile` backed by a sops secret, never a committed hash.
|
||||
- Each new host must have its SSH host public key registered as a recipient before it can decrypt its secrets.
|
||||
@@ -1 +0,0 @@
|
||||
../.agents/skills
|
||||
92
.claude/spec/laptop-mvi.md
Normal file
92
.claude/spec/laptop-mvi.md
Normal file
@@ -0,0 +1,92 @@
|
||||
## Problem Statement
|
||||
|
||||
I'm returning to NixOS after ~2 years away, and I want to start by moving my laptop (`neogaia`, a Dell XPS 13 9380 currently running CachyOS) onto it. My old config still exists but is stale and written in a style I no longer want to copy verbatim. Eventually this same config has to grow to cover my desktop and three servers, so whatever I build for the laptop has to be a clean, scalable foundation — not a throwaway.
|
||||
|
||||
Reimaging the laptop is destructive and I only get one machine, so I need a tightly-scoped, well-understood **minimum viable install (MVI)**: the smallest config that boots the laptop into a usable state I can then iterate on live, without risking a half-defined system that strands me at a dead console.
|
||||
|
||||
## Solution
|
||||
|
||||
Rebuild the `Skeleton` and a single `neogaia` `Host` to the point where the laptop:
|
||||
|
||||
- boots from an encrypted disk (LUKS + btrfs + zram),
|
||||
- comes up on wifi,
|
||||
- lets me log into a console as my user and run `nixos-rebuild switch`,
|
||||
- and already carries my core terminal tooling (fish, tmux, nvim, Claude Code).
|
||||
|
||||
Everything graphical and everything multi-host is deliberately left for later iterative passes, which are safe because a mistake then is "edit and rebuild," not "reimage." The MVI is the one step that must be right *before* reimaging; the rest is reversible.
|
||||
|
||||
The install itself is done from the NixOS live ISO by cloning the repo from my Gitea and running a single `disko-install` against the `neogaia` `Host`, then setting a bootstrap password by hand.
|
||||
|
||||
## User Stories
|
||||
|
||||
1. As the operator, I want the `Skeleton` rewritten around my old scalable ideas (the `Auto-loader`, the `Enable convention`, per-`Host` layout), so that the config stays legible and shareable across all five future machines without me copying stale code.
|
||||
2. As the operator, I want the flake hand-rolled and cleaned up (no framework layer), so that the whole plumbing stays readable in one place for a config that only targets a handful of `x86_64-linux` machines.
|
||||
3. As the operator, I want every `Module` auto-discovered and imported but inert until a `Host` sets its `enable` flag, so that each `Host` reads as a checklist of features.
|
||||
4. As the operator, I want a `nixos-unstable` base with an `unstable overlay` and a `stable overlay`, so that I can run rolling by default but reach up to bleeding-edge or down to rock-solid on a per-package basis.
|
||||
5. As the operator, I want home-manager integrated as a NixOS module with global packages, so that one `nixos-rebuild switch` builds both the system and my user environment atomically.
|
||||
6. As the operator, I want my user modelled as an explicit option defaulting to `alexion` (no impure environment lookup), so that the config is reproducible and honest about who the user is.
|
||||
7. As the operator, I want the laptop's disk declared with `disko` as encrypted btrfs plus zram swap, so that the install is reproducible and the laptop is encrypted at rest.
|
||||
8. As the operator, I want the system to prompt for the LUKS passphrase at boot via systemd-boot and the initrd, so that the encrypted disk unlocks on a normal boot.
|
||||
9. As the operator, I want the CachyOS kernel from chaotic-nyx with the chaotic binary cache wired in from the first build, so that I get the performance/feel I'm used to without compiling the kernel from source.
|
||||
10. As the operator, I want Intel microcode and the redistributable firmware for the QCA6174 wifi included, so that the laptop's hardware works out of the box.
|
||||
11. As the operator, I want NetworkManager enabled, so that I can join wifi easily from the console.
|
||||
12. As the operator, I want an SSH daemon running, so that I can drive the rest of the setup remotely if the console is inconvenient.
|
||||
13. As the operator, I want my user in `wheel` with a manually-set bootstrap password, so that I can log in and use sudo on first boot without committing any secret to a public repo.
|
||||
14. As the operator, I want fish as my default login shell, configured natively via home-manager with my `cachyos-config.fish` translated (greeting, bat-manpager, `done` and bang-bang plugins, helper functions, eza/nav aliases) and all Arch/pacman-specific parts dropped or replaced with NixOS equivalents, so that my shell feels like home but is correct for NixOS.
|
||||
15. As the operator, I want tmux configured natively via home-manager using my exact existing `tmux.conf` text, so that my terminal multiplexer is identical to today with no plugin manager needed.
|
||||
16. As the operator, I want my nvim config brought in verbatim (lazy.nvim managing its own plugins) via a writable out-of-store symlink, with `git`/`gcc`/`ripgrep`/`fd` provided by Nix, so that my editor is identical to today and lazy.nvim can still update and write its lockfile.
|
||||
17. As the operator, I want Claude Code installed declaratively and authenticatable without a browser on the laptop, so that I can use it over the console/SSH via the paste-code flow or an API key.
|
||||
18. As the operator, I want timezone `America/New_York`, locale `en_GB.UTF-8`, and console keymap `us` set, so that the base system matches my locale preferences.
|
||||
19. As the operator, I want to install by cloning the repo from my Gitea onto the live ISO and running `disko-install` against `neogaia`, so that I avoid self-signed-TLS/auth problems with flake fetching during install.
|
||||
20. As the operator, I want the `Skeleton` designed so that per-`Host` disk layouts, per-`Host` kernels, and preserved ZFS pools are all expressible, so that the same foundation extends to the desktop and the three servers later without restructuring.
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
**Skeleton**
|
||||
- Hand-rolled flake, rewritten and trimmed; no flake-parts.
|
||||
- `Auto-loader` rewritten: recursively discovers and imports every `Module` under the modules tree without the old null-placeholder traversal hack; a single discovery helper feeds the `Host` imports. The old `nixosModules` flake output is dropped.
|
||||
- Helper lib trimmed to the `Auto-loader`, the host-builder, and the script-from-file helper. `with lib.my` replaced by explicit `inherit`s throughout. `enable` flags use the stdlib enable-option helper rather than bespoke sugar.
|
||||
- `nixos-unstable` as the base channel. An `unstable overlay` exposes `nixpkgs-unstable` packages; a `stable overlay` exposes the latest stable release (`nixos-26.05`). chaotic-nyx added as an input with its overlay and binary cache from the start.
|
||||
- home-manager sourced from `nix-community`, tracking master with nixpkgs followed, integrated as a NixOS module with global packages and user packages.
|
||||
- User modelled as an explicit option defaulting to `alexion`, in `wheel`, driving the system user and the home-manager user in lockstep.
|
||||
|
||||
**neogaia Host**
|
||||
- Disk declared via `disko`: LUKS-encrypted btrfs with subvolumes plus zram swap. systemd-boot on an EFI system partition; initrd LUKS unlock.
|
||||
- CachyOS kernel selected via a small per-`Host` kernel mechanism; chaotic substituter and trusted key in the Nix settings.
|
||||
- Intel microcode; redistributable firmware enabled for the QCA6174 wifi. NetworkManager for networking. A zram toggle `Module` enabled here.
|
||||
- SSH daemon enabled. Baseline CLI (git, editor, flakes) present. Claude Code installed declaratively.
|
||||
- fish `Module`: native home-manager configuration; translated aliases/functions/plugins/init; set as the default login shell. tmux `Module`: native home-manager, exact existing config text inlined. nvim `Module`: verbatim config placed as a writable out-of-store symlink with runtime dependencies provided by Nix.
|
||||
- Locale, timezone, and keymap set to the detected values.
|
||||
|
||||
**Install flow**
|
||||
- Repo pushed to Gitea first. From the NixOS live ISO: join wifi, clone the repo locally, run `disko-install` against the `neogaia` `Host` with the chaotic substituter passed to the install-time daemon, set bootstrap passwords via `nixos-enter`, reboot.
|
||||
|
||||
**Secrets (design only in MVI)**
|
||||
- Per ADR 0001, secrets use `sops-nix` with age keys derived from each `Host`'s SSH host key. The MVI does not wire any secret, because a `Host`'s age key does not exist until its first install generates the SSH host key. The bootstrap password is set by hand and never committed; moving passwords to a `hashedPasswordFile` backed by a sops secret is the first post-boot task, out of scope here.
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
- A good test here asserts externally-observable evaluation/build success of the whole `Host`, not the internals of any individual `Module`.
|
||||
- **Primary seam (required):** the `neogaia` `Host` evaluates and its system toplevel builds. Building the toplevel drives the entire `Skeleton` — the `Auto-loader` discovering every `Module`, all three overlays resolving, home-manager integration, and every enabled module's config merging without conflict — plus the `disko` layout, which builds from the same tree. Nearly all config-authoring errors surface at this seam short of booting real hardware.
|
||||
- No unit-level tests of individual modules; the config-merge model makes the whole-`Host` build the meaningful unit, and it is the highest available seam.
|
||||
- Prior art: none in this repo yet (it starts empty); this build-the-toplevel check is the pattern to reuse for every future `Host`.
|
||||
- The genuine end-to-end confirmation is the real reimage, which is manual and irreversible by nature and is not automated.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Any graphical environment: Wayland-vs-i3 choice, greeter/display manager, theming (Nord via Stylix or otherwise), fonts, terminal emulator, browser, general desktop apps, gaming (Steam/Lutris/proton-cachyos), emulation.
|
||||
- Full `sops-nix` wiring and moving passwords off the bootstrap value (immediate post-boot follow-up, but not MVI).
|
||||
- Migrating nvim to a native home-manager configuration with Nix-managed plugins.
|
||||
- chaotic-nyx packages beyond the kernel (`mesa-git`, `proton-cachyos`, `scx` schedulers).
|
||||
- Flatpak strategy (`nix-flatpak` vs dropping the old imperative helper).
|
||||
- The desktop `Host` (`zeus`), including Nvidia.
|
||||
- The three servers: deployment model, service migration (plex/arr/kavita/nfs/torrent-through-protonvpn), ZFS wiring and pool import, backups/monitoring, and per-server kernel/channel pinning.
|
||||
- VM-based CI (`nixosTest` boot assertions) — explicitly a future addition, not part of this deliverable.
|
||||
|
||||
## Further Notes
|
||||
|
||||
- **Bootstrap ordering:** the flake must exist on Gitea before the install can consume it, and the manual password step keeps the public repo free of any secret while still yielding a login on first boot.
|
||||
- **Gitea is a bootstrap dependency:** every NixOS install pulls the config from self-hosted Gitea, so the Gitea host must stay reachable during any install — relevant when sequencing the servers so the migration never locks the operator out of their own configs.
|
||||
- **chaotic cache at install time:** the install-time Nix daemon on the live ISO must have the chaotic substituter configured, or it compiles the CachyOS kernel from source on the USB stick.
|
||||
- **Extends to future Hosts by construction:** disk layout, kernel, and channel are all per-`Host` concerns in the `Skeleton`, and existing ZFS pools are preserved by import rather than declared through `disko`. This is what lets the desktop and the three servers join later without reworking the foundation.
|
||||
- **Theme target is Nord** (the current CachyOS setup is Nord across terminal, tmux, and nvim), superseding the old repo's Dracula — relevant when the theming branch is grilled.
|
||||
37
.claude/tasks/0001-skeleton-and-building-host.md
Normal file
37
.claude/tasks/0001-skeleton-and-building-host.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
spec: laptop-mvi
|
||||
---
|
||||
|
||||
## What to build
|
||||
|
||||
Stand up the `Skeleton` and a minimal `neogaia` `Host` that evaluates and whose system toplevel builds — the walking skeleton every later slice extends and re-verifies against.
|
||||
|
||||
The `Skeleton` is a hand-rolled flake (no flake-parts): `nixos-unstable` base channel, an `unstable overlay` exposing `nixpkgs-unstable` as `unstable.<name>`, a `stable overlay` exposing `nixos-26.05` as `stable.<name>`, and chaotic-nyx wired as an input with its overlay and binary cache.
|
||||
The helper lib is trimmed to three pieces: the `Auto-loader` (recursively discovers and imports every `Module` under `modules/` and every `Host` under `hosts/` with no null-placeholder traversal hack), the host-builder, and the script-from-file helper.
|
||||
`with lib.my` is not used — dependencies are `inherit`ed explicitly.
|
||||
The `Enable convention` uses the stdlib enable-option helper; every `Module` is imported unconditionally and guards its body with `mkIf config.modules.<path>.enable`.
|
||||
home-manager is sourced from `nix-community` (master, nixpkgs followed) and integrated as a NixOS module with global packages and user packages.
|
||||
The `user` is an explicit option defaulting to `alexion` (no impure environment lookup), placed in `wheel`, driving the system user and the home-manager user in lockstep.
|
||||
The `neogaia` `Host` carries only enough (placeholder `hardware-configuration.nix`, filesystems/bootloader stubs, `stateVersion`) to make `nixosConfigurations.neogaia.config.system.build.toplevel` evaluate and build; real disk/kernel/networking arrive in later slices.
|
||||
|
||||
Design the per-`Host` layout so disk layout, kernel, and channel are all per-`Host` concerns from the start (story 20), so the desktop and servers extend this foundation without restructuring.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `nix flake check` succeeds and the flake exposes `nixosConfigurations.neogaia`.
|
||||
- [x] `nixosConfigurations.neogaia.config.system.build.toplevel` builds.
|
||||
- [x] Adding a new `.nix` file under `modules/` is auto-discovered and imported without editing any `imports` list, and stays inert until its `enable` flag is set.
|
||||
- [x] All three overlays resolve: `unstable.<pkg>`, `stable.<pkg>`, and a chaotic-nyx package are each reachable in a `Host`.
|
||||
- [x] home-manager builds as part of the same `nixos-rebuild switch` toplevel (system + user environment atomic).
|
||||
- [x] The `user` option defaults to `alexion`, has no impure environment lookup, places the user in `wheel`, and drives both the system and home-manager user.
|
||||
- [x] The old `nixosModules` flake output and the `with lib.my` idiom are absent.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **Verification.** `nix flake check` builds `checks.x86_64-linux.neogaia` = the Host toplevel (the spec's primary seam). Overlays confirmed via `nix eval` of `pkgs.unstable.hello` (2.12.3), `pkgs.stable.hello` (2.12.1), and `pkgs.linuxPackages_cachyos.kernel` (7.1.3, from chaotic). Auto-loader inertness confirmed both ways: the reference `modules/example.nix` is off by default, and `extendModules` with `modules.example.enable = true` activates its body.
|
||||
- **chaotic binary cache.** Wired via `inputs.chaotic.nixosModules.default`, which puts both the `nyx-cache.chaotic.cx` substituter and its trusted public key into the built config (verified by evaluating `config.nix.settings.substituters`/`trusted-public-keys`). chaotic deliberately does **not** follow our nixpkgs, so the cache stays usable. Making the substituter/key explicit is task 0003's concern; here it is inherited from the module.
|
||||
- **Shared base lives in `system/`.** The Skeleton's shared base config (overlays, `user`, flakes, home-manager wiring) is a `system/` module always imported by the host-builder, kept separate from the auto-loaded feature `Module`s under `modules/` so the base is never gated by an `enable` flag.
|
||||
- **`modules/example.nix` kept intentionally.** It is the Auto-loader / Enable-convention reference every real Module copies; remove it once a real Module supersedes its teaching value.
|
||||
- **`scriptFromFile` present but unused.** The task mandates the helper lib carry it ("the script-from-file helper"); its first caller lands with a later Module.
|
||||
- **Home-manager base user only.** The base sets `home.username`/`homeDirectory`/`stateVersion` for the `user`; `extraSpecialArgs` passes both `inputs` and `my` (the flake lib) so upcoming HM Modules (fish/tmux/nvim) can reach `scriptFromFile`.
|
||||
- **Deviations from plan.** Added an `options.user.description` (GECOS) alongside `user.name` — small and expected for a real account. Baseline `git` + global `allowUnfree` are set in the base (git is required for flakes; unfree is needed by chaotic/home-manager and later Claude Code). Placeholder `fileSystems`/bootloader and `hardware-configuration.nix` in `neogaia` are stubs that task 0002 (disko) replaces.
|
||||
31
.claude/tasks/0002-neogaia-disk-and-boot.md
Normal file
31
.claude/tasks/0002-neogaia-disk-and-boot.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
spec: laptop-mvi
|
||||
blocked-by: 0001-skeleton-and-building-host
|
||||
---
|
||||
|
||||
## What to build
|
||||
|
||||
Declare the `neogaia` laptop's disk with `disko` and make it unlock and boot on real hardware: a LUKS-encrypted btrfs volume with subvolumes plus zram swap, on an EFI system partition using systemd-boot, with the LUKS passphrase prompted at boot via the initrd.
|
||||
|
||||
The layout must build from the same tree as the `Host` toplevel (so the whole-`Host` build exercises it), and must be expressed as a per-`Host` disk concern so other machines can declare their own layouts later.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `neogaia` declares a `disko` layout: LUKS-encrypted btrfs with subvolumes plus zram swap on an EFI system partition.
|
||||
- [x] systemd-boot is the bootloader; the initrd prompts for the LUKS passphrase so a normal boot unlocks the encrypted disk.
|
||||
- [x] The `disko` layout builds as part of the `neogaia` toplevel build (no separate invocation needed to catch layout errors).
|
||||
- [x] The disk layout is a per-`Host` concern, expressible differently for future `Host`s without restructuring the `Skeleton`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **Layout.** One GPT disk at `/dev/nvme0n1`: a 512M EF00 ESP (vfat, `umask=0077`) mounted at `/boot`, and a 100%-fill LUKS partition (`cryptroot`, `allowDiscards`) holding a btrfs filesystem with three subvolumes — `@root` → `/`, `@home` → `/home`, `@nix` → `/nix` — each mounted `compress=zstd,noatime`.
|
||||
There is deliberately no on-disk swap partition; swap is RAM-backed zram.
|
||||
- **Skeleton vs. per-Host split.** The disko *module* (`inputs.disko.nixosModules.disko`) is wired into the host-builder in `lib/default.nix`, so every `Host` can interpret a `disko.devices` declaration; the *layout itself* lives in `hosts/neogaia/disk.nix`.
|
||||
A future `Host` declares a different layout, or none at all (an undeclared `disko.devices` is a no-op), so servers that preserve an existing pool by import need no `Skeleton` change.
|
||||
- **disko input follows nixpkgs.** Unlike chaotic (which must not), disko follows our `nixpkgs` so it builds against the same base.
|
||||
- **Boot unlock.** disko's `type = "luks"` (no key file) generates `boot.initrd.luks.devices.cryptroot`, so the classic initrd prompts for the passphrase on a normal boot; the `nvme` initrd module was already present in `hardware-configuration.nix`.
|
||||
- **zram enabled directly, not yet a Module.** Criterion 1 requires "plus zram swap," so `zramSwap.enable = true` is set on the `Host` now.
|
||||
Task 0003 owns the reusable zram toggle `Module` and will lift this line into it; the placeholder `fileSystems`/bootloader stubs from task 0001 are removed here since disko now derives `fileSystems`.
|
||||
- **Verification.** `nix flake check` (the `checks.x86_64-linux.neogaia` toplevel) builds green.
|
||||
Confirmed via `nix eval`: disko-derived `fileSystems` = `/`,`/home`,`/nix` on btrfs `/dev/mapper/cryptroot` + `/boot` on the ESP; `boot.initrd.luks.devices` = `["cryptroot"]`; `systemd-boot.enable` and `zramSwap.enable` both true; `swapDevices` empty.
|
||||
The genuine end-to-end confirmation is the manual `disko-install` reimage, which is irreversible by nature and not automated.
|
||||
26
.claude/tasks/0003-kernel-and-hardware.md
Normal file
26
.claude/tasks/0003-kernel-and-hardware.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
spec: laptop-mvi
|
||||
blocked-by: 0001-skeleton-and-building-host
|
||||
---
|
||||
|
||||
## What to build
|
||||
|
||||
Give `neogaia` the kernel and hardware enablement it needs to run well on the Dell XPS 13 9380: the CachyOS kernel pulled as a binary from chaotic-nyx (not compiled from source), Intel microcode, and the redistributable firmware for the QCA6174 wifi. Add a zram toggle `Module` and enable it here.
|
||||
|
||||
The kernel is selected through a small per-`Host` kernel mechanism so other `Host`s can choose different kernels. The chaotic substituter and its trusted public key are added to the Nix settings so the kernel is fetched from the binary cache from the first build.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `neogaia` runs the CachyOS kernel selected via a per-`Host` kernel mechanism, sourced from chaotic-nyx.
|
||||
- [x] The chaotic substituter and trusted public key are in the Nix settings, so the kernel is fetched from cache rather than compiled.
|
||||
- [x] Intel microcode is enabled.
|
||||
- [x] Redistributable firmware is enabled so the QCA6174 wifi hardware is available.
|
||||
- [-] A zram toggle `Module` exists (following the `Enable convention`) and is enabled on `neogaia`. — Module dropped in PR review; zram is enabled inline on `neogaia` instead (see notes).
|
||||
- [x] The `neogaia` toplevel still builds with all of the above.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **Per-`Host` kernel mechanism = native `boot.kernelPackages`.** neogaia sets `boot.kernelPackages = pkgs.linuxPackages_cachyos` directly in its Host directory (`hosts/neogaia/default.nix`). No custom wrapper option was added: `boot.kernelPackages` is already a per-`Host` setting, so other `Host`s pick their own kernel the same way. A string→package wrapper would have been premature abstraction with one `Host` and one kernel, so it was deliberately left out; the "mechanism" is the per-`Host` placement of the native option.
|
||||
- **Substituter/key live in the shared base, via the `extra-` options.** They were added to `system/default.nix` (shared by every `Host`), not just neogaia, because the chaotic module is wired for all `Host`s and the cache is general plumbing. `nix.settings.extra-substituters` / `extra-trusted-public-keys` are used rather than the replacing `substituters` / `trusted-public-keys`, so `cache.nixos.org` (and any other substituter) is only appended to, never dropped. chaotic's own module also provides these entries; the explicit declaration is belt-and-suspenders and keeps the built system's cache config visible and independent of that module.
|
||||
- **Dev-host build needed a daemon-level cache.** Building the toplevel here first compiled the CachyOS kernel (and rustc bootstrap) from source, because the build daemon's `/etc/nix/nix.conf` had no `nyx-cache` substituter — the built system's `nix.settings` do not govern the daemon doing the build, and the dev user is a non-trusted client that cannot add substituters from the CLI. Adding `extra-substituters`/`extra-trusted-public-keys` for `nyx-cache` to `/etc/nix/nix.conf` (sudo) and restarting `nix-daemon` fixed it; the build then fetched the kernel (7.1.3) from the cache. Recorded as a gotcha in `CLAUDE.md`.
|
||||
- **zram is enabled inline, not as a `Module` (criterion 5 dropped).** The task asked for a zram toggle `Module`, and one was built first (`modules/zram.nix`), but PR review rejected it as a single-line abstraction that wraps the native `zramSwap.enable` toggle without adding anything. It was removed, and `neogaia` sets `zramSwap.enable = true` directly, as it did before task 0003. The `Enable convention` reference remains `modules/example.nix`; real feature `Module`s arrive with fish/tmux/nvim/Claude Code in later tasks.
|
||||
28
.claude/tasks/0004-networking-and-base-system.md
Normal file
28
.claude/tasks/0004-networking-and-base-system.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
spec: laptop-mvi
|
||||
blocked-by: 0001-skeleton-and-building-host
|
||||
---
|
||||
|
||||
## What to build
|
||||
|
||||
Make the booted laptop a usable console I can log into and reach remotely: NetworkManager for joining wifi, an SSH daemon for driving the rest of the setup over the network, and the base locale settings.
|
||||
|
||||
Set timezone `America/New_York`, locale `en_GB.UTF-8`, and console keymap `us`.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] NetworkManager is enabled so wifi can be joined from the console.
|
||||
- [x] An SSH daemon is enabled so the machine can be driven remotely.
|
||||
- [x] Timezone is `America/New_York`, locale is `en_GB.UTF-8`, console keymap is `us`.
|
||||
- [x] The `neogaia` toplevel still builds with all of the above.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **Placement in the Host, not a Module.** NetworkManager, the SSH daemon, and the locale/timezone/keymap settings all live directly in `hosts/neogaia/default.nix`, alongside the kernel/hardware/zram lines from task 0003.
|
||||
This follows the precedent set in that task, where a speculative enable-gated Module was dropped in review in favour of inlining for the single-Host MVI.
|
||||
A shared locale Module or a networking Module can be extracted later when a second Host actually needs the same settings; extracting now would be speculative generality.
|
||||
- **SSH left unhardened deliberately.** `services.openssh.enable = true` keeps NixOS's default password authentication on.
|
||||
This is required by the install flow: first-boot access is over SSH with the hand-set bootstrap password, and no SSH keys or sops-derived age key exist until the install generates the Host's SSH host key.
|
||||
Moving to key-only auth / `hashedPasswordFile` is the first post-boot follow-up per the spec's Secrets section, out of scope for the MVI.
|
||||
- **Locale/timezone mix is as specified.** `i18n.defaultLocale = "en_GB.UTF-8"` with `time.timeZone = "America/New_York"` and `console.keyMap = "us"` mixes region and locale; this matches the operator's stated preferences verbatim and is intentional.
|
||||
- **Verification.** Built the primary seam — `nix build .#checks.x86_64-linux.neogaia` (the Host toplevel) — to exit 0; the systemd units for `wpa_supplicant` (NetworkManager's backend) and openssh appear in the build. The five option values were also confirmed via `nix eval`.
|
||||
44
.claude/tasks/0005-fish-shell-module.md
Normal file
44
.claude/tasks/0005-fish-shell-module.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
spec: laptop-mvi
|
||||
blocked-by: 0001-skeleton-and-building-host
|
||||
---
|
||||
|
||||
## What to build
|
||||
|
||||
A fish `Module`, configured natively via home-manager, that makes the shell feel like the current CachyOS setup but is correct for NixOS. Translate the existing `cachyos-config.fish` and the rest of the fish snapshot under `reference/home/.config/fish/`: the greeting, the bat-manpager, the `done` and bang-bang plugins, the helper functions, and the eza/nav aliases. Drop or replace every Arch/pacman-specific part with its NixOS equivalent. Set fish as the default login shell.
|
||||
|
||||
Configure the plugins natively through home-manager rather than a fish plugin manager.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] A fish `Module` (following the `Enable convention`) is enabled on `neogaia` and configured natively via home-manager.
|
||||
- [x] The greeting, bat-manpager, `done` and bang-bang plugins, helper functions, and eza/nav aliases from the reference config are reproduced.
|
||||
- [x] All Arch/pacman-specific parts are dropped or replaced with NixOS equivalents.
|
||||
- [x] fish is the user's default login shell.
|
||||
- [x] The `neogaia` toplevel still builds with the fish `Module` enabled.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **Plugins are native, not inlined.** `done` and `bang-bang` come from `pkgs.fishPlugins.*` via `programs.fish.plugins`; home-manager drops them into `~/.config/fish/conf.d/` where fish auto-sources them.
|
||||
The reference config inlined the bang-bang `__history_previous_command` functions and binds by hand; the plugin supplies those, so they are not re-inlined.
|
||||
- **`done` tuning uses `set -g`, not `set -U`.** The reference set `__done_min_cmd_duration`/`__done_notification_urgency_level` as universal variables, which persist to the universal-variable file and then ignore config changes.
|
||||
A declarative config must own these each session, so they are set global (`set -g`) in `interactiveShellInit`.
|
||||
- **Arch/pacman aliases dropped:** `grubup`, `fixpacman`, `mirror` (`cachyos-rate-mirrors`), `apt`/`apt-get` (`man pacman`), `big` (`expac`), `gitpkg`, `rip` (`expac`).
|
||||
**Replaced with NixOS equivalents:** `update` → `sudo nixos-rebuild switch` (was `pacman -Syu`), `cleanup` → `sudo nix-collect-garbage -d` (was `pacman -Rns`).
|
||||
- **Machine-specific PATH hacks dropped, per spec story 6 ("no impure environment lookup").** The hardcoded `BUN_INSTALL`, the `node-v24...` tarball path, and `~/Applications/depot_tools` from `config.fish` are absolute/impure and are not reproduced; such tooling should be Nix-provided when its own Module arrives.
|
||||
The portable bits are kept: `~/.local/bin` on PATH (guarded) and sourcing `~/.fish_profile`.
|
||||
- **`env.fish` and `rustup.fish` deliberately not carried over.** `ANDROID_HOME`/platform-tools (Android SDK) and `source ~/.cargo/env.fish` (rust) are dev-toolchain integrations outside the MVI's core tooling (fish/tmux/nvim/Claude Code); they belong to future per-toolchain Modules that provide those tools through Nix rather than sourcing an impure env file.
|
||||
- **`hw` (`hwinfo --short`) and `tb` (`nc termbin.com 9999`) dropped.** These are generic rather than pacman-specific, but each needs an extra package (`hwinfo`, a `netcat`) that the minimal install does not otherwise pull in; left out of the MVI and easy to add later.
|
||||
- **`copy` kept verbatim** (including the upstream `trim-right` call) to preserve exact parity with the current shell.
|
||||
- **Verification.** The `neogaia` system toplevel builds (the spec's primary seam).
|
||||
The rendered `~/.config/fish/` was inspected in the build output: aliases, the three helper functions, the `fastfetch` greeting, the bat manpager, the `done` tuning vars, and the `conf.d/plugin-done.fish` + `conf.d/plugin-bang-bang.fish` plugin files are all present; `users.users.alexion.shell` resolves to `pkgs.fish`.
|
||||
|
||||
### Post-review adjustments
|
||||
|
||||
- **`defaultShell` option.** Setting fish as the login shell moved behind `modules.fish.defaultShell` (default `false`, gated with `mkIf`); `neogaia` opts in explicitly. Enabling the Module alone no longer changes the login shell.
|
||||
- **Abbreviation-first.** Every non-eza alias is now a `shellAbbr` (the eza `ls` family stays an alias), `preferAbbrs = true`, and `generateCompletions = true` is pinned rather than left to the upstream default.
|
||||
- **vi command-line editing.** `interactiveShellInit` sets `fish_key_bindings fish_vi_key_bindings`; the `bang-bang` plugin re-binds `!`/`$` in insert mode via its own `--on-variable fish_key_bindings` handler, so the switch keeps them working.
|
||||
- **Trimmed aliases.** Navigation capped at four dots (`.....`/`......` dropped); `psmem`, `psmem10`, `dir`, `vdir`, and `please` removed.
|
||||
- **Interactive init lives in a real fish file.** The Module lives at `modules/fish/fish.nix`; its `interactiveShellInit` is `builtins.readFile ./config.fish`, so the interactive init is written as one editable fish file (vi editing, `EDITOR`/`VISUAL`, the bat manpager, the done plugin tuning, and `~/.local/bin`/`~/.fish_profile`) that home-manager renders into `~/.config/fish/config.fish`. The file is small enough that splitting it into fragments was not worth the indirection; Nix inlines it at build time, so nothing of ours is autoloaded from a separate runtime file.
|
||||
- **`copy` stays a function file.** `functions/copy.fish` holds the non-trivial `copy` body, read into the `functions` option; fish autoloads function files lazily, so that is the idiomatic home for a function. Trivial one-liner functions stay inline in `fish.nix`.
|
||||
- The Auto-loader only collects `.nix`, so every `.fish` file under `modules/fish/` is inert to it.
|
||||
43
.claude/tasks/0006-tmux-module.md
Normal file
43
.claude/tasks/0006-tmux-module.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
spec: laptop-mvi
|
||||
blocked-by: 0001-skeleton-and-building-host
|
||||
---
|
||||
|
||||
## What to build
|
||||
|
||||
A tmux `Module`, configured natively via home-manager, that reproduces the current terminal multiplexer exactly: the existing `tmux.conf` text (under `reference/home/.config/tmux/`) inlined verbatim, with no plugin manager needed.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] A tmux `Module` (following the `Enable convention`) is enabled on `neogaia` and configured natively via home-manager.
|
||||
- [x] The existing `tmux.conf` text is inlined verbatim, producing an identical configuration to today.
|
||||
- [x] No tmux plugin manager is used.
|
||||
- [x] The `neogaia` toplevel still builds with the tmux `Module` enabled.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
**Approach — option translation ("the nix way") instead of byte-verbatim inlining.**
|
||||
On the operator's explicit call ("I would prefer to do things the nix way. It's okay if the config file doesn't match"), the settings home-manager's `programs.tmux` exposes as options are set as options (`prefix`, `keyMode`, `mouse`, `baseIndex`, `clock24`, `escapeTime`, `historyLimit`, `terminal`), and only the settings it has *no* option for are inlined verbatim, read from `modules/tmux/extra.conf` via `builtins.readFile`.
|
||||
The generated `~/.config/tmux/tmux.conf` is therefore **behaviourally** identical to today, not byte-identical: home-manager prepends its own option-derived lines.
|
||||
This was preferred over `xdg.configFile.source = ./tmux.conf` (which would have been byte-identical) after weighing both.
|
||||
Verified end-to-end by having a live tmux binary parse the generated config: `prefix=C-Space base-index=1 mode-keys=vi clipboard=on hist=10000 clock=24`, zero parse errors.
|
||||
|
||||
**`clock24 = true` is required, not cosmetic.**
|
||||
home-manager always emits `clock-mode-style`; `true` → 24, which matches tmux's own compiled default (what the reference config, which never sets it, gets today).
|
||||
Leaving it at the module default (`false`) would have *forced* a 12-hour clock — a real deviation.
|
||||
|
||||
**Pane navigation stays in `extra.conf`.**
|
||||
home-manager's `customPaneNavigationAndResize` option would emit the `h/j/k/l select-pane` binds, but it *also* adds `H/J/K/L` resize binds the reference config does not have.
|
||||
To stay faithful, the `h/j/k/l` binds are inlined in `extra.conf` and the option is left off.
|
||||
|
||||
**`secureSocket` left at the home-manager default (`true`).**
|
||||
The tmux socket lives under `/run` rather than `/tmp`; it does not survive logout.
|
||||
This differs from stock tmux behaviour and was accepted deliberately.
|
||||
|
||||
**Comments in `extra.conf` rewritten to the project convention.**
|
||||
The reference `tmux.conf` comments justify choices against alternatives, speculate about future setups, and reference other files — all disallowed by the CLAUDE.md comment convention.
|
||||
Since `extra.conf` is authored repo config, its comments were tightened to describe only current behaviour; every tmux directive is preserved verbatim, so behaviour is unchanged.
|
||||
|
||||
**Version-sensitivity (not a defect today).**
|
||||
`programs.tmux.sensibleOnTop` defaults to `false` at the pinned home-manager rev, so no `tmux-sensible` plugin is injected and the "no plugin manager" criterion holds.
|
||||
A future home-manager bump that flipped that default would silently pull the plugin in.
|
||||
24
.claude/tasks/0007-nvim-module.md
Normal file
24
.claude/tasks/0007-nvim-module.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
spec: laptop-mvi
|
||||
blocked-by: 0001-skeleton-and-building-host
|
||||
---
|
||||
|
||||
## What to build
|
||||
|
||||
An nvim `Module` that gives the primary user Neovim configured declaratively through **nixvim**, with **functional parity** to the operator's existing config.
|
||||
Parity is about the "what" — the same plugins, keymaps, options, colorscheme, and behaviour — not the "how".
|
||||
The mechanism is deliberately free to follow NixOS's declarative paradigm rather than transplanting the imperative lazy.nvim setup: plugins are managed by Nix (no plugin manager, no runtime cloning, no lockfile), and as much of the config as possible is expressed as typed Nix, with raw Lua kept only as an escape hatch.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] An nvim `Module` (following the `Enable convention`) is enabled on `neogaia`.
|
||||
- [x] Neovim is configured via **nixvim**, wired as a flake input (`nixvim.inputs.nixpkgs.follows = "nixpkgs"`), consumed as its home-manager module under `home-manager.users.<user>.programs.nixvim`.
|
||||
- [x] Functional parity with the previous config: the same plugins (neogit, diffview, gitsigns, oil, snacks, gbprod-nord, render-markdown, which-key, treesitter), keymaps, `vim` options, the `nord` colorscheme, the Neogit blame-toggle autocmd, and markdown concealment — verified headless against the generated config.
|
||||
- [x] Runtime dependencies `git`, `ripgrep`, and `fd` are provided by Nix; `gcc` is not needed because Nix builds the treesitter grammars.
|
||||
- [x] The `neogaia` toplevel still builds with the nvim `Module` enabled.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **nixvim, typed Nix first.** `modules/nvim/nvim.nix` enables `programs.nixvim` with `opts`, `globals`, `keymaps`, and typed `plugins.*` settings. Treesitter uses `plugins.treesitter` (`highlight.enable`, `indent.enable`, `grammarPackages` from the module's own `builtGrammars`) covering nix, lua, bash, fish, markdown, rust, python, java, kotlin, c, cpp, html, css, javascript, typescript, go.
|
||||
- **The imperative remainder** — the `gbprod/nord.nvim` setup + colorscheme call, the markdown `conceallevel` autocmd, and the Neogit blame-toggle `BufUnload` autocmd — lives in `modules/nvim/config.lua`, pulled in via `extraConfigLua = builtins.readFile ./config.lua`. `gbprod-nord` comes in through `extraPlugins` because nixvim's `colorschemes.nord` is a different plugin.
|
||||
- **Verification.** `nix build .#…programs.nixvim.build.package` exits 0 and the whole toplevel evaluates. The generated config was exercised headless (launched with `-u` the generated init and a scratch `HOME`, since the wrapper otherwise loads the dev host's real `~/.config/nvim`): all options, keymaps, plugins, the `nord` colorscheme, treesitter highlight + indent, and markdown conceal load with no errors.
|
||||
23
.claude/tasks/0008-claude-code-module.md
Normal file
23
.claude/tasks/0008-claude-code-module.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
spec: laptop-mvi
|
||||
blocked-by: 0001-skeleton-and-building-host
|
||||
---
|
||||
|
||||
## What to build
|
||||
|
||||
Install Claude Code declaratively on `neogaia`, and make it authenticatable without a browser on the laptop so it can be used over the console/SSH via the paste-code flow or an API key.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Claude Code is installed declaratively (following the `Enable convention` if expressed as a `Module`) and enabled on `neogaia`.
|
||||
- [x] The browserless authentication path (paste-code flow or API key) is documented so it works over console/SSH.
|
||||
- [x] The `neogaia` toplevel still builds with Claude Code included.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **Native home-manager module, not a raw package.** Claude Code is enabled through home-manager's own `programs.claude-code` module (`home-manager.users.<user>.programs.claude-code.enable = true`), mirroring how `tmux`/`fish` use their native home-manager options rather than dropping a package into `home.packages`. The module ships within home-manager itself, so — unlike `nvim`/nixvim — no new flake input is needed. Per the invocation's steer to prefer the tmux/nvim conventions over the task wording, the feature `Module` at `modules/claude-code/claude-code.nix` is kept as thin as the `tmux` module: just the `enable` option and the delegation.
|
||||
- **No settings written.** The module manages no `~/.claude` contents and writes no `settings.json`, so login and first-run configuration stay interactive. This keeps auth material (subscription token or API key) out of the repo.
|
||||
- **Auth docs co-located with the module.** The browserless authentication guide lives at `modules/claude-code/authentication.md`, next to the module, following the repo pattern where each module directory holds its own supporting files. It covers both the paste-code OAuth flow (open the printed URL on another device, paste the code back — works unchanged over SSH) and the `ANTHROPIC_API_KEY` path. This is distinct from task 0009's OS-install docs, which cover `disko-install`, not the CLI login.
|
||||
- **Verification.** `nix build .#checks.x86_64-linux.neogaia` (the primary Host seam) builds the toplevel with `claude-code-2.1.209` included; `config.modules.claude-code.enable` and the home-manager `programs.claude-code.enable` both evaluate `true`.
|
||||
- **Note on flake evaluation.** The new module file had to be `git add`ed before the flake could see it — flakes evaluate the git tree, so an untracked Module is invisible to the Auto-loader and the host errors with "option does not exist".
|
||||
- **Personal config ported into the Module (beyond the acceptance criteria).** At the operator's request the declarative half of `~/.claude` now lives in the Module and is applied when it is enabled: the global agent instructions (`context = ./CLAUDE.md`), the skills tree (`skills = ./skills`, 15 skills), the attention-bell hook (`hooks."attention-bell.sh"`), and `settings.json` (`model = "opus"` plus the Stop/Notification/SessionStart hook wiring). Runtime state (`projects/`, `plugins/`, `cache/`, `history.jsonl`, sessions) and the `~/.claude/.credentials.json` secret are deliberately left out, so login survives rebuilds and no secret enters the repo. Stale `agents`/`commands` symlinks (into an outdated `~/wrk/claude`) were skipped. Verified against the built `home-files`: `~/.claude/{CLAUDE.md,settings.json,skills/,hooks/attention-bell.sh}` are all generated, with the hook executable. The `gitea-axi` SessionStart hook depends on that binary being on `PATH`; the flake does not yet provide it, so the hook is a no-op on a host until it is installed.
|
||||
36
.claude/tasks/0009-install-flow-docs.md
Normal file
36
.claude/tasks/0009-install-flow-docs.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
spec: laptop-mvi
|
||||
blocked-by: [0002-neogaia-disk-and-boot, 0003-kernel-and-hardware, 0004-networking-and-base-system, 0005-fish-shell-module, 0006-tmux-module, 0007-nvim-module, 0008-claude-code-module]
|
||||
---
|
||||
|
||||
## What to build
|
||||
|
||||
Document the one-shot install procedure that turns the completed `neogaia` `Host` into a running encrypted laptop from the NixOS live ISO — the capstone, written once every functional slice is in place so it describes the actually-complete `Host`.
|
||||
|
||||
The procedure: push the repo to Gitea first; from the live ISO, join wifi, clone the repo locally (avoiding self-signed-TLS/auth problems with flake fetching during install), and run `disko-install` against the `neogaia` `Host` with the chaotic substituter passed to the install-time Nix daemon (or it compiles the CachyOS kernel from source on the USB stick). Then set the bootstrap password by hand via `nixos-enter` — never committed to the public repo — and reboot.
|
||||
|
||||
Note the bootstrap ordering (the flake must exist on Gitea before the install can consume it) and that moving the password to a `hashedPasswordFile` backed by a sops secret is the first post-boot task, out of scope here (per ADR 0001, an age key does not exist until the first install generates the SSH host key).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] The install procedure is documented end to end: push to Gitea → join wifi on the live ISO → clone locally → `disko-install` against `neogaia` → set bootstrap password via `nixos-enter` → reboot.
|
||||
- [x] The docs state that the install-time Nix daemon must have the chaotic substituter configured, or the kernel compiles from source on the USB stick.
|
||||
- [x] The docs explain that the local clone avoids self-signed-TLS/auth problems with flake fetching during install.
|
||||
- [x] The bootstrap password is set by hand and never committed; the docs flag the sops-backed `hashedPasswordFile` migration as the first post-boot follow-up.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The runbook lives at `docs/install.md`.
|
||||
|
||||
Every documented command was checked against the actual pinned tooling rather than written from memory:
|
||||
|
||||
- The `disko-install` and `disko` flags (`--flake`, `--disk NAME DEVICE`, `--write-efi-boot-entries`, `--option`, `--mode mount`) were read out of the pinned disko revision's wrapped scripts (the disko rev in `flake.lock`).
|
||||
- A consequence surfaced there and shaped the doc: `disko-install` traps `EXIT` and **unmounts** the target, so the "set the bootstrap password" step must first remount with `disko --mode mount` before `nixos-enter`.
|
||||
A naive `nixos-enter --root /mnt` straight after the install would have found nothing mounted.
|
||||
- The chaotic substituter URL and trusted key are quoted verbatim from `system/default.nix`, and `--disk main /dev/nvme0n1` matches `hosts/neogaia/disk.nix`.
|
||||
|
||||
Two secrets are set by hand at install time, not one: the doc distinguishes the **LUKS passphrase** (prompted by disko at format, typed at every boot) from the **bootstrap login password** (set via `nixos-enter passwd`).
|
||||
The task named only the login password; the LUKS passphrase is an unavoidable part of the same by-hand flow, so it is documented alongside for a complete runbook.
|
||||
|
||||
Beyond the task's terse list, the doc adds: a minimal-vs-graphical ISO split for joining wifi, and — from review — an SSH-key caveat for the clone plus an HTTPS-with-`sslVerify=false` fallback (which also reinforces the "git can skip verification where the flake fetcher can't" point behind the local-clone requirement).
|
||||
No criteria were dropped.
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,8 +1 @@
|
||||
/reference/
|
||||
/.direnv/
|
||||
|
||||
# BEGIN mkSkillsShellHook
|
||||
# Generated by mkSkillsShellHook. Nix-delivered skill symlinks, kept out of git.
|
||||
.claude/skills
|
||||
.agents/skills/gitea-axi
|
||||
# END mkSkillsShellHook
|
||||
|
||||
34
.sops.yaml
34
.sops.yaml
@@ -1,34 +0,0 @@
|
||||
# Recipients for the encrypted files under secrets/.
|
||||
keys:
|
||||
# A recipient of every file.
|
||||
# One readable only by machines becomes unrecoverable once they are wiped.
|
||||
# Adding a recipient requires decrypting first.
|
||||
# No private half here, only in the operator's password manager.
|
||||
- &admin age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
|
||||
# Generated on the machine it names.
|
||||
- &neogaia age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe
|
||||
- &pikachu age1wf5s0n0tgt6ld2ysgu9dc67mj8ylwecgl4utzg7hqwy3kut9zyms7aglmh
|
||||
|
||||
creation_rules:
|
||||
# Material belonging to one machine.
|
||||
# No machine other than the one named is a recipient, so a host that is
|
||||
# compromised cannot decrypt another's material.
|
||||
- path_regex: secrets/neogaia\.yaml$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *neogaia
|
||||
|
||||
- path_regex: secrets/pikachu\.yaml$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *pikachu
|
||||
|
||||
# Material common to every machine, so it is stored once rather than per host.
|
||||
- path_regex: secrets/shared\.yaml$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *neogaia
|
||||
- *pikachu
|
||||
110
AGENTS.md
110
AGENTS.md
@@ -1,110 +0,0 @@
|
||||
# dotfiles-nixos
|
||||
|
||||
One flake builds every machine I own.
|
||||
|
||||
## Repository Conventions
|
||||
|
||||
- Use `docs/technical-writing.md` and `docs/dictionary.md` while authoring any file.
|
||||
Apply them to comments, user-facing descriptions, commit prose, pull-request prose, and Markdown documentation.
|
||||
- Comments posted to the forge under the operator's account must end with `— Pi`.
|
||||
The sign-off identifies the agent as the author instead of the operator.
|
||||
- Commit messages follow Conventional Commits, defined in `docs/conventional-commits.md`.
|
||||
Use a scope for the changed module or host, such as `fish`, `nvim`, or `neogaia`.
|
||||
- Keep commit messages about the Git history, not the forge workflow.
|
||||
Do not reference issue or pull-request numbers unless the commit itself requires a stable external reference.
|
||||
- During pull-request development, add follow-up commits instead of amending existing commits.
|
||||
Preserve review history until the pull request is ready to merge.
|
||||
- Use `gitea-axi` for Gitea issues, pull requests, labels, reviews, comments, and milestones.
|
||||
It resolves this repository from the `origin` remote inside the checkout.
|
||||
|
||||
## Dotfiles Conventions
|
||||
|
||||
### Desktop
|
||||
|
||||
- When adding a graphical application, add a `window-rewrite` icon mapping in `modules/desktop/session/waybar.nix`.
|
||||
The workspace indicator uses this mapping to show an application icon instead of the generic default glyph.
|
||||
Match the mapping on the window class.
|
||||
Run `hyprctl clients -j | jq -r '.[].class' | sort -u` to list classes in the running session.
|
||||
- Set Hyprland session environment variables in `modules/desktop/session/hyprland/default.nix` with `env = KEY,VALUE`.
|
||||
`home.sessionVariables` do **not** reach the Hyprland session because UWSM does not source `hm-session-vars.sh`.
|
||||
Source cursor values from `config.stylix.cursor`.
|
||||
Set `XCURSOR_*` and `HYPRCURSOR_*` to the same theme for Bibata.
|
||||
- Configure the Steam desktop client as an XWayland application.
|
||||
SDL Wayland selectors do not make the visible client native Wayland.
|
||||
Keep fractional scaling sharp with Hyprland's `xwayland.force_zero_scaling` and Steam's `STEAM_FORCE_DESKTOPUI_SCALING`.
|
||||
|
||||
### Firefox
|
||||
|
||||
- Manage Firefox search engines through `programs.firefox.search` with `force = true`.
|
||||
Hide built-in engines with `<engine>.metaData.hidden = true` because omission does not remove them.
|
||||
Use current engine ids, such as `default = "ddg"` instead of `"DuckDuckGo"`.
|
||||
Decode the built `search.json.mozlz4` with `mozlz4a -d <search.json.mozlz4>` to check the result.
|
||||
- Avoid Home Manager Firefox extension settings unless the whole profile can use the legacy extension-storage backend.
|
||||
Any non-empty `profiles.<name>.extensions.settings.<id>.settings` sets `extensions.webextensions.ExtensionStorageIDB.enabled = false` for the whole profile.
|
||||
|
||||
### Neovim
|
||||
|
||||
- Nixvim's flake input follows the root nixpkgs source, but its Home Manager module does not reuse the host's `pkgs` instance.
|
||||
Keep `programs.nixvim.nixpkgs.useGlobalPackages = true`.
|
||||
That setting makes Nixvim use the shared package set without warning that its source default changed.
|
||||
|
||||
### AI Tooling
|
||||
|
||||
- Use produced literals for Herdr key names that include shifted punctuation.
|
||||
For example, use `prefix+<` instead of `prefix+shift+comma`.
|
||||
- Treat `~/.pi/agent/skills` as generated output from Home Manager.
|
||||
Do not edit skills in that directory.
|
||||
A new file created there stays outside the repo.
|
||||
Shared global skills come from the `skills` flake through `modules/agents/skills.nix`.
|
||||
Update the `skills` flake input, then rebuild.
|
||||
- Keep generated `.agents/skills/.gitignore` files from ignoring symlinked skills.
|
||||
Pi skill discovery honors `.gitignore`, `.ignore`, and `.fdignore` inside scanned skill directories.
|
||||
An ignored symlinked skill does not load even when `.agents/skills/<name>/SKILL.md` exists.
|
||||
- Write directly to flake-managed Pi extension, prompt, and skill directories only for throwaway development or local experiments.
|
||||
Home Manager activation can overwrite or hide unmanaged files in those directories.
|
||||
Promote finished work into the dotfiles module before treating it as deployed.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Guest modules are host-agnostic wiring, while host files own deployment-specific service configuration such as served volumes and host paths.
|
||||
For example, `guests/copyparty.nix` should expose options and translate them into guest mounts/service settings, while `hosts/pikachu/default.nix` chooses `/inbox`, `/media`, and their backing paths.
|
||||
Guest addresses and routes are host-level configuration because a guest module can be used on multiple hosts.
|
||||
- The registry collector reads only top-level host `config.registry` declarations.
|
||||
Registry declarations inside guest interiors live under `config.containers.<guest>.config.registry`.
|
||||
The collected registry does not include those guest-interior declarations.
|
||||
Publish guest values from host-side code, or extend the collector to recurse into guests.
|
||||
See http://git.alexion.dev/alexion/dotfiles/issues/65.
|
||||
- A NixOS module function argument default such as `registry ? config.registry` does not safely handle an unprovided module argument.
|
||||
The module system still queries `_module.args.registry`.
|
||||
Evaluation fails when that argument is absent.
|
||||
For an optional special-argument fallback, bind module arguments through `@args`.
|
||||
Test `args ? <name>` inside the module body.
|
||||
|
||||
- `chaotic-nyx` must **not** follow this flake's `nixpkgs`.
|
||||
Chaotic builds its packages against its own pinned `nixpkgs`.
|
||||
Its overlay defaults to `onTopOf = "flake-nixpkgs"`, the cache-friendly path.
|
||||
That setup lets `nyx-cache.chaotic.cx` serve the CachyOS kernel instead of building it from source.
|
||||
Chaotic packages do not see this flake's `unstable` or `stable` overlays.
|
||||
|
||||
- Remote deploy helpers that run target-side `sudo` over SSH need a TTY.
|
||||
Use `ssh -t` unless the target command cannot prompt for sudo credentials.
|
||||
|
||||
- This host has no `python` or `python3` command on its ordinary `PATH`.
|
||||
For one-off Python commands, use Nix explicitly, such as `nix shell nixpkgs#python3 -c python3 <script>`.
|
||||
- `nixos-generate-config --show-hardware-config` needs root on this machine, even when it only prints.
|
||||
Without root, it fails with `Failed to retrieve subvolume info for /`.
|
||||
The root filesystem uses btrfs.
|
||||
- Nix flake evaluation ignores untracked files in this checkout.
|
||||
Stage or commit new auto-loaded modules before evaluating the flake.
|
||||
Otherwise, `nix flake check` and `nixos-rebuild --flake` cannot see those modules.
|
||||
They can then report missing options that exist only in the working tree.
|
||||
|
||||
- `sops.secrets.<name>.owner` must name a user known on the decrypting host.
|
||||
Do not set the owner to a guest-only service user unless the host also declares that user.
|
||||
Use a host-visible owner plus a shared group mode when the guest service can read the decrypted secret file through its group.
|
||||
|
||||
- Actual Budget's NixOS service runs `actual-server` with `WorkingDirectory = services.actual.settings.dataDir`.
|
||||
The `actual-server` CLI wrapper sets `ACTUAL_CONFIG_PATH=./config.json` when the working directory contains `config.json`.
|
||||
That environment variable overrides the config path generated by the NixOS module.
|
||||
During migration from old community-script state, move or rewrite a copied `/data/config.json`.
|
||||
Otherwise, the copied file shadows the declarative module settings.
|
||||
45
CLAUDE.md
45
CLAUDE.md
@@ -1,5 +1,42 @@
|
||||
# Claude Code compatibility
|
||||
# dotfiles-nixos
|
||||
|
||||
You MUST read and follow [`AGENTS.md`](AGENTS.md) before doing any work in this repository.
|
||||
`AGENTS.md` is the canonical project instruction file.
|
||||
This file exists only so Claude Code discovers that canonical instruction file.
|
||||
One flake that builds every machine the user owns.
|
||||
The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overlays) lives in `.claude/CONTEXT.md`; the current deliverable's spec is `.claude/spec/laptop-mvi.md`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Write comments only where they earn their place, and keep them concise.
|
||||
Assume the reader can read code: comment the "why", not the "what", and explain "what" only when it is genuinely non-obvious.
|
||||
A comment must be self-contained to its file — accurate to a reader looking at that file alone.
|
||||
Do not write about history ("used to be X", "now moved here") or future state, about how a value is consumed elsewhere, or to justify the choice against alternatives; state the positive reason a thing exists, keeping any real stakes as a present-tense consequence.
|
||||
The only permitted cross-file mention is a bare pointer explaining why something is *absent* here (e.g. "disko derives `fileSystems`; none declared here"), never narrating what the other file or tool does.
|
||||
Do not use the domain model's capitalized terms (Host, Module, Skeleton, Auto-loader, Enable convention) as glossary references; describe things in plain language, using "host"/"module" only as ordinary lowercase nouns.
|
||||
Never reference agent-facing state (anything under `.claude/` or `CLAUDE.md`).
|
||||
A file-top header is one concise purpose line, added only where the filename or path does not already say it — never a feature inventory of the code below.
|
||||
For a placeholder, say so plainly plus any actionable present-tense directive ("Placeholder: regenerate with nixos-generate-config on the target machine"), never "placeholder for <missing feature>".
|
||||
Option `description`/`mkEnableOption` strings are user-facing documentation rather than comments, so they may describe behaviour more fully — but the self-contained rule and the bans on glossary terms and agent-state references still apply.
|
||||
- Comments posted to Gitea (pull requests, issues, reviews) go out under the operator's account, so sign every one to make clear the author is the agent, not the operator.
|
||||
End the comment with a `— Claude` sign-off.
|
||||
(A dedicated bot account may replace this later; until then, the sign-off is the only marker.)
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Nix on the dev host needs experimental features passed per-command.
|
||||
This repo is developed on `neogaia` while it still runs **CachyOS** (the migration target), where Nix is the distro package at `/usr/bin/nix` in multi-user daemon mode.
|
||||
The system `/etc/nix/nix.conf` does not enable flakes, so export `NIX_CONFIG="experimental-features = nix-command flakes"` (or pass `--extra-experimental-features 'nix-command flakes'`) for every command.
|
||||
- The dev user is a non-trusted daemon client (`nix store info` reports `Trusted: 0`).
|
||||
You cannot add substituters from the CLI, so rely on what the flake/config declares (e.g. the chaotic cache is wired by the chaotic module, not a CLI flag).
|
||||
Caveat that bites when a Host actually selects the CachyOS kernel: the substituters a `nix build` fetches from are the **daemon's** (`/etc/nix/nix.conf`), *not* the `nix.settings` of the config being built — those only govern the built system.
|
||||
This dev host's `/etc/nix/nix.conf` has no `substituters`/`trusted-substituters` lines, so building a toplevel whose `boot.kernelPackages` is `linuxPackages_cachyos` compiles the kernel (and rustc bootstrap, etc.) from source instead of hitting `nyx-cache`.
|
||||
To build such a Host here, first add `extra-substituters = https://nyx-cache.chaotic.cx/` and `extra-trusted-public-keys = nyx-cache.chaotic.cx:dJxTrgMC3V3cFfyIiBQDQorG6k1LsqurH/srpMSq7qk=` to `/etc/nix/nix.conf` (sudo) and `sudo systemctl restart nix-daemon`.
|
||||
`nix eval` of the kernel version does *not* trigger this — only a real build does.
|
||||
- If `/nix/store` is missing or `nix-daemon` is inactive after a fresh Nix install, initialise it with `sudo systemd-tmpfiles --create nix-daemon.conf && sudo systemctl enable --now nix-daemon.socket`.
|
||||
- The primary build/verify seam for any Host is `nix flake check`, which builds `checks.x86_64-linux.<host>` (the system toplevel); cheap targeted checks use `nix eval .#nixosConfigurations.<host>.config...`.
|
||||
- chaotic-nyx must **not** follow our `nixpkgs`, and its packages are built against chaotic's own pinned nixpkgs (its overlay defaults to `onTopOf = "flake-nixpkgs"`, the cache-friendly path).
|
||||
That is what lets the `nyx-cache.chaotic.cx` binary cache hit instead of compiling the CachyOS kernel from source; the tradeoff is that chaotic packages do not see our `unstable`/`stable` overlays.
|
||||
- The remote is self-hosted Gitea (`git.alexion.dev`); the forge CLI is `tea` (login `axi`), and `gh` is not installed.
|
||||
- nixpkgs `vimPlugins.nord-nvim` is `shaunsingh/nord.nvim` (no `require("nord").setup()`); the config wants `gbprod/nord.nvim`, which is packaged as `vimPlugins.gbprod-nord`.
|
||||
- nixpkgs `vimPlugins.nvim-treesitter` tracks the rewritten `main` branch: there is no `require("nvim-treesitter.configs").setup{ensure_installed,highlight,indent}`. Under nixvim, use `plugins.treesitter` with `highlight.enable`/`indent.enable` and `grammarPackages = with config.programs.nixvim.plugins.treesitter.package.builtGrammars; [ ... ]` — the module's own `package.builtGrammars`, **not** `pkgs.vimPlugins.nvim-treesitter.*` (whose query files can mismatch). The module targets the main branch and enables features via neovim-native APIs (`vim.treesitter.start()`, `require'nvim-treesitter'.indentexpr()`).
|
||||
- Neovim is configured via **nixvim** (flake input `nixvim`, consumed as `inputs.nixvim.homeModules.nixvim` added to `home-manager.sharedModules`, config under `home-manager.users.<user>.programs.nixvim`). `nixvim.inputs.nixpkgs.follows = "nixpkgs"` is set; nixvim then emits a benign eval warning that its pinned nixpkgs differs from the followed one — builds and runs fine, do not "fix" it by dropping the follows.
|
||||
- To reference the nixvim-built package's own attrs (e.g. treesitter `builtGrammars`) inside our NixOS module, give `home-manager.users.<user>` the module-function form (`hm: { programs.nixvim = { ... hm.config.programs.nixvim... }; }`), since the outer `config` is the NixOS config, not the home-manager one.
|
||||
- **Verifying a nixvim change headless:** `programs.nixvim.build.package`'s wrapper has **no `-u`**, so running `$OUT/bin/nvim` loads the caller's `~/.config/nvim` (the dev host's real config), *not* the built config — silently. To exercise the built config, launch with `-u "$(nix build --no-link --print-out-paths .#…programs.nixvim.build.initFile)"` and a scratch `HOME`/`XDG_CONFIG_HOME`. `conceallevel` is window-local: set it with `opt_local`/`vim.wo`, never `vim.bo[buf]` (which errors).
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# Conventional Commits
|
||||
|
||||
> Sourced from [conventionalcommits.org/en/v1.0.0](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
> by the Conventional Commits authors, licensed under
|
||||
> [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/).
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The Conventional Commits specification is a lightweight convention on top of commit messages.
|
||||
It provides an easy set of rules for creating an explicit commit history; which makes it easier to write automated tools on top of.
|
||||
This convention dovetails with [SemVer](http://semver.org), by describing the features, fixes, and breaking changes made in commit messages.
|
||||
|
||||
The commit message should be structured as follows:
|
||||
|
||||
```
|
||||
<type>[optional scope]: <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer(s)]
|
||||
```
|
||||
|
||||
The commit contains the following structural elements, to communicate intent to the consumers of your library:
|
||||
|
||||
1. **fix:** a commit of the _type_ `fix` patches a bug in your codebase (this correlates with `PATCH` in Semantic Versioning).
|
||||
2. **feat:** a commit of the _type_ `feat` introduces a new feature to the codebase (this correlates with `MINOR` in Semantic Versioning).
|
||||
3. **BREAKING CHANGE:** a commit that has a footer with a token `BREAKING CHANGE:`, or appends a `!` after the type/scope, introduces a breaking API change (correlating with `MAJOR` in Semantic Versioning). A BREAKING CHANGE can be part of commits of any _type_.
|
||||
4. _types_ other than `fix:` and `feat:` are allowed, for example `build:`, `chore:`, `ci:`, `docs:`, `style:`, `refactor:`, `perf:`, `test:`, and others.
|
||||
5. _footers_ other than `BREAKING CHANGE: <description>` may be provided and follow a convention similar to [git trailer format](https://git-scm.com/docs/git-interpret-trailers).
|
||||
|
||||
Additional types are not mandated by the Conventional Commits specification, and have no implicit effect in Semantic Versioning (unless they include a BREAKING CHANGE).
|
||||
A scope may be provided to a commit's type, to provide additional contextual information and is contained within parenthesis, e.g., `feat(parser): add ability to parse arrays`.
|
||||
|
||||
---
|
||||
|
||||
## Specification
|
||||
|
||||
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.ietf.org/rfc/rfc2119.txt).
|
||||
|
||||
1. Commits MUST be prefixed with a type, which consists of a noun, `feat`, `fix`, etc., followed by the OPTIONAL scope, OPTIONAL `!`, and REQUIRED terminal colon and space.
|
||||
2. The type `feat` MUST be used when a commit adds a new feature to your application or library.
|
||||
3. The type `fix` MUST be used when a commit represents a bug fix for your application.
|
||||
4. A scope MAY be provided after a type. A scope MUST consist of a noun describing a section of the codebase surrounded by parenthesis, e.g., `fix(parser):`.
|
||||
5. A description MUST immediately follow the colon and space after the type/scope prefix. The description is a short summary of the code changes, e.g., _fix: array parsing issue when multiple spaces were contained in string_.
|
||||
6. A longer commit body MAY be provided after the short description, providing additional contextual information about the code changes. The body MUST begin one blank line after the description.
|
||||
7. A commit body is free-form and MAY consist of any number of newline separated paragraphs.
|
||||
8. One or more footers MAY be provided one blank line after the body. Each footer MUST consist of a word token, followed by either a `:<space>` or `<space>#` separator, followed by a string value (this is inspired by the [git trailer convention](https://git-scm.com/docs/git-interpret-trailers)).
|
||||
9. A footer's token MUST use `-` in place of whitespace characters, e.g., `Acked-by` (this helps differentiate the footer section from a multi-paragraph body). An exception is made for `BREAKING CHANGE`, which MAY also be used as a token.
|
||||
10. A footer's value MAY contain spaces and newlines, and parsing MUST terminate when the next valid footer token/separator pair is observed.
|
||||
11. Breaking changes MUST be indicated in the type/scope prefix of a commit, or as an entry in the footer section.
|
||||
12. If included as a footer, a breaking change MUST consist of the uppercase text `BREAKING CHANGE`, followed by a colon, space, and description, e.g., _BREAKING CHANGE: environment variables now take precedence over config files_.
|
||||
13. If included in the type/scope prefix, breaking changes MUST be indicated by a `!` immediately before the `:`. If `!` is used, `BREAKING CHANGE:` MAY be omitted from the footer section, and the commit description SHALL be used to describe the breaking change.
|
||||
14. Types other than `feat` and `fix` MAY be used in your commit messages, e.g., _docs: correct spelling of CHANGELOG_.
|
||||
15. The units of information that make up Conventional Commits MUST NOT be treated as case sensitive by implementors, with the exception of BREAKING CHANGE which MUST be uppercase.
|
||||
16. BREAKING-CHANGE MUST be synonymous with BREAKING CHANGE, when used as a token in a footer.
|
||||
@@ -1,59 +0,0 @@
|
||||
# Technical Writing and Domain Dictionary
|
||||
|
||||
This dictionary defines project-specific controlled vocabulary, prohibited terms, and domain allowlists.
|
||||
It applies to comments, user-facing descriptions, commit prose, pull-request prose, and Markdown documentation in this repository.
|
||||
|
||||
ASD-STE100 Simplified Technical English partly informs these rules.
|
||||
This repository uses those ideas as a local style discipline.
|
||||
It does not claim formal ASD-STE100 compliance.
|
||||
|
||||
## Strictly Banned Fluff and Latin Terms
|
||||
|
||||
Do not use these Latin abbreviations, conversational filler words, and subjective qualifiers.
|
||||
Remove them or use the approved replacements.
|
||||
|
||||
| Disapproved Term | Approved Replacement | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| `i.e.` | `that is` | Latin abbreviation. Unclear to non-native speakers. |
|
||||
| `e.g.` | `for example` | Latin abbreviation. |
|
||||
| `etc.` | `and so forth`, `and others` | Latin abbreviation. Vague and non-exhaustive. |
|
||||
| `vs.` / `versus` | `compared to`, `against` | Latin abbreviation. |
|
||||
| `via` | `by using`, `through`, `with` | Latin term. Prefer direct prepositions. |
|
||||
| `ad hoc` | `one-off`, `for this case` | Latin term. Prefer direct wording. |
|
||||
| `simply` | omit | Subjective filler. Code and concepts are not inherently simple. |
|
||||
| `just` | omit | Minimizing filler word. |
|
||||
| `obviously` | omit | Subjective qualifier. |
|
||||
| `clearly` | omit | Subjective qualifier. |
|
||||
| `easily` | omit | Subjective qualifier. |
|
||||
| `properly` | `correctly`, or specify exact behavior | Vague and subjective. State the exact condition. |
|
||||
| `as necessary` | specify exact condition | Ambiguous. Define the condition explicitly. |
|
||||
| `very` / `quite` | omit | Empty intensifiers. |
|
||||
| `basically` | omit | Conversational filler. |
|
||||
| `actually` | omit | Conversational filler. |
|
||||
| `really` | omit | Empty intensifier. |
|
||||
| `furthermore` / `moreover` | `also`, `and` | Overly formal transitional filler. |
|
||||
|
||||
## Technical and Domain Allowed Language
|
||||
|
||||
Use only approved project-specific terms as controlled vocabulary in documentation and comments.
|
||||
Add a term here when repeated project prose needs it.
|
||||
Use the simplest accurate word when a term is not listed.
|
||||
|
||||
| Approved Domain Term | Category | Description and Permitted Context |
|
||||
| :--- | :--- | :--- |
|
||||
| agent | Agent tooling | An AI coding assistant acting in the operator's environment. |
|
||||
| operator | Agent tooling | The human driving the development process. Use when distinguishing the human from the agent acting on their behalf. |
|
||||
| guest | dotfiles | A systemd-nspawn container managed by this flake. Use for configured containers under `guests/`. |
|
||||
| host | dotfiles | A machine with a NixOS configuration built by this flake. |
|
||||
| module | dotfiles | In this repository, a self-contained configuration under `modules/`. Use for one program, service, or subsystem. |
|
||||
| registry | dotfiles | A typed configuration namespace whose entries can be collected across hosts and reused by other configuration. |
|
||||
| Conventional Commits | Git | A commit-message format defined in `docs/conventional-commits.md`. |
|
||||
| forge | Git | A web service that hosts Git repositories, pull requests, issues, and code review comments. Use when the exact provider does not matter. |
|
||||
| flake | Nix | A Nix project with pinned inputs and declared outputs. Use for the repository-level build unit defined by `flake.nix`. |
|
||||
| assembly file | Nix | A Nix file under `flake/` that constructs flake outputs or arguments passed to NixOS modules. |
|
||||
| assembly function | Nix | A project function in `flake/` that creates modules, systems, or output values. |
|
||||
| library file | Nix | A Nix file that defines reusable project functions instead of a NixOS module or flake output. |
|
||||
| library function | Nix | A pure project function exposed under `util`. |
|
||||
| NixOS module | Nix | A Nix file evaluated by `nixosSystem` for a NixOS configuration. |
|
||||
| overlay | Nix | A function that modifies a Nix package set by adding or replacing packages. |
|
||||
| workspace | Window management | A named or numbered group of windows in a graphical session. Some desktop environments call this a virtual desktop. |
|
||||
325
docs/install.md
325
docs/install.md
@@ -1,52 +1,24 @@
|
||||
# Installing and provisioning a host
|
||||
# Installing `neogaia`
|
||||
|
||||
This document covers the procedures that put a machine into the fleet and keep its secrets readable.
|
||||
This is the one-shot procedure that turns the `neogaia` `Host` in this flake into a running, encrypted Dell XPS 13 laptop, installed from the NixOS live ISO.
|
||||
|
||||
- [Installing a host from the live ISO](#installing-a-host-from-the-live-iso), the destructive one-shot that turns a host in this flake into a running, encrypted machine.
|
||||
- [Provisioning an already-running host](#provisioning-an-already-running-host), done live on the machine with no reimage.
|
||||
- [Editing secrets](#editing-secrets), the day-to-day workflow.
|
||||
- [Recovering a wrongly-provisioned machine](#recovering-a-wrongly-provisioned-machine) from the live ISO.
|
||||
It is destructive: it formats `/dev/nvme0n1` in full.
|
||||
Read it end to end before starting, because the laptop is the only machine and the reimage is irreversible.
|
||||
|
||||
The install is destructive: it formats the target disk in full.
|
||||
Read it end to end before starting, because on a single-machine fleet the reimage is irreversible.
|
||||
The whole install is a single `disko-install` against the `neogaia` `Host`, followed by setting a bootstrap login password by hand.
|
||||
Everything the installed system needs — the LUKS layout, the CachyOS kernel, the wifi firmware, the user, and the terminal tooling — is already declared in the flake, so this document is only the mechanics of getting that flake onto the disk.
|
||||
|
||||
## What arrives by hand
|
||||
## Bootstrap ordering
|
||||
|
||||
Exactly one secret is entered by hand: the **LUKS passphrase** that encrypts the disk, typed when the disk is formatted and again at every boot.
|
||||
The install consumes the flake from Gitea, so **the flake must already be on Gitea before you start** — the repo cannot pull a config that hasn't been pushed.
|
||||
The bootstrap login password is likewise set by hand at the end and is **never committed**, which is what keeps the public repo free of any secret while still yielding a working login on first boot.
|
||||
|
||||
Everything else arrives declared.
|
||||
The login password is a `sops`-encrypted secret consumed through `hashedPasswordFile`, and the SSH host keys are restored from secrets rather than generated.
|
||||
No password is set interactively at any point, and `users.mutableUsers = false` means one set by hand would be ignored anyway.
|
||||
Two secrets are set by hand during this install, both entered interactively and neither stored in the repo:
|
||||
|
||||
## Identity before first boot
|
||||
1. The **LUKS passphrase** that encrypts the disk, entered when `disko-install` formats it and again at every boot.
|
||||
2. The **bootstrap login password** for the `alexion` user, set through `nixos-enter` just before the reboot.
|
||||
|
||||
A machine reads its secrets with an **age identity** at `/var/lib/sops-nix/key.txt` on its encrypted root.
|
||||
Its public half must be registered as a recipient of every secrets file the machine needs, and the re-keyed files must be in the flake's git tree when the system is built, because the ciphertext is baked into the store.
|
||||
|
||||
**A host's identity is therefore generated and registered before its first boot, not after it.**
|
||||
The login password arrives only from a decrypted secret and there is no fallback credential — no interactive password, no unlocked root account, no authorized SSH key.
|
||||
A first boot without a registered identity cannot decrypt the password hash, so the account it would log in as has no usable password and the machine has no way in short of the [recovery procedure](#recovering-a-wrongly-provisioned-machine).
|
||||
|
||||
Identities come in two tiers.
|
||||
The **admin identity** lives in Proton Pass, is a recipient of every file, and is the credential that authorizes registering a new host.
|
||||
Each **host identity** is generated on its own machine, never transmitted, and reads only that machine's file plus the shared one.
|
||||
A host identity is deliberately not derived from the machine's SSH host key, which is what frees those host keys to be secrets in their own right.
|
||||
|
||||
## Tooling
|
||||
|
||||
Neither `sops` nor `age` is installed by this flake.
|
||||
Run them from nixpkgs as needed:
|
||||
|
||||
```console
|
||||
$ nix run nixpkgs#sops -- <args>
|
||||
$ nix shell nixpkgs#age -c age-keygen <args>
|
||||
```
|
||||
|
||||
On the live ISO these need `--extra-experimental-features 'nix-command flakes'`, since the ISO's daemon has neither enabled.
|
||||
|
||||
## Installing a host from the live ISO
|
||||
|
||||
### 0. Push the repo to Gitea
|
||||
## 0. Push the repo to Gitea
|
||||
|
||||
From your working checkout, make sure `main` is committed and pushed to the Gitea remote:
|
||||
|
||||
@@ -54,12 +26,11 @@ From your working checkout, make sure `main` is committed and pushed to the Gite
|
||||
$ git push origin main
|
||||
```
|
||||
|
||||
The clone in step 2 sees only what has been pushed, so anything left behind in your working checkout will not reach the machine.
|
||||
Changes made inside that clone afterwards are a separate matter — step 4 makes one there deliberately.
|
||||
The install reads only committed, git-tracked content, so anything uncommitted will not make it onto the laptop.
|
||||
|
||||
### 1. Boot the live ISO and join wifi
|
||||
## 1. Boot the live ISO and join wifi
|
||||
|
||||
Boot from a NixOS live ISO (the minimal installer is enough).
|
||||
Boot the machine from a NixOS live ISO (the minimal installer is enough).
|
||||
The installer logs in as the `nixos` user, who has passwordless `sudo`.
|
||||
|
||||
On the minimal ISO, bring up wifi with `wpa_supplicant`:
|
||||
@@ -83,109 +54,29 @@ $ nmcli device wifi connect "YOUR_SSID" password "YOUR_WIFI_PASSWORD"
|
||||
|
||||
Confirm you have connectivity (`ping -c1 github.com`) before continuing.
|
||||
|
||||
### 2. Clone the repo locally
|
||||
## 2. Clone the repo locally
|
||||
|
||||
Clone this repo onto the live ISO and work from that local checkout:
|
||||
|
||||
```console
|
||||
$ git clone ssh://gitea@git.alexion.dev:2022/alexion/dotfiles.git
|
||||
$ cd dotfiles
|
||||
$ git clone ssh://gitea@git.alexion.dev:2022/alexion/dotfiles-nixos.git
|
||||
$ cd dotfiles-nixos
|
||||
```
|
||||
|
||||
Cloning over SSH needs your Gitea SSH key present in the live session, since the ISO starts with none.
|
||||
If getting the key onto the ISO is inconvenient, clone over HTTPS instead and tell git to skip the self-signed certificate:
|
||||
|
||||
```console
|
||||
$ git -c http.sslVerify=false clone https://git.alexion.dev/alexion/dotfiles.git
|
||||
$ cd dotfiles
|
||||
$ git -c http.sslVerify=false clone https://git.alexion.dev/alexion/dotfiles-nixos.git
|
||||
$ cd dotfiles-nixos
|
||||
```
|
||||
|
||||
Do **not** point `disko-install` straight at the Gitea flake URL.
|
||||
Gitea serves HTTPS with a self-signed certificate and expects authentication, and Nix's flake fetcher has no easy way to skip certificate verification or supply those credentials mid-install.
|
||||
A plain `git clone` sidesteps that entirely — over SSH there is no TLS, and over HTTPS git takes the `sslVerify=false` above that the flake fetcher won't — and then `disko-install` consumes the flake from a local path, where no fetch of our repo happens during the build.
|
||||
(Every other flake input is public and still fetched from GitHub over ordinary, valid TLS.
|
||||
Only our own repo is the problem the local clone solves.)
|
||||
(The public flake inputs — `nixpkgs`, `chaotic`, `disko` — are still fetched from GitHub over ordinary, valid TLS; only our own repo is the problem the local clone solves.)
|
||||
|
||||
### 3. Generate the host identity
|
||||
|
||||
Generate the identity in the live session and keep it there until step 6 writes it onto the installed root:
|
||||
|
||||
```console
|
||||
$ nix shell nixpkgs#age -c age-keygen -o /tmp/key.txt
|
||||
Public key: age1...
|
||||
```
|
||||
|
||||
`age-keygen` prints the public recipient on generation.
|
||||
Recover it later from the identity itself if the line scrolls away:
|
||||
|
||||
```console
|
||||
$ nix shell nixpkgs#age -c age-keygen -y /tmp/key.txt
|
||||
```
|
||||
|
||||
The private half never leaves this session except onto the target disk.
|
||||
Do not copy it into the repo, and do not carry it to another machine.
|
||||
|
||||
### 4. Register the recipient and re-key
|
||||
|
||||
Add the public recipient to `.sops.yaml` as a named anchor, then list it under every file the host must read:
|
||||
|
||||
```yaml
|
||||
keys:
|
||||
- &admin age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
|
||||
- &neogaia age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe
|
||||
- &newhost age1...
|
||||
|
||||
creation_rules:
|
||||
- path_regex: secrets/shared\.yaml$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *neogaia
|
||||
- *newhost
|
||||
```
|
||||
|
||||
If the host gets a secrets file of its own, give it a rule too.
|
||||
`sops` matches a file against these rules to decide who to encrypt it to, and refuses a file no rule matches with `no matching creation rules found`:
|
||||
|
||||
```yaml
|
||||
- path_regex: secrets/newhost\.yaml$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin
|
||||
- *newhost
|
||||
```
|
||||
|
||||
Then re-key each file you changed, which rewrites its data key for the new recipient list without touching any value:
|
||||
|
||||
```console
|
||||
$ SOPS_AGE_KEY_FILE=/path/to/admin-identity \
|
||||
nix run nixpkgs#sops -- updatekeys secrets/shared.yaml
|
||||
```
|
||||
|
||||
Re-keying requires an identity that can already decrypt the file.
|
||||
The live ISO holds no host identity of its own, so paste the admin identity out of Proton Pass into a file in the live session for this step.
|
||||
|
||||
Then populate that file, which needs no existing identity because encrypting only reads recipients:
|
||||
|
||||
```console
|
||||
$ nix run nixpkgs#sops -- secrets/newhost.yaml
|
||||
```
|
||||
|
||||
A host with `modules.network.ssh.enable` expects one entry per key type, named `ssh-host-<type>-key`, each holding a private key generated with `ssh-keygen -t <type> -N "" -f /tmp/<type>`.
|
||||
The build fails at evaluation if a declared secret is absent from the file, so a host that enables the daemon without these will not install.
|
||||
Commit the matching public halves beside the host's configuration in plaintext, since publishing them is their purpose.
|
||||
|
||||
Stage everything you changed.
|
||||
A flake sees only git-tracked files, so an unstaged `secrets/newhost.yaml` is invisible to evaluation even though it exists on disk:
|
||||
|
||||
```console
|
||||
$ git add .sops.yaml secrets/
|
||||
```
|
||||
|
||||
Staging is enough for the build.
|
||||
The commit comes in step 8, and no push is needed here because the install builds from this local clone.
|
||||
|
||||
### 5. Run `disko-install`
|
||||
## 3. Run `disko-install`
|
||||
|
||||
Run the install as root from inside the clone:
|
||||
|
||||
@@ -202,27 +93,27 @@ $ sudo nix --extra-experimental-features 'nix-command flakes' run \
|
||||
What each part does:
|
||||
|
||||
- `--flake .#neogaia` installs the `neogaia` `Host` from the local clone.
|
||||
- `--disk main /dev/nvme0n1` maps disko's `main` disk to the NVMe device.
|
||||
It matches the device declared in `hosts/neogaia/disk.nix` and is stated explicitly so there is no doubt about the target.
|
||||
- `--write-efi-boot-entries` writes the systemd-boot entry into this machine's NVRAM, because the disk stays in the machine it was installed from.
|
||||
- `--disk main /dev/nvme0n1` maps disko's `main` disk to the NVMe device; it matches the device declared in `hosts/neogaia/disk.nix` and is stated explicitly so there is no doubt about the target.
|
||||
- `--write-efi-boot-entries` writes the systemd-boot entry into this machine's NVRAM, because the disk stays in the laptop it was installed from.
|
||||
- The two `--option` lines are the important part: they hand the **chaotic binary cache** to the install-time Nix daemon on the live ISO.
|
||||
|
||||
The chaotic substituter must be passed here explicitly.
|
||||
The `nix.settings` in the flake configure the substituters of the *installed* system, not the live ISO's daemon that runs this build.
|
||||
The ISO's daemon has no `substituters` beyond `cache.nixos.org`.
|
||||
The `nix.settings` in the flake configure the substituters of the *installed* system, not the live ISO's daemon that runs this build; the ISO's daemon has no `substituters` beyond `cache.nixos.org`.
|
||||
Without these two `--option` flags, the build cannot fetch the prebuilt CachyOS kernel and **compiles `linuxPackages_cachyos` (and its toolchain) from source on the USB stick** — a very long detour that the cache avoids.
|
||||
Because the install runs as root, and root is a trusted Nix user, the daemon honours these client-supplied substituter settings.
|
||||
|
||||
Partway through, disko formats the LUKS container and **prompts for a disk-encryption passphrase**.
|
||||
This is the passphrase you will type at every boot to unlock the disk.
|
||||
Choose it deliberately.
|
||||
This is the passphrase you will type at every boot to unlock the disk; choose it deliberately.
|
||||
|
||||
When it finishes it prints `disko-install succeeded`.
|
||||
`disko-install` unmounts the target filesystem on exit, so nothing is mounted at this point — step 6 remounts it.
|
||||
`disko-install` unmounts the target filesystem on exit, so nothing is mounted at this point — step 4 remounts it.
|
||||
|
||||
### 6. Write the identity onto the installed root
|
||||
## 4. Set the bootstrap login password
|
||||
|
||||
Remount the just-installed system with disko, which reopens the LUKS container (prompting for the passphrase from step 5) and mounts the subvolumes under `/mnt`:
|
||||
The installed system was written with no login password (`nixos-install --no-root-password`, and the flake sets none for `alexion`), so it cannot yet be logged into.
|
||||
Set a bootstrap password by hand before rebooting.
|
||||
|
||||
First remount the just-installed system with disko, which reopens the LUKS container (prompting for the passphrase from step 3) and mounts the subvolumes under `/mnt`:
|
||||
|
||||
```console
|
||||
$ sudo nix --extra-experimental-features 'nix-command flakes' run \
|
||||
@@ -230,22 +121,17 @@ $ sudo nix --extra-experimental-features 'nix-command flakes' run \
|
||||
--mode mount --flake .#neogaia
|
||||
```
|
||||
|
||||
Then place the identity generated in step 3, owned by root and readable by nobody else:
|
||||
Then enter the installed system and set the password for your user:
|
||||
|
||||
```console
|
||||
$ sudo install -d -m 0755 /mnt/var/lib/sops-nix
|
||||
$ sudo install -m 0400 -o root -g root /tmp/key.txt /mnt/var/lib/sops-nix/key.txt
|
||||
$ sudo nixos-enter --root /mnt
|
||||
[nixos-enter]# passwd alexion
|
||||
[nixos-enter]# exit
|
||||
```
|
||||
|
||||
It goes on the root subvolume rather than anywhere mounted later because the password secret is decrypted before user accounts are created, which is earlier than any other mount.
|
||||
This password lives only on the laptop's disk; it is **never committed** anywhere.
|
||||
|
||||
Confirm the identity matches the recipient you registered before rebooting, since this is the last cheap moment to catch a mismatch:
|
||||
|
||||
```console
|
||||
$ sudo nix shell nixpkgs#age -c age-keygen -y /mnt/var/lib/sops-nix/key.txt
|
||||
```
|
||||
|
||||
### 7. Reboot
|
||||
## 5. Reboot
|
||||
|
||||
Unmount and reboot into the installed system:
|
||||
|
||||
@@ -255,132 +141,13 @@ $ sudo reboot
|
||||
```
|
||||
|
||||
Remove the USB stick.
|
||||
At boot you are prompted for the LUKS passphrase from step 5.
|
||||
After unlocking, log in at the console as `alexion` with the password from the shared secrets file, and you have a working system with fish, tmux, nvim, and Claude Code.
|
||||
At boot you are prompted for the LUKS passphrase from step 3; after unlocking, log in at the console as `alexion` with the bootstrap password from step 4 and you have a working system with fish, tmux, nvim, and Claude Code.
|
||||
|
||||
If the login is rejected, the identity and the registered recipient disagree — see [recovery](#recovering-a-wrongly-provisioned-machine).
|
||||
## First post-boot task
|
||||
|
||||
### 8. Commit the recipient change
|
||||
Setting the login password by hand is a bootstrap shortcut, not the end state.
|
||||
The first thing to do on the running laptop is to move that password to a `hashedPasswordFile` backed by a `sops-nix` secret, so it is declared and reproducible like everything else.
|
||||
|
||||
The re-key from step 4 exists only in the live session's clone, which is gone.
|
||||
From a machine that is already a recipient of the affected files, repeat the `.sops.yaml` edit and re-key, then commit and push:
|
||||
|
||||
```console
|
||||
$ sudo SOPS_AGE_KEY_FILE=/var/lib/sops-nix/key.txt \
|
||||
nix run nixpkgs#sops -- updatekeys secrets/shared.yaml
|
||||
$ git add .sops.yaml secrets/
|
||||
$ git commit -m "feat(secrets): register newhost as a recipient"
|
||||
$ git push origin main
|
||||
```
|
||||
|
||||
Until this lands, the repo's copy of each file has one recipient fewer than the copy the new machine was built from, and the next rebuild from the repo would lock it out.
|
||||
|
||||
## Provisioning an already-running host
|
||||
|
||||
A machine that is up and running gets its identity live.
|
||||
There is no reimage and no live ISO, because the running generation is the fallback: if activation fails, the rebuild fails and the machine keeps working as it is.
|
||||
|
||||
Generate the identity on the machine itself, straight into place:
|
||||
|
||||
```console
|
||||
$ sudo install -d -m 0755 /var/lib/sops-nix
|
||||
$ sudo nix shell nixpkgs#age -c age-keygen -o /var/lib/sops-nix/key.txt
|
||||
$ sudo chmod 0400 /var/lib/sops-nix/key.txt
|
||||
```
|
||||
|
||||
Register the printed public recipient in `.sops.yaml` and re-key each file the host must read, exactly as in [step 4](#4-register-the-recipient-and-re-key), using the admin identity.
|
||||
|
||||
Then rebuild:
|
||||
|
||||
```console
|
||||
$ sudo nixos-rebuild switch --flake .#neogaia
|
||||
```
|
||||
|
||||
Activation decrypts the secrets with the new identity.
|
||||
Confirm they materialized before trusting the change:
|
||||
|
||||
```console
|
||||
$ sudo ls -l /run/secrets/ /run/secrets-for-users/
|
||||
```
|
||||
|
||||
Both directories matter.
|
||||
Ordinary secrets land in `/run/secrets/`, but a secret marked as needed for user creation is decrypted in an earlier stage and lands in `/run/secrets-for-users/` — which is where the login password hash goes, so it is the one to check before rebooting.
|
||||
|
||||
Commit and push the recipient change once the rebuild succeeds.
|
||||
|
||||
## Editing secrets
|
||||
|
||||
Opening a file decrypts it into an editor and re-encrypts on save:
|
||||
|
||||
```console
|
||||
$ sudo SOPS_AGE_KEY_FILE=/var/lib/sops-nix/key.txt \
|
||||
nix run nixpkgs#sops -- secrets/shared.yaml
|
||||
```
|
||||
|
||||
`sudo` is needed because the identity is mode `0400` and owned by root.
|
||||
|
||||
**What the workstation can do alone** is anything to a file it is already a recipient of.
|
||||
For `neogaia` that is `secrets/shared.yaml` and `secrets/neogaia.yaml`: changing a value, adding a key, and even adding another recipient all work from the host identity, because each only requires decrypting a file the machine can already decrypt.
|
||||
|
||||
**What needs the admin identity** is any file the workstation is not a recipient of — another machine's `secrets/<host>.yaml`.
|
||||
Unlock the admin identity out of Proton Pass for that session and point `SOPS_AGE_KEY_FILE` at it:
|
||||
|
||||
```console
|
||||
$ SOPS_AGE_KEY_FILE=/path/to/admin-identity \
|
||||
nix run nixpkgs#sops -- secrets/zeus.yaml
|
||||
```
|
||||
|
||||
That friction is the point.
|
||||
A workstation that could decrypt every machine's material would make the admin identity ceremonial, and a compromised laptop would carry the whole fleet with it.
|
||||
The admin identity stays a break-glass credential rather than something sitting unlocked on a machine.
|
||||
|
||||
Two changes need more than a save.
|
||||
Rotating the login password means generating a fresh hash with `mkpasswd`, since `users.mutableUsers = false` makes `passwd` inert, and rebuilding.
|
||||
Re-keying the SSH host keys restarts `sshd`, which is declared and automatic.
|
||||
|
||||
## Recovering a wrongly-provisioned machine
|
||||
|
||||
A machine whose identity and registered recipient disagree boots but cannot be logged into: the password hash never decrypts, and there is no fallback credential.
|
||||
Recovery is from the live ISO.
|
||||
|
||||
Boot the ISO, join wifi, and clone the repo as in steps 1 and 2.
|
||||
Then reopen and mount the encrypted root:
|
||||
|
||||
```console
|
||||
$ sudo nix --extra-experimental-features 'nix-command flakes' run \
|
||||
github:nix-community/disko/latest#disko -- \
|
||||
--mode mount --flake .#neogaia
|
||||
```
|
||||
|
||||
Read the identity actually on the disk, and compare it against the recipient the repo registered:
|
||||
|
||||
```console
|
||||
$ sudo nix shell nixpkgs#age -c age-keygen -y /mnt/var/lib/sops-nix/key.txt
|
||||
```
|
||||
|
||||
**If the repo's recipient is right and the disk's identity is wrong**, replace the identity with the one that matches and reboot.
|
||||
Nothing was built against the wrong key, so no rebuild is needed:
|
||||
|
||||
```console
|
||||
$ sudo install -m 0400 -o root -g root /path/to/correct-key.txt /mnt/var/lib/sops-nix/key.txt
|
||||
$ sudo umount -R /mnt && sudo reboot
|
||||
```
|
||||
|
||||
**If the disk's identity is right and the repo's recipient is wrong**, re-key against the identity on the disk, using the admin identity to decrypt, then rebuild the target from the ISO:
|
||||
|
||||
```console
|
||||
$ SOPS_AGE_KEY_FILE=/path/to/admin-identity \
|
||||
nix run nixpkgs#sops -- updatekeys secrets/shared.yaml
|
||||
$ git add .sops.yaml secrets/
|
||||
$ sudo NIX_CONFIG="experimental-features = nix-command flakes" \
|
||||
nixos-install --root /mnt --flake .#neogaia --no-root-password \
|
||||
--option extra-substituters https://nyx-cache.chaotic.cx/ \
|
||||
--option extra-trusted-public-keys nyx-cache.chaotic.cx:dJxTrgMC3V3cFfyIiBQDQorG6k1LsqurH/srpMSq7qk=
|
||||
$ sudo umount -R /mnt && sudo reboot
|
||||
```
|
||||
|
||||
The rebuild is required here and not in the first case, because the secrets file is baked into the system closure at build time.
|
||||
`nixos-install` reuses the already-formatted disk rather than touching the partition table, so the LUKS container and its passphrase are untouched, and it is idempotent if it fails partway.
|
||||
The substituter flags matter for the same reason they do during the install: without them the CachyOS kernel is compiled from source on the USB stick.
|
||||
|
||||
If neither identity is recoverable, generate a new one as in [step 3](#3-generate-the-host-identity), register it, re-key, and rebuild — the machine's own secrets are lost, but everything encrypted to the admin identity survives.
|
||||
This is deliberately out of scope for the install itself.
|
||||
Per ADR 0001, each `Host`'s secrets are encrypted to an age key derived from that `Host`'s SSH host key — and that host key does not exist until this first install generates it.
|
||||
So the sops wiring can only happen *after* the machine is up, which is exactly why it is the first follow-up rather than part of this procedure.
|
||||
|
||||
270
docs/nix.md
270
docs/nix.md
@@ -1,270 +0,0 @@
|
||||
# Nix Conventions
|
||||
|
||||
This document records local Nix style and design conventions.
|
||||
Use these conventions when changing this flake.
|
||||
Read this document with `docs/dictionary.md`.
|
||||
|
||||
## Names
|
||||
|
||||
### Reserve `lib` for `nixpkgs.lib`
|
||||
|
||||
Use `lib` only for the base `nixpkgs.lib` value.
|
||||
Do not use `lib` for project functions.
|
||||
Use `util` for library functions passed through module arguments.
|
||||
Use `flake.mk<Name>` for assembly functions used by flake outputs.
|
||||
|
||||
Avoid:
|
||||
|
||||
```nix
|
||||
specialArgs = {
|
||||
lib = util;
|
||||
};
|
||||
```
|
||||
|
||||
Use:
|
||||
|
||||
```nix
|
||||
specialArgs = {
|
||||
inherit util;
|
||||
};
|
||||
```
|
||||
|
||||
Then call library functions through `util` inside modules:
|
||||
|
||||
```nix
|
||||
{
|
||||
lib,
|
||||
util,
|
||||
...
|
||||
}:
|
||||
{
|
||||
networking.networkmanager.unmanaged = [ "interface-name:${util.bridgeName 10}" ];
|
||||
}
|
||||
```
|
||||
|
||||
### Name project function namespaces by role
|
||||
|
||||
Put pure project functions under `util`.
|
||||
Put functions that create systems or output values under `flake`.
|
||||
This keeps data-only functions separate from functions that carry flake context.
|
||||
|
||||
Avoid:
|
||||
|
||||
```nix
|
||||
util.mkHosts { root = self + "/hosts"; }
|
||||
```
|
||||
|
||||
Use:
|
||||
|
||||
```nix
|
||||
flake.mkHosts { root = self + "/hosts"; }
|
||||
```
|
||||
|
||||
## File Placement
|
||||
|
||||
### Put files in the directory that owns their role
|
||||
|
||||
Choose the file location before writing a Nix file.
|
||||
Put assembly files under `flake/`.
|
||||
Put library files under `lib/`.
|
||||
Put modules under `modules/`.
|
||||
Put host configurations under `hosts/<name>/`.
|
||||
Put host-side guest wiring under `guests/`.
|
||||
Put registry definitions under `registry/`.
|
||||
Keep `flake.nix` focused on inputs and output wiring.
|
||||
Do not add root-level Nix files other than `flake.nix`.
|
||||
|
||||
A module is self-contained configuration under `modules/`.
|
||||
A module configures one program, service, or subsystem.
|
||||
Files under `hosts/`, `guests/`, and `modules/` return NixOS modules.
|
||||
|
||||
### Use `default.nix` for module directories with support files
|
||||
|
||||
Use `default.nix` as the primary module when a module directory owns support files.
|
||||
Keep sibling files for data or code read by that primary module.
|
||||
|
||||
Avoid:
|
||||
|
||||
```text
|
||||
modules/shell/fish/fish.nix
|
||||
modules/shell/fish/config.fish
|
||||
```
|
||||
|
||||
Use:
|
||||
|
||||
```text
|
||||
modules/shell/fish/default.nix
|
||||
modules/shell/fish/config.fish
|
||||
```
|
||||
|
||||
### Use non-module `default.nix` files only for directory assembly
|
||||
|
||||
Outside `modules/`, use `default.nix` to connect files in its directory.
|
||||
Do not define primary assembly functions in `default.nix`.
|
||||
Put primary assembly functions in named files.
|
||||
|
||||
Avoid:
|
||||
|
||||
```text
|
||||
flake/default.nix # defines mkHost
|
||||
```
|
||||
|
||||
Use:
|
||||
|
||||
```text
|
||||
flake/default.nix # imports ./host.nix
|
||||
flake/host.nix # defines mkHosts
|
||||
```
|
||||
|
||||
### Keep assembly files focused on one concept
|
||||
|
||||
Give each assembly file under `flake/` one top-level concept.
|
||||
Keep helper functions private to that file when no other file needs them.
|
||||
Export the constructor for the concept when another assembly file needs it.
|
||||
Name that constructor `mk<Concept>`.
|
||||
|
||||
Avoid:
|
||||
|
||||
```nix
|
||||
{
|
||||
inherit collectNixFiles mkHost mkHosts;
|
||||
}
|
||||
```
|
||||
|
||||
Use:
|
||||
|
||||
```nix
|
||||
{
|
||||
inherit mkHosts;
|
||||
}
|
||||
```
|
||||
|
||||
## Function Arguments
|
||||
|
||||
### Use closed interfaces for assembly files and library files
|
||||
|
||||
Use exact function argument patterns for assembly files and library files.
|
||||
The caller owns the argument set for these files.
|
||||
The imported file names each accepted attribute.
|
||||
The imported file rejects stale or accidental extra attributes.
|
||||
|
||||
Avoid:
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs,
|
||||
lib,
|
||||
self,
|
||||
...
|
||||
}:
|
||||
```
|
||||
|
||||
Use:
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs,
|
||||
lib,
|
||||
self,
|
||||
}:
|
||||
```
|
||||
|
||||
### Pass function arguments explicitly
|
||||
|
||||
Pass only the values an assembly file or library file needs.
|
||||
Bind `lib` to the base `nixpkgs.lib` value at the call site.
|
||||
Use `inherit` only for values that keep the same name.
|
||||
|
||||
Avoid:
|
||||
|
||||
```nix
|
||||
import ./flake inputs
|
||||
```
|
||||
|
||||
Use:
|
||||
|
||||
```nix
|
||||
let
|
||||
inherit (nixpkgs) lib;
|
||||
in
|
||||
import ./flake { inherit lib inputs self; }
|
||||
```
|
||||
|
||||
### Keep NixOS module interfaces open
|
||||
|
||||
Files under `hosts/`, `guests/`, and `modules/` return NixOS modules.
|
||||
`nixosSystem` owns their argument shape.
|
||||
Name each argument the module uses.
|
||||
Keep `...` so the module accepts standard arguments and special arguments.
|
||||
|
||||
Avoid:
|
||||
|
||||
```nix
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
registry,
|
||||
}:
|
||||
```
|
||||
|
||||
Use:
|
||||
|
||||
```nix
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
registry,
|
||||
...
|
||||
}:
|
||||
```
|
||||
|
||||
## `let` Bindings
|
||||
|
||||
### Use role-specific binding names
|
||||
|
||||
A `let` binding name must explain the value's role in the expression.
|
||||
Avoid names that describe only the value's type.
|
||||
|
||||
Avoid:
|
||||
|
||||
```nix
|
||||
let
|
||||
names = lib.attrNames hosts;
|
||||
in
|
||||
lib.genAttrs names mkHost
|
||||
```
|
||||
|
||||
Use:
|
||||
|
||||
```nix
|
||||
let
|
||||
hostNames = lib.attrNames hosts;
|
||||
in
|
||||
lib.genAttrs hostNames mkHost
|
||||
```
|
||||
|
||||
## Standard Library Calls
|
||||
|
||||
### Qualify standard library calls in flake assembly
|
||||
|
||||
Use qualified standard library calls inside assembly files.
|
||||
Use `lib.mapAttrs` instead of inheriting `mapAttrs` from `lib`.
|
||||
This keeps the source of each function visible.
|
||||
|
||||
Avoid:
|
||||
|
||||
```nix
|
||||
let
|
||||
inherit (lib) mapAttrs;
|
||||
in
|
||||
mapAttrs f attrs
|
||||
```
|
||||
|
||||
Use:
|
||||
|
||||
```nix
|
||||
lib.mapAttrs f attrs
|
||||
```
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
# Technical Writing
|
||||
|
||||
This guide defines writing rules for comments, user-facing descriptions, commit prose, pull-request prose, and Markdown documentation.
|
||||
These rules apply throughout this repository.
|
||||
|
||||
ASD-STE100 Simplified Technical English partly informs these rules.
|
||||
This repository uses those ideas as a local style discipline.
|
||||
It does not claim formal ASD-STE100 compliance.
|
||||
|
||||
Read the local dictionary with this guide.
|
||||
In this guide, “the dictionary” means `docs/dictionary.md`.
|
||||
The dictionary defines banned terms and the strict domain allowlist.
|
||||
|
||||
## Structural Conciseness and Grammar Rules
|
||||
|
||||
- **Clear and concise style:** Use direct, concise writing.
|
||||
Use simple present tense for factual descriptions.
|
||||
Do not use uncertainty words such as `could`, `might`, and `should` unless uncertainty is the subject.
|
||||
Use each domain word with the meaning assigned in the dictionary.
|
||||
Avoid: `The tool should usually handle the input correctly.`
|
||||
Use: `The tool validates the input.`
|
||||
- **Avoid jargon and inflated language:** Use common technical words.
|
||||
Avoid unnecessary jargon in three categories:
|
||||
1. Linguistic and grammatical meta-jargon.
|
||||
Avoid: `modal auxiliaries`
|
||||
Use: `uncertain words`
|
||||
2. Inflated corporate phrasing.
|
||||
Avoid: `utilize`
|
||||
Use: `use`
|
||||
Avoid: `commence`
|
||||
Use: `start`
|
||||
3. Unapproved domain inventions.
|
||||
Do not use project-specific terms unless the dictionary defines them.
|
||||
General technical words do not need dictionary entries.
|
||||
Add a common word to the dictionary when it has a project-specific meaning.
|
||||
If a term is vague or overloaded, choose one precise term.
|
||||
List avoided alternatives in the dictionary.
|
||||
Choose the simplest word that accurately communicates the meaning.
|
||||
- **Sentence length limits:** Code comments, user-facing descriptions, and procedural instructions have a 20-word limit.
|
||||
Descriptive Markdown paragraphs have a 25-word limit.
|
||||
Do not put multiple sentences on one physical line.
|
||||
Wrap a sentence across multiple physical lines when readability requires it.
|
||||
Split long sentences into multiple concise sentences.
|
||||
Avoid: `The command configures the service with the generated settings and restarts it after the file changes.`
|
||||
Use: `The command configures the service with the generated settings.`
|
||||
Use: `It restarts the service after the file changes.`
|
||||
- **Active voice:** Always use active voice.
|
||||
The subject must perform the action.
|
||||
Do not use passive voice.
|
||||
Avoid: `Stale entries are invalidated by the cache.`
|
||||
Use: `The cache invalidates stale entries.`
|
||||
- **Imperative mood:** Procedural instructions and task steps must start with an imperative verb.
|
||||
Summary fragments for declarations use third-person singular.
|
||||
Avoid: `The token should be validated before use.`
|
||||
Use for a task step: `Validate the token before use.`
|
||||
Use for a declaration summary: `Validates the token before use.`
|
||||
- **Noun clusters:** Do not write more than three consecutive nouns without a preposition or hyphen.
|
||||
Avoid: `session cache entry lookup`
|
||||
Use: `lookup of session cache entries`
|
||||
- **One idea per sentence:** Express only one instruction or core concept per sentence.
|
||||
Do not combine multiple operations in one compound sentence.
|
||||
Avoid: `Validate the input and restart the service when validation succeeds.`
|
||||
Use: `Validate the input.`
|
||||
Use: `Restart the service when validation succeeds.`
|
||||
- **Code token exemption:** Text in Markdown backticks, URLs, and code blocks counts as one word.
|
||||
Sentence-length limits and noun-cluster limits do not apply to these tokens.
|
||||
Example: `curl --fail --silent https://example.com/health` counts as one word.
|
||||
- **Cause and time precision:** Use `because` for causality.
|
||||
Use `since` only for passage of time.
|
||||
Use `as` only for simultaneous action.
|
||||
Avoid: `Since the token is expired, validation fails.`
|
||||
Use: `Because the token is expired, validation fails.`
|
||||
|
||||
## In-Code Documentation Conventions
|
||||
|
||||
- **Declaration coverage and exemptions:** Document public interfaces, configuration options, fields, and non-trivial public methods.
|
||||
Omit comments on obvious declarations when the name and context are self-explanatory.
|
||||
Avoid: `Enable this option.`
|
||||
Use: `Enables automatic cleanup after a successful run.`
|
||||
- **Declaration non-redundancy:** Comments must explain purpose, preconditions, invariants, or non-obvious consequences.
|
||||
Do not repeat information that the name, type, path, or parameter list already conveys.
|
||||
Avoid: `The timeout option sets the timeout.`
|
||||
Use: `Controls how long the command waits before it reports failure.`
|
||||
- **Multi-line declaration comments:** Start multi-line declaration comments with one summary sentence.
|
||||
Put a blank line between the summary and detail paragraphs.
|
||||
Use detail paragraphs for preconditions, invariants, examples, and non-obvious consequences.
|
||||
Avoid:
|
||||
|
||||
```text
|
||||
The caller must load the signing key before calling this method.
|
||||
Validates the token and returns false for expired tokens.
|
||||
```
|
||||
|
||||
Use:
|
||||
|
||||
```text
|
||||
Validates the token.
|
||||
|
||||
The caller must load the signing key before calling this method.
|
||||
Expired tokens return false instead of an error.
|
||||
```
|
||||
- **Code symbol references:** Enclose identifiers, paths, commands, option names, and file names in Markdown backticks.
|
||||
Avoid: Run npm test before editing package.json.
|
||||
Use: Run `npm test` before editing `package.json`.
|
||||
- **Boolean descriptions:** Start boolean descriptions with the behavior verb.
|
||||
Use `True if` only when the value reports state instead of enabling behavior.
|
||||
Avoid: `Flag that specifies whether retries are enabled.`
|
||||
Use: `Enables retry behavior.`
|
||||
Use for state: `True if the token is expired.`
|
||||
581
flake.lock
generated
581
flake.lock
generated
@@ -1,73 +1,5 @@
|
||||
{
|
||||
"nodes": {
|
||||
"base16": {
|
||||
"inputs": {
|
||||
"fromYaml": "fromYaml"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1755819240,
|
||||
"narHash": "sha256-qcMhnL7aGAuFuutH4rq9fvAhCpJWVHLcHVZLtPctPlo=",
|
||||
"owner": "SenchoPens",
|
||||
"repo": "base16.nix",
|
||||
"rev": "75ed5e5e3fce37df22e49125181fa37899c3ccd6",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "SenchoPens",
|
||||
"repo": "base16.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"base16-fish": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1765809053,
|
||||
"narHash": "sha256-XCUQLoLfBJ8saWms2HCIj4NEN+xNsWBlU1NrEPcQG4s=",
|
||||
"owner": "tomyun",
|
||||
"repo": "base16-fish",
|
||||
"rev": "86cbea4dca62e08fb7fd83a70e96472f92574782",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "tomyun",
|
||||
"repo": "base16-fish",
|
||||
"rev": "86cbea4dca62e08fb7fd83a70e96472f92574782",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"base16-helix": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1776754714,
|
||||
"narHash": "sha256-E3OAK27smtATTmX45uoTSRsVD+Y+ZiVVfgM/tjpbtYg=",
|
||||
"owner": "tinted-theming",
|
||||
"repo": "base16-helix",
|
||||
"rev": "4d508123037e7851ad36ebf7d9c48b0e9e1eb581",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "tinted-theming",
|
||||
"repo": "base16-helix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"base16-vim": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1732806396,
|
||||
"narHash": "sha256-e0bpPySdJf0F68Ndanwm+KWHgQiZ0s7liLhvJSWDNsA=",
|
||||
"owner": "tinted-theming",
|
||||
"repo": "base16-vim",
|
||||
"rev": "577fe8125d74ff456cf942c733a85d769afe58b7",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "tinted-theming",
|
||||
"repo": "base16-vim",
|
||||
"rev": "577fe8125d74ff456cf942c733a85d769afe58b7",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"chaotic": {
|
||||
"inputs": {
|
||||
"flake-schemas": "flake-schemas",
|
||||
@@ -75,11 +7,11 @@
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785327209,
|
||||
"narHash": "sha256-heXGjUBU1UsTHFzedDzYct9Cblr6FGzQcYyjCykywh8=",
|
||||
"lastModified": 1784318604,
|
||||
"narHash": "sha256-P/N5ZbGWITiTfmiWpE/1uyXdOCagpgw/YAZLZJSzx/I=",
|
||||
"owner": "chaotic-cx",
|
||||
"repo": "nyx",
|
||||
"rev": "90cfa9864fa08c923dddeca965103ad44663dd64",
|
||||
"rev": "21a8ef816f34558a438d778057a8809322ea2415",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -109,44 +41,6 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"firefox-addons": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"dir": "pkgs/firefox-addons",
|
||||
"lastModified": 1785384175,
|
||||
"narHash": "sha256-sWSJPXpQwKJstL4rdhpAQYCYlHK5wOkEHR8/lNHBVb4=",
|
||||
"owner": "rycee",
|
||||
"repo": "nur-expressions",
|
||||
"rev": "db607f3d0afe811bcb3b16266f28b2fc5af4e74f",
|
||||
"type": "gitlab"
|
||||
},
|
||||
"original": {
|
||||
"dir": "pkgs/firefox-addons",
|
||||
"owner": "rycee",
|
||||
"repo": "nur-expressions",
|
||||
"type": "gitlab"
|
||||
}
|
||||
},
|
||||
"firefox-gnome-theme": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1782007937,
|
||||
"narHash": "sha256-PbnJr+eB+9Czol3ReI83dUgEhcn0sDK6TSy6ODTQm88=",
|
||||
"owner": "rafaelmardojai",
|
||||
"repo": "firefox-gnome-theme",
|
||||
"rev": "981bd332015397fb1ca033fa982bd61635160c78",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "rafaelmardojai",
|
||||
"repo": "firefox-gnome-theme",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
@@ -168,27 +62,6 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts_2": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"stylix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1782949081,
|
||||
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-schemas": {
|
||||
"locked": {
|
||||
"lastModified": 1780327564,
|
||||
@@ -203,102 +76,6 @@
|
||||
"url": "https://flakehub.com/f/DeterminateSystems/flake-schemas/%3D0.5.0.tar.gz"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"fromYaml": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1731966426,
|
||||
"narHash": "sha256-lq95WydhbUTWig/JpqiB7oViTcHFP8Lv41IGtayokA8=",
|
||||
"owner": "SenchoPens",
|
||||
"repo": "fromYaml",
|
||||
"rev": "106af9e2f715e2d828df706c386a685698f3223b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "SenchoPens",
|
||||
"repo": "fromYaml",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitea-axi": {
|
||||
"inputs": {
|
||||
"home-manager": "home-manager_2",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785340481,
|
||||
"narHash": "sha256-GSxdQ7w8yYfZnfkXUuZ2fYIKibe9ZU8xDGyqpeTb2tE=",
|
||||
"ref": "refs/heads/main",
|
||||
"rev": "627fc9a32bb80d97923540fc0d3e9661961462ba",
|
||||
"revCount": 83,
|
||||
"type": "git",
|
||||
"url": "https://git.alexion.dev/alexion/gitea-axi"
|
||||
},
|
||||
"original": {
|
||||
"type": "git",
|
||||
"url": "https://git.alexion.dev/alexion/gitea-axi"
|
||||
}
|
||||
},
|
||||
"gitea-axi_2": {
|
||||
"inputs": {
|
||||
"home-manager": "home-manager_4",
|
||||
"nixpkgs": [
|
||||
"skills",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785340481,
|
||||
"narHash": "sha256-GSxdQ7w8yYfZnfkXUuZ2fYIKibe9ZU8xDGyqpeTb2tE=",
|
||||
"ref": "refs/heads/main",
|
||||
"rev": "627fc9a32bb80d97923540fc0d3e9661961462ba",
|
||||
"revCount": 83,
|
||||
"type": "git",
|
||||
"url": "https://git.alexion.dev/alexion/gitea-axi"
|
||||
},
|
||||
"original": {
|
||||
"type": "git",
|
||||
"url": "https://git.alexion.dev/alexion/gitea-axi"
|
||||
}
|
||||
},
|
||||
"gnome-shell": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"host": "gitlab.gnome.org",
|
||||
"lastModified": 1776175984,
|
||||
"narHash": "sha256-RJFlFW8GiMei6oqUGrMkGEvVqOH8U7Q8abc1yK4VKD8=",
|
||||
"owner": "GNOME",
|
||||
"repo": "gnome-shell",
|
||||
"rev": "e0fdc4c13250e9a9b8ea9594c83925274f4a5dca",
|
||||
"type": "gitlab"
|
||||
},
|
||||
"original": {
|
||||
"host": "gitlab.gnome.org",
|
||||
"owner": "GNOME",
|
||||
"ref": "50.1",
|
||||
"repo": "gnome-shell",
|
||||
"type": "gitlab"
|
||||
}
|
||||
},
|
||||
"home-manager": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
@@ -307,11 +84,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785288465,
|
||||
"narHash": "sha256-nCkxaGRtyNheNTxoc527gjOG0BN2zovsWDQVBeKDMW8=",
|
||||
"lastModified": 1784129366,
|
||||
"narHash": "sha256-N5JiyICSeQF14x+OQebNyPpYowOT9Rs1iKyeCylSzOA=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "36662afed2fa1c9b69bdd03edb92ad572202ca20",
|
||||
"rev": "165228b0efefc3e635e5174020c40ea64271dc25",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -323,16 +100,15 @@
|
||||
"home-manager_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"gitea-axi",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1784588016,
|
||||
"narHash": "sha256-ouZe80aWEhMLVMkqICFDN+JUw+0FJtCr/bh+hHtRtMg=",
|
||||
"lastModified": 1784351324,
|
||||
"narHash": "sha256-By+kuRJZRqs2TuXgtR8vJ8cTKWXw33YG/Yollu5cO1U=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "deeb6b7eb7e0c44ae1819c051ce175bd92a85100",
|
||||
"rev": "460108009ca1ff69ca2ff19079ca2c838d6e3080",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -341,111 +117,13 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"home-manager_3": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785306346,
|
||||
"narHash": "sha256-DScBkW0fOgpGPK2trNoX3ryLTlaC14+gglFo/BhGJ4g=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "e705714e918c3b11affcdd15db2cbe3a070420a0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"home-manager_4": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"skills",
|
||||
"gitea-axi",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1784588016,
|
||||
"narHash": "sha256-ouZe80aWEhMLVMkqICFDN+JUw+0FJtCr/bh+hHtRtMg=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "deeb6b7eb7e0c44ae1819c051ce175bd92a85100",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"home-manager_5": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"skills",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1784725727,
|
||||
"narHash": "sha256-J5+C9wsO0lhDyUalQzfplDbRjyHDYeEH5+9sdyXtwa8=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "041a999e8c1c5b731913855909e68d30ca69b8e0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"monorepo": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1788059616,
|
||||
"narHash": "sha256-HBYT3h0vXRsvmgPOhUW3kgPTkgdl73PEwRTJQ5X48aM=",
|
||||
"path": "/home/alexion/dev",
|
||||
"type": "path"
|
||||
},
|
||||
"original": {
|
||||
"path": "/home/alexion/dev",
|
||||
"type": "path"
|
||||
}
|
||||
},
|
||||
"nixos-hardware": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785232496,
|
||||
"narHash": "sha256-65EQYIRRpTdpH8lUiB6Mvo5uBkG60aBIzAJuALfx+O0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixos-hardware",
|
||||
"rev": "2e790b0a6be8ec2b76174ac0931b8ff11919ec98",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "nixos-hardware",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1785090369,
|
||||
"narHash": "sha256-m0pDuRJG7EDo9ri+4Ksu83VsI+PlxNC9lNBfydejce4=",
|
||||
"lastModified": 1784120854,
|
||||
"narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "624af665418d3c65d544145b4d34ad696439570e",
|
||||
"rev": "753cc8a3a87467296ddd1fa93f0cc3e81120ee46",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -457,11 +135,11 @@
|
||||
},
|
||||
"nixpkgs-stable": {
|
||||
"locked": {
|
||||
"lastModified": 1785133411,
|
||||
"narHash": "sha256-Yjv0WEg39KRYS0rBdTbu6Fc/or/ihAKk13W9sQ6VWd0=",
|
||||
"lastModified": 1784280462,
|
||||
"narHash": "sha256-DtoqIqM7VkR6NxAkcLpMwmi02USwWb3JdmNGLyhthc0=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "2f5a153c270b70cb0f8c11f46d96d6d3bc39f4e3",
|
||||
"rev": "293d6abedf0478e681a4dfcfcb35b30fc796a32f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -473,11 +151,11 @@
|
||||
},
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1785301185,
|
||||
"narHash": "sha256-eoS3KQTO0aPWXZvIaRbRAzSSHW3l5wdMFXtT1ISfoKA=",
|
||||
"lastModified": 1784347607,
|
||||
"narHash": "sha256-VI5cdo27nEZ3m1SlgB8RvBbrqFUO2/dUgrrLWe407oA=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9bc02893134c733dd85de46ee4fb2fac696b5529",
|
||||
"rev": "31cd72fdba8fa052e437ce7e6879c4fe62def10f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -489,11 +167,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1785318670,
|
||||
"narHash": "sha256-dN6Ou5x/+23FZLEpYP3IffO+NyJFzUlGumt1uu3MMaY=",
|
||||
"lastModified": 1784120854,
|
||||
"narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0954f7ee2f6bb3dc7d4e3d0d8bcb8fd4bde4cfc5",
|
||||
"rev": "753cc8a3a87467296ddd1fa93f0cc3e81120ee46",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -512,11 +190,11 @@
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785364321,
|
||||
"narHash": "sha256-BLuHl+nZKb+FDq3GAM6L+UBEiyVepXANA31fT1F56pw=",
|
||||
"lastModified": 1784057377,
|
||||
"narHash": "sha256-yycNej5//EsRbV10moBoh+/63vXEwZD1ZFEiRm6C9rQ=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixvim",
|
||||
"rev": "acd69cc15d57004e8cb4495034320263a3d362ea",
|
||||
"rev": "07180a087e4a00720dc0731cbcd8dec796974381",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -525,126 +203,15 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nur": {
|
||||
"inputs": {
|
||||
"flake-parts": [
|
||||
"stylix",
|
||||
"flake-parts"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"stylix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1783439237,
|
||||
"narHash": "sha256-WUr8JF2v3n4Y30E5dxv4sAgNJXpVDBoCQNoQ/V4+n4o=",
|
||||
"owner": "nix-community",
|
||||
"repo": "NUR",
|
||||
"rev": "b70bb66c7bcd162642f3a609bc16843c7059f503",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "NUR",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"chaotic": "chaotic",
|
||||
"disko": "disko",
|
||||
"firefox-addons": "firefox-addons",
|
||||
"gitea-axi": "gitea-axi",
|
||||
"home-manager": "home-manager_3",
|
||||
"monorepo": "monorepo",
|
||||
"nixos-hardware": "nixos-hardware",
|
||||
"nixpkgs": [
|
||||
"monorepo",
|
||||
"nixpkgs"
|
||||
],
|
||||
"home-manager": "home-manager_2",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"nixpkgs-stable": "nixpkgs-stable",
|
||||
"nixpkgs-unstable": "nixpkgs-unstable",
|
||||
"nixvim": "nixvim",
|
||||
"skills": "skills",
|
||||
"sops-nix": "sops-nix",
|
||||
"stylix": "stylix"
|
||||
}
|
||||
},
|
||||
"skills": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"gitea-axi": "gitea-axi_2",
|
||||
"home-manager": "home-manager_5",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1787455371,
|
||||
"narHash": "sha256-MJ0bX4/cksSbGl7v9o0l+Wy1xAEa3BgzG7Hs8EEGde0=",
|
||||
"ref": "refs/heads/main",
|
||||
"rev": "9a1d21da4d3e70165ff772967d639df244dcc2ea",
|
||||
"revCount": 55,
|
||||
"type": "git",
|
||||
"url": "https://git.alexion.dev/alexion/skills"
|
||||
},
|
||||
"original": {
|
||||
"type": "git",
|
||||
"url": "https://git.alexion.dev/alexion/skills"
|
||||
}
|
||||
},
|
||||
"sops-nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1783174389,
|
||||
"narHash": "sha256-aCWC8ngycU7OdJrU2+Je3qf+1a2ykuBvpPhZT/9tXMc=",
|
||||
"owner": "Mic92",
|
||||
"repo": "sops-nix",
|
||||
"rev": "f1406619a3884cd5c47992a70b8b35c9c0fcb4c9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "Mic92",
|
||||
"repo": "sops-nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"stylix": {
|
||||
"inputs": {
|
||||
"base16": "base16",
|
||||
"base16-fish": "base16-fish",
|
||||
"base16-helix": "base16-helix",
|
||||
"base16-vim": "base16-vim",
|
||||
"firefox-gnome-theme": "firefox-gnome-theme",
|
||||
"flake-parts": "flake-parts_2",
|
||||
"gnome-shell": "gnome-shell",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"nur": "nur",
|
||||
"systems": "systems_3",
|
||||
"tinted-kitty": "tinted-kitty",
|
||||
"tinted-schemes": "tinted-schemes",
|
||||
"tinted-tmux": "tinted-tmux",
|
||||
"tinted-zed": "tinted-zed"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1784676123,
|
||||
"narHash": "sha256-ndyanKzw90yX2nUVFmTuYqXidUNymtMfgmIHyNdhht0=",
|
||||
"owner": "danth",
|
||||
"repo": "stylix",
|
||||
"rev": "66714e5ce44269ecc58c20d9196da8dbe1b27a31",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "danth",
|
||||
"repo": "stylix",
|
||||
"type": "github"
|
||||
"nixvim": "nixvim"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
@@ -662,100 +229,6 @@
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_3": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"tinted-kitty": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1735730497,
|
||||
"narHash": "sha256-4KtB+FiUzIeK/4aHCKce3V9HwRvYaxX+F1edUrfgzb8=",
|
||||
"owner": "tinted-theming",
|
||||
"repo": "tinted-kitty",
|
||||
"rev": "de6f888497f2c6b2279361bfc790f164bfd0f3fa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "tinted-theming",
|
||||
"repo": "tinted-kitty",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"tinted-schemes": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1781968807,
|
||||
"narHash": "sha256-yYO3Vw2M0y3TAUqt+9+Mj0zwP3XDTF5/PXcPhhFQ1ZM=",
|
||||
"owner": "tinted-theming",
|
||||
"repo": "schemes",
|
||||
"rev": "2ccef2f4b22e3cab5a9292811f7133a07eeba4a7",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "tinted-theming",
|
||||
"repo": "schemes",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"tinted-tmux": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1782012462,
|
||||
"narHash": "sha256-2iDiD8DQLwS1lGuD9TS8WlvNyDoTs6krWntJbtB2zGo=",
|
||||
"owner": "tinted-theming",
|
||||
"repo": "tinted-tmux",
|
||||
"rev": "8c4e750f738a742bd73377ee41d3dadedebedef4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "tinted-theming",
|
||||
"repo": "tinted-tmux",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"tinted-zed": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1782009766,
|
||||
"narHash": "sha256-VUhBjpGvWqHI7rWeyMYb/u87YJSXHKfVV6S+IelWeO8=",
|
||||
"owner": "tinted-theming",
|
||||
"repo": "base16-zed",
|
||||
"rev": "5e8350bcd354e3241ab681a265fa6ef060c40be1",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "tinted-theming",
|
||||
"repo": "base16-zed",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
77
flake.nix
77
flake.nix
@@ -2,10 +2,8 @@
|
||||
description = "Alexion's NixOS configuration — one flake for every host";
|
||||
|
||||
inputs = {
|
||||
monorepo.url = "path:/home/alexion/dev";
|
||||
|
||||
# Base package set shared with the monorepo.
|
||||
nixpkgs.follows = "monorepo/nixpkgs";
|
||||
# Base channel.
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
|
||||
# Fresher packages, reachable per-package as `unstable.<name>`.
|
||||
nixpkgs-unstable.url = "github:nixos/nixpkgs/nixpkgs-unstable";
|
||||
@@ -18,59 +16,20 @@
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# Signed AMO extensions, pinned by version and hash.
|
||||
firefox-addons = {
|
||||
url = "gitlab:rycee/nur-expressions?dir=pkgs/firefox-addons";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# Follows our nixpkgs so its plugins build against the same package set.
|
||||
nixvim = {
|
||||
url = "github:nix-community/nixvim";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# Declarative disk partitioning.
|
||||
# Each host declares its own layout.
|
||||
# Declarative disk partitioning; each host declares its own layout.
|
||||
disko = {
|
||||
url = "github:nix-community/disko";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# Upstream per-machine hardware profiles.
|
||||
# Each host imports its own.
|
||||
nixos-hardware = {
|
||||
url = "github:NixOS/nixos-hardware";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# Decrypts committed secrets at activation, from an age identity on the host.
|
||||
sops-nix = {
|
||||
url = "github:Mic92/sops-nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# Themes the graphical layer from one base16 scheme.
|
||||
# Follows our nixpkgs so it themes the same package set the host builds.
|
||||
stylix = {
|
||||
url = "github:danth/stylix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# Agent-ergonomic CLI for Gitea, with a home-manager module for the agent context.
|
||||
gitea-axi = {
|
||||
url = "git+https://git.alexion.dev/alexion/gitea-axi";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# Personal agent skills, packaged as per-skill derivations with a home-manager module.
|
||||
skills = {
|
||||
url = "git+https://git.alexion.dev/alexion/skills";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# CachyOS kernel and binary cache.
|
||||
# Pins its own nixpkgs so its cache stays usable and the kernel is fetched from it.
|
||||
# CachyOS kernel and binary cache. Pins its own nixpkgs so its cache stays
|
||||
# usable and the kernel is fetched from it.
|
||||
chaotic.url = "github:chaotic-cx/nyx/nyxpkgs-unstable";
|
||||
};
|
||||
|
||||
@@ -78,30 +37,18 @@
|
||||
{ self, nixpkgs, ... }@inputs:
|
||||
let
|
||||
inherit (nixpkgs) lib;
|
||||
my = import ./lib { inherit lib inputs self; };
|
||||
in
|
||||
{
|
||||
inherit (import ./flake { inherit lib inputs self; }) nixosConfigurations;
|
||||
# Helper functions for discovering and building hosts.
|
||||
lib = my;
|
||||
|
||||
# A project shell for agent-local resources that should travel with this
|
||||
# checkout rather than the operator's global profile.
|
||||
devShells.x86_64-linux.default =
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
||||
in
|
||||
pkgs.mkShell {
|
||||
packages = [ inputs.gitea-axi.packages.x86_64-linux.gitea-axi ];
|
||||
shellHook = inputs.skills.lib.mkSkillsShellHook [
|
||||
inputs.gitea-axi.packages.x86_64-linux.gitea-axi-skill
|
||||
];
|
||||
};
|
||||
# Every host under hosts/ is discovered and built.
|
||||
nixosConfigurations = my.mkHosts (self + "/hosts");
|
||||
|
||||
# `nix flake check` builds every discovered host toplevel.
|
||||
# `nix flake check` builds each host's toplevel.
|
||||
checks.x86_64-linux = lib.mapAttrs (
|
||||
name: host:
|
||||
if host.config.warnings == [ ] then
|
||||
host.config.system.build.toplevel
|
||||
else
|
||||
throw "Host ${name} has evaluation warnings:\n${lib.concatStringsSep "\n" host.config.warnings}"
|
||||
_name: host: host.config.system.build.toplevel
|
||||
) self.nixosConfigurations;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
lib,
|
||||
inputs,
|
||||
self,
|
||||
}:
|
||||
let
|
||||
util = import ../lib { inherit lib; };
|
||||
|
||||
registry = import ./registry.nix {
|
||||
inherit lib;
|
||||
hosts = self.nixosConfigurations;
|
||||
root = self + "/registry";
|
||||
};
|
||||
|
||||
modules = import ./module.nix {
|
||||
inherit util;
|
||||
root = self + "/modules";
|
||||
};
|
||||
|
||||
guestModules = import ./guest.nix {
|
||||
inherit
|
||||
inputs
|
||||
lib
|
||||
registry
|
||||
specialArgs
|
||||
util
|
||||
;
|
||||
repositoryModules = modules;
|
||||
root = self + "/guests";
|
||||
};
|
||||
|
||||
specialArgs = {
|
||||
inherit
|
||||
inputs
|
||||
self
|
||||
util
|
||||
;
|
||||
registry = registry.collected;
|
||||
};
|
||||
in
|
||||
{
|
||||
nixosConfigurations = import ./host.nix {
|
||||
inherit
|
||||
inputs
|
||||
lib
|
||||
specialArgs
|
||||
guestModules
|
||||
;
|
||||
registryModule = registry.module;
|
||||
repositoryModules = modules;
|
||||
root = self + "/hosts";
|
||||
};
|
||||
}
|
||||
279
flake/guest.nix
279
flake/guest.nix
@@ -1,279 +0,0 @@
|
||||
{
|
||||
inputs,
|
||||
lib,
|
||||
root,
|
||||
specialArgs,
|
||||
repositoryModules,
|
||||
registry,
|
||||
util,
|
||||
}:
|
||||
let
|
||||
mkGuest =
|
||||
name:
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
cfg = config.guests.${name};
|
||||
|
||||
directoryMounts = lib.filterAttrs (_: mount: mount.directory != null) cfg.mounts;
|
||||
|
||||
directoryScript = lib.concatStringsSep "\n" (
|
||||
lib.mapAttrsToList (
|
||||
_: mount:
|
||||
''
|
||||
${config.system.path}/bin/install -d \
|
||||
${lib.optionalString (mount.owner != null) "-o ${lib.escapeShellArg (toString mount.owner)}"} \
|
||||
${lib.optionalString (mount.group != null) "-g ${lib.escapeShellArg mount.group}"} \
|
||||
${lib.optionalString (mount.mode != null) "-m ${lib.escapeShellArg mount.mode}"} \
|
||||
${lib.escapeShellArg mount.directory}
|
||||
''
|
||||
) directoryMounts
|
||||
);
|
||||
in
|
||||
{
|
||||
options.guests.${name} = {
|
||||
enable = lib.mkEnableOption "the ${name} guest, run in its own nested container";
|
||||
vlan = lib.mkOption {
|
||||
type = lib.types.ints.between 1 4094;
|
||||
example = 10;
|
||||
description = ''
|
||||
The tagged VLAN this guest lives on. The guest attaches to its host's
|
||||
`br-vlan<id>` bridge for that VLAN. The id must be one of the host's
|
||||
`modules.network.vlans.ids`.
|
||||
'';
|
||||
};
|
||||
mac = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = util.deriveMac "${config.networking.hostName}.${name}";
|
||||
defaultText = lib.literalMD "a stable address derived from the host name and guest option path";
|
||||
example = "bc:24:11:00:00:01";
|
||||
description = ''
|
||||
The guest's MAC address on its VLAN, pinned inside the guest by its
|
||||
own networkd. Set it to reuse an existing address so a router's DHCP
|
||||
reservation keeps working. Left unset, a stable address is derived
|
||||
from the host name and guest option path in the locally-administered range.
|
||||
'';
|
||||
};
|
||||
address = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "10.23.20.126";
|
||||
description = ''
|
||||
Expected IP address assigned outside NixOS for this guest.
|
||||
Left null, this guest contributes no address to the address registry.
|
||||
'';
|
||||
};
|
||||
reverseProxy = {
|
||||
enable = lib.mkEnableOption "a reverse-proxy route for this guest";
|
||||
|
||||
route = lib.mkOption {
|
||||
type = registry.types.route;
|
||||
description = "Route registry entry published for this guest.";
|
||||
};
|
||||
};
|
||||
mounts = lib.mkOption {
|
||||
type = lib.types.attrsOf (
|
||||
lib.types.submodule {
|
||||
options = {
|
||||
directory = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/srv/media";
|
||||
description = "Host directory bind-mounted into the guest.";
|
||||
};
|
||||
file = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/run/secrets/service-password";
|
||||
description = "Host file bind-mounted into the guest.";
|
||||
};
|
||||
readOnly = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Mount the host path read-only inside the guest.";
|
||||
};
|
||||
owner = lib.mkOption {
|
||||
type = lib.types.nullOr (
|
||||
lib.types.oneOf [
|
||||
lib.types.str
|
||||
lib.types.ints.positive
|
||||
]
|
||||
);
|
||||
default = null;
|
||||
description = "Owner assigned to the host directory before the guest starts.";
|
||||
};
|
||||
group = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Group assigned to the host directory before the guest starts.";
|
||||
};
|
||||
mode = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "0770";
|
||||
description = "Mode assigned to the host directory before the guest starts.";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
default = { };
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
"/data/media" = { directory = "/srv/media"; };
|
||||
"/data/config" = { directory = "/srv/config/jellyfin"; };
|
||||
}
|
||||
'';
|
||||
description = ''
|
||||
Host paths bind-mounted into the guest, keyed by the path they appear
|
||||
at inside the guest, so a guest sees exactly the data it should at any
|
||||
granularity — a single folder, a whole pool, or a decrypted secret.
|
||||
Mounts are read-write unless `readOnly` is set.
|
||||
'';
|
||||
};
|
||||
interior = lib.mkOption {
|
||||
type = lib.types.deferredModule;
|
||||
default = { };
|
||||
description = "NixOS module imported into the guest interior.";
|
||||
};
|
||||
limits = {
|
||||
memory = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "2G";
|
||||
description = ''
|
||||
Cap on the guest's memory, applied to its unit as `MemoryMax`.
|
||||
Accepts systemd size suffixes such as `512M` or `2G`. Left null,
|
||||
the guest's memory is uncapped.
|
||||
'';
|
||||
};
|
||||
cpu = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "150%";
|
||||
description = ''
|
||||
Cap on the guest's CPU, applied to its unit as `CPUQuota`, where
|
||||
`100%` is one full core. Left null, the guest's CPU is uncapped.
|
||||
'';
|
||||
};
|
||||
tasksMax = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.ints.positive;
|
||||
default = null;
|
||||
example = 512;
|
||||
description = ''
|
||||
Cap on the number of processes and threads the guest may spawn,
|
||||
applied to its unit as `TasksMax`. Left null, the task count is
|
||||
uncapped.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
assertions =
|
||||
let
|
||||
hasDirectorySettings = mount: mount.owner != null || mount.group != null || mount.mode != null;
|
||||
hasCompleteDirectorySettings = mount: mount.owner != null && mount.group != null && mount.mode != null;
|
||||
hasOneSource = mount: (mount.directory != null) != (mount.file != null);
|
||||
mountsWithInvalidSource = lib.attrNames (lib.filterAttrs (_: mount: !hasOneSource mount) cfg.mounts);
|
||||
fileMountsWithHostSettings = lib.attrNames (
|
||||
lib.filterAttrs (_: mount: mount.file != null && hasDirectorySettings mount) cfg.mounts
|
||||
);
|
||||
directoryMountsWithPartialHostSettings = lib.attrNames (
|
||||
lib.filterAttrs (_: mount: mount.directory != null && hasDirectorySettings mount && !hasCompleteDirectorySettings mount) cfg.mounts
|
||||
);
|
||||
in
|
||||
[
|
||||
{
|
||||
assertion = mountsWithInvalidSource == [ ];
|
||||
message = ''
|
||||
guests.${name} mounts ${lib.concatStringsSep ", " mountsWithInvalidSource} must set exactly one of `directory` or `file`.
|
||||
'';
|
||||
}
|
||||
{
|
||||
assertion = fileMountsWithHostSettings == [ ];
|
||||
message = ''
|
||||
guests.${name} file mounts ${lib.concatStringsSep ", " fileMountsWithHostSettings} must not set `owner`, `group`, or `mode`.
|
||||
'';
|
||||
}
|
||||
{
|
||||
assertion = directoryMountsWithPartialHostSettings == [ ];
|
||||
message = ''
|
||||
guests.${name} directory mounts ${lib.concatStringsSep ", " directoryMountsWithPartialHostSettings} must set `owner`, `group`, and `mode` together.
|
||||
'';
|
||||
}
|
||||
{
|
||||
assertion = lib.elem cfg.vlan config.modules.network.vlans.ids;
|
||||
message = ''
|
||||
guests.${name}.vlan = ${toString cfg.vlan} is not among its host's modules.network.vlans.ids (${lib.concatMapStringsSep ", " toString config.modules.network.vlans.ids}). Declare the VLAN on the host or correct the guest's placement.
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
registry.address.${config.networking.hostName}.${name} =
|
||||
lib.mkIf (cfg.address != null) cfg.address;
|
||||
|
||||
registry.route.${config.networking.hostName}.${name} =
|
||||
lib.mkIf cfg.reverseProxy.enable cfg.reverseProxy.route;
|
||||
|
||||
systemd.services."container@${name}" =
|
||||
let
|
||||
bridgeDevice = "sys-subsystem-net-devices-${lib.replaceStrings [ "-" ] [ "\\x2d" ] (util.bridgeName cfg.vlan)}.device";
|
||||
in
|
||||
{
|
||||
after = [ bridgeDevice ];
|
||||
wants = [ bridgeDevice ];
|
||||
|
||||
serviceConfig = lib.filterAttrs (_: v: v != null) {
|
||||
MemoryMax = cfg.limits.memory;
|
||||
CPUQuota = cfg.limits.cpu;
|
||||
TasksMax = cfg.limits.tasksMax;
|
||||
};
|
||||
preStart = lib.mkIf (directoryMounts != { }) directoryScript;
|
||||
};
|
||||
|
||||
containers.${name} = {
|
||||
autoStart = true;
|
||||
privateNetwork = lib.mkDefault true;
|
||||
hostBridge = util.bridgeName cfg.vlan;
|
||||
privateUsers = lib.mkDefault "no";
|
||||
bindMounts = lib.mapAttrs (_: mount: {
|
||||
hostPath = if mount.directory != null then mount.directory else mount.file;
|
||||
isReadOnly = mount.readOnly;
|
||||
}) cfg.mounts;
|
||||
|
||||
inherit specialArgs;
|
||||
|
||||
config = {
|
||||
imports = [
|
||||
inputs.sops-nix.nixosModules.sops
|
||||
inputs.stylix.nixosModules.stylix
|
||||
|
||||
repositoryModules
|
||||
registry.module
|
||||
cfg.interior
|
||||
|
||||
{
|
||||
modules.guest.enable = true;
|
||||
|
||||
networking.useNetworkd = true;
|
||||
networking.useHostResolvConf = false;
|
||||
|
||||
systemd.network.networks."20-eth0" = {
|
||||
matchConfig.Name = "eth0";
|
||||
linkConfig.MACAddress = cfg.mac;
|
||||
networkConfig.DHCP = "yes";
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
let
|
||||
files = util.collectNixFiles root;
|
||||
guestFiles = lib.filterAttrs (file: type: type == "regular" && lib.hasSuffix ".nix" file) (builtins.readDir root);
|
||||
names = map (file: lib.removeSuffix ".nix" file) (lib.attrNames guestFiles);
|
||||
in
|
||||
{
|
||||
imports = files ++ map mkGuest names;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
inputs,
|
||||
lib,
|
||||
root,
|
||||
specialArgs,
|
||||
repositoryModules,
|
||||
|
||||
guestModules,
|
||||
registryModule,
|
||||
}:
|
||||
let
|
||||
mkHost =
|
||||
{
|
||||
root,
|
||||
name,
|
||||
}:
|
||||
lib.nixosSystem {
|
||||
inherit specialArgs;
|
||||
modules = [
|
||||
inputs.chaotic.nixosModules.default
|
||||
inputs.disko.nixosModules.disko
|
||||
inputs.sops-nix.nixosModules.sops
|
||||
inputs.stylix.nixosModules.stylix
|
||||
|
||||
repositoryModules
|
||||
guestModules
|
||||
|
||||
# TODO: refactor
|
||||
registryModule (root + "/${name}")
|
||||
|
||||
{
|
||||
networking.hostName = name;
|
||||
modules.host.enable = true;
|
||||
}
|
||||
];
|
||||
};
|
||||
in
|
||||
let
|
||||
directories = lib.filterAttrs (_name: type: type == "directory") (builtins.readDir root);
|
||||
names = lib.attrNames directories;
|
||||
in
|
||||
lib.genAttrs names (
|
||||
name:
|
||||
mkHost { inherit root name; }
|
||||
)
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
root,
|
||||
util,
|
||||
}:
|
||||
{
|
||||
imports = util.collectNixFiles root;
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
{ hosts, lib, root }:
|
||||
let
|
||||
checkType = lib.types.submodule {
|
||||
options = {
|
||||
assertion = lib.mkOption {
|
||||
type = lib.types.functionTo lib.types.bool;
|
||||
description = ''
|
||||
Predicate run against this registry's flattened collected entries.
|
||||
'';
|
||||
};
|
||||
|
||||
message = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
Error message shown when this registry validation check fails.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
predicates = {
|
||||
uniqueValue = value: entries: builtins.length (lib.unique (map value entries)) == builtins.length entries;
|
||||
};
|
||||
|
||||
registryLib = lib // {
|
||||
registry = (lib.registry or { }) // {
|
||||
inherit predicates;
|
||||
};
|
||||
};
|
||||
|
||||
registryDefinitionModule = {
|
||||
options = {
|
||||
type = lib.mkOption {
|
||||
type = lib.types.raw;
|
||||
description = ''
|
||||
NixOS option type for one registry entry.
|
||||
'';
|
||||
};
|
||||
|
||||
replicate = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Whether collected entries are merged and published under every host key.
|
||||
'';
|
||||
};
|
||||
|
||||
checks = lib.mkOption {
|
||||
type = lib.types.listOf checkType;
|
||||
default = [ ];
|
||||
description = ''
|
||||
Validation checks for this registry's flattened collected entries.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
registryName = fileName: lib.removeSuffix ".nix" fileName;
|
||||
|
||||
registryOption = registry: lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.attrsOf registry.type);
|
||||
default = { };
|
||||
description = ''
|
||||
Lazily collected fleet-visible registry entries, nested by host and entry name.
|
||||
'';
|
||||
};
|
||||
|
||||
mkRegistry = path:
|
||||
let
|
||||
evaluated = lib.evalModules {
|
||||
specialArgs.lib = registryLib;
|
||||
modules = [
|
||||
registryDefinitionModule
|
||||
path
|
||||
];
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit (evaluated.config) type replicate checks;
|
||||
};
|
||||
|
||||
definitions = lib.mapAttrs' (
|
||||
name: type:
|
||||
let
|
||||
path = root + "/${name}";
|
||||
in
|
||||
lib.nameValuePair (registryName name) (mkRegistry path)
|
||||
) (lib.filterAttrs (name: type: type == "regular" && lib.hasSuffix ".nix" name) (builtins.readDir root));
|
||||
|
||||
flattenRegistryEntries = registryName: data:
|
||||
lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
host: entries:
|
||||
lib.mapAttrsToList (entry: value: {
|
||||
path = "registry.${registryName}.${host}.${entry}";
|
||||
inherit host entry value;
|
||||
}) entries
|
||||
) data
|
||||
);
|
||||
|
||||
rawDeclarations = registryName: hosts:
|
||||
lib.mapAttrs (_hostName: host: host.config.registry.${registryName} or { }) hosts;
|
||||
|
||||
replicatedEntries = registryName: raw:
|
||||
let
|
||||
entries = lib.concatLists (
|
||||
lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
_evalHost: hostDeclarations:
|
||||
lib.mapAttrsToList (
|
||||
hostName: declarations:
|
||||
lib.mapAttrsToList (entryName: value: {
|
||||
path = "registry.${registryName}.${hostName}.${entryName}";
|
||||
inherit entryName value;
|
||||
}) declarations
|
||||
) hostDeclarations
|
||||
) raw
|
||||
)
|
||||
);
|
||||
in
|
||||
lib.foldl' (
|
||||
acc: entry:
|
||||
if lib.hasAttr entry.entryName acc then
|
||||
if acc.${entry.entryName}.value == entry.value then
|
||||
acc
|
||||
else
|
||||
throw "conflicting replicated registry entry ${entry.path} does not match ${acc.${entry.entryName}.path}"
|
||||
else
|
||||
acc // {
|
||||
${entry.entryName} = entry;
|
||||
}
|
||||
) { } entries;
|
||||
|
||||
replicatedData = registryName: hosts: raw:
|
||||
let
|
||||
entries = lib.mapAttrs (_entryName: entry: entry.value) (replicatedEntries registryName raw);
|
||||
in
|
||||
lib.mapAttrs (_hostName: _host: entries) hosts;
|
||||
|
||||
validationAssertions = data:
|
||||
lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
registryName: registry:
|
||||
let
|
||||
entries = flattenRegistryEntries registryName data.${registryName};
|
||||
in
|
||||
map (check: {
|
||||
assertion = check.assertion entries;
|
||||
inherit (check) message;
|
||||
}) registry.checks
|
||||
) definitions
|
||||
);
|
||||
|
||||
assertValid = data:
|
||||
let
|
||||
assertions = validationAssertions data;
|
||||
failures = lib.filter (assertion: !assertion.assertion) assertions;
|
||||
in
|
||||
assert lib.assertMsg (lib.all (assertion: assertion.assertion) assertions) (
|
||||
lib.concatMapStringsSep "\n" (assertion: assertion.message) failures
|
||||
);
|
||||
data;
|
||||
|
||||
join = registry: host: entry:
|
||||
lib.filterAttrs (_name: value: value != null) (
|
||||
lib.mapAttrs (registryName: _registry: (registry.${registryName}.${host} or { }).${entry} or null) definitions
|
||||
);
|
||||
|
||||
collectData =
|
||||
{ hosts }:
|
||||
let
|
||||
raw = lib.mapAttrs (registryName: _registry: rawDeclarations registryName hosts) definitions;
|
||||
data = lib.mapAttrs (
|
||||
registryName: registry:
|
||||
if registry.replicate or false then
|
||||
replicatedData registryName hosts raw.${registryName}
|
||||
else
|
||||
lib.mapAttrs (hostName: host: host.config.registry.${registryName}.${hostName} or { }) hosts
|
||||
) definitions;
|
||||
in
|
||||
assertValid data;
|
||||
|
||||
withOperations = registry:
|
||||
registry
|
||||
// {
|
||||
join = host: entry: join registry host entry;
|
||||
};
|
||||
in
|
||||
{
|
||||
types = lib.mapAttrs (_name: definition: definition.type) definitions;
|
||||
|
||||
module = {
|
||||
options.registry = lib.mapAttrs (_name: registry: registryOption registry) definitions;
|
||||
};
|
||||
|
||||
collected = withOperations (collectData { inherit hosts; });
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.guests.actualbudget;
|
||||
dataDir = "/data";
|
||||
in
|
||||
{
|
||||
options.guests.actualbudget = {
|
||||
dataPath = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/srv/actualbudget";
|
||||
description = "Host path bind-mounted as Actual Budget's data directory.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.dataPath != null;
|
||||
message = "guests.actualbudget.dataPath must point at the host directory that stores Actual Budget data.";
|
||||
}
|
||||
];
|
||||
|
||||
guests.actualbudget = {
|
||||
interior = {
|
||||
modules.services.actualbudget = {
|
||||
enable = true;
|
||||
inherit dataDir;
|
||||
};
|
||||
};
|
||||
mounts.${dataDir}.directory = cfg.dataPath;
|
||||
limits = {
|
||||
memory = lib.mkDefault "2G";
|
||||
cpu = lib.mkDefault "200%";
|
||||
tasksMax = lib.mkDefault 512;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.guests.copyparty;
|
||||
|
||||
volumeType = lib.types.submodule {
|
||||
options = {
|
||||
hostPath = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Host path bind-mounted into the Copyparty guest for this volume.";
|
||||
};
|
||||
|
||||
path = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Guest path served by this Copyparty volume.";
|
||||
};
|
||||
|
||||
access = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.listOf lib.types.str);
|
||||
default = { };
|
||||
description = "Copyparty access map from permission string to account names.";
|
||||
};
|
||||
|
||||
flags = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
description = "Copyparty volume flags.";
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
options.guests.copyparty = {
|
||||
accountName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "alexion";
|
||||
description = "Copyparty account name configured for password authentication.";
|
||||
};
|
||||
|
||||
passwordSecret = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "services/copyparty/users/alexion/password";
|
||||
description = "SOPS secret name containing the Copyparty account password.";
|
||||
};
|
||||
|
||||
stateDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/var/lib/copyparty";
|
||||
description = "Guest path where Copyparty stores service state.";
|
||||
};
|
||||
|
||||
statePath = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/srv/copyparty";
|
||||
description = "Host path bind-mounted as Copyparty's state directory.";
|
||||
};
|
||||
|
||||
reverseProxy.trustedSources = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
description = "IP ranges that may supply Copyparty's forwarded client IP header.";
|
||||
};
|
||||
|
||||
volumes = lib.mkOption {
|
||||
type = lib.types.attrsOf volumeType;
|
||||
default = { };
|
||||
description = "Copyparty volumes keyed by their URL path.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.statePath != null;
|
||||
message = "guests.copyparty.statePath must point at the host directory that stores Copyparty state.";
|
||||
}
|
||||
{
|
||||
assertion = cfg.volumes != { };
|
||||
message = "guests.copyparty.volumes must declare the host's served Copyparty volumes.";
|
||||
}
|
||||
];
|
||||
|
||||
guests.copyparty = {
|
||||
interior.modules.services.copyparty = {
|
||||
enable = true;
|
||||
inherit (cfg) accountName stateDir;
|
||||
passwordFile = "/run/secrets/${cfg.passwordSecret}";
|
||||
reverseProxy = {
|
||||
enable = cfg.reverseProxy.enable;
|
||||
inherit (cfg.reverseProxy) trustedSources;
|
||||
};
|
||||
volumes = lib.mapAttrs (_urlPath: volume: {
|
||||
inherit (volume) path access flags;
|
||||
}) cfg.volumes;
|
||||
};
|
||||
|
||||
mounts =
|
||||
{
|
||||
${cfg.stateDir} = {
|
||||
directory = cfg.statePath;
|
||||
owner = config.registry."stable-uid".${config.networking.hostName}.copyparty;
|
||||
group = "storage";
|
||||
mode = "0770";
|
||||
};
|
||||
${"/run/secrets/${cfg.passwordSecret}"} = {
|
||||
file = config.sops.secrets.${cfg.passwordSecret}.path;
|
||||
readOnly = true;
|
||||
};
|
||||
}
|
||||
// lib.mapAttrs' (_urlPath: volume: lib.nameValuePair volume.path { directory = volume.hostPath; }) cfg.volumes;
|
||||
limits = {
|
||||
memory = lib.mkDefault "2G";
|
||||
cpu = lib.mkDefault "200%";
|
||||
tasksMax = lib.mkDefault 512;
|
||||
};
|
||||
};
|
||||
|
||||
sops.secrets.${cfg.passwordSecret} = {
|
||||
owner = "root";
|
||||
group = "storage";
|
||||
mode = "0440";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
giteaSshBackend = "10.23.20.45:2022";
|
||||
in
|
||||
{
|
||||
config = lib.mkIf config.guests.reverse-proxy.enable {
|
||||
guests.reverse-proxy = {
|
||||
interior = {
|
||||
modules.services.reverse-proxy.enable = true;
|
||||
|
||||
services.nginx.virtualHosts = {
|
||||
"games.alexion.dev" = {
|
||||
forceSSL = true;
|
||||
enableACME = true;
|
||||
locations."/".proxyPass = "http://10.23.20.32";
|
||||
};
|
||||
"git.alexion.dev" = {
|
||||
forceSSL = true;
|
||||
enableACME = true;
|
||||
locations."/".proxyPass = "http://10.23.20.32";
|
||||
};
|
||||
"music.alexion.dev" = {
|
||||
forceSSL = true;
|
||||
enableACME = true;
|
||||
locations."/".proxyPass = "http://10.23.20.32";
|
||||
};
|
||||
"photos.alexion.dev" = {
|
||||
forceSSL = true;
|
||||
enableACME = true;
|
||||
locations."/".proxyPass = "http://10.23.20.32";
|
||||
};
|
||||
"plex.alexion.dev" = {
|
||||
forceSSL = true;
|
||||
enableACME = true;
|
||||
locations."/".proxyPass = "http://10.23.20.32";
|
||||
};
|
||||
"servers.alexion.dev" = {
|
||||
forceSSL = true;
|
||||
enableACME = true;
|
||||
locations."/".proxyPass = "http://10.23.20.32";
|
||||
};
|
||||
"todo.alexion.dev" = {
|
||||
forceSSL = true;
|
||||
enableACME = true;
|
||||
locations."/".proxyPass = "http://10.23.20.32";
|
||||
};
|
||||
"wings.alexion.dev" = {
|
||||
forceSSL = true;
|
||||
enableACME = true;
|
||||
locations."/".proxyPass = "http://10.23.20.32";
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 2022 ];
|
||||
|
||||
services.nginx.streamConfig = ''
|
||||
server {
|
||||
listen 2022;
|
||||
proxy_pass ${giteaSshBackend};
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
limits = {
|
||||
memory = lib.mkDefault "1G";
|
||||
cpu = lib.mkDefault "100%";
|
||||
tasksMax = lib.mkDefault 256;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
{
|
||||
inputs,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{ pkgs, ... }:
|
||||
# neogaia — Dell XPS 13 9380 laptop.
|
||||
# Disk layout is in ./disk.nix.
|
||||
# `fileSystems` are derived from it, none declared here.
|
||||
# Disk layout is in ./disk.nix; `fileSystems` are derived from it, none declared here.
|
||||
{
|
||||
imports = [
|
||||
inputs.nixos-hardware.nixosModules.dell-xps-13-9380
|
||||
./hardware-configuration.nix
|
||||
./disk.nix
|
||||
];
|
||||
@@ -21,33 +15,29 @@
|
||||
|
||||
boot.kernelPackages = pkgs.linuxPackages_cachyos;
|
||||
|
||||
hardware.cpu.intel.updateMicrocode = true;
|
||||
|
||||
# Redistributable firmware for the QCA6174 wifi (ath10k blobs).
|
||||
# Intel microcode updates follow from this, so none is declared here.
|
||||
hardware.enableRedistributableFirmware = true;
|
||||
|
||||
# RAM-backed swap, no on-disk swap partition.
|
||||
# RAM-backed swap; no on-disk swap partition.
|
||||
zramSwap.enable = true;
|
||||
|
||||
# So wifi can be joined from the console.
|
||||
networking.networkmanager.enable = true;
|
||||
|
||||
registry.address.neogaia.self = "10.23.50.146";
|
||||
|
||||
# So setup can be driven over the network.
|
||||
# The matching host public keys sit beside this file in plaintext, since
|
||||
# publishing them is their purpose.
|
||||
modules.network.ssh.enable = true;
|
||||
modules.network.ssh.hostKeys.sopsFile = ../../secrets/neogaia.yaml;
|
||||
modules.network.ssh.userKey.sopsFile = ../../secrets/neogaia.yaml;
|
||||
services.openssh.enable = true;
|
||||
|
||||
modules.shell.herdr.enable = true;
|
||||
modules.agents.tools.gitea-axi.enable = true;
|
||||
modules.agents.pi.enable = true;
|
||||
# fish as the login shell.
|
||||
modules.fish.enable = true;
|
||||
modules.fish.defaultShell = true;
|
||||
|
||||
modules.desktop.enable = true;
|
||||
modules.desktop.apps.obsidian.enable = true;
|
||||
modules.desktop.apps.steam.enable = true;
|
||||
modules.tmux.enable = true;
|
||||
modules.nvim.enable = true;
|
||||
modules.claude-code.enable = true;
|
||||
|
||||
time.timeZone = "America/New_York";
|
||||
i18n.defaultLocale = "en_GB.UTF-8";
|
||||
console.keyMap = "us";
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{ ... }:
|
||||
# neogaia's disk layout for disko: one NVMe disk, GPT, with an EFI system
|
||||
# partition and a LUKS container holding btrfs subvolumes.
|
||||
# No swap partition, since swap is zram.
|
||||
# disko derives `fileSystems` and `boot.initrd.luks.devices` from this.
|
||||
# partition and a LUKS container holding btrfs subvolumes. No swap partition;
|
||||
# swap is zram. disko derives `fileSystems` and `boot.initrd.luks.devices` from this.
|
||||
{
|
||||
disko.devices.disk.main = {
|
||||
type = "disk";
|
||||
@@ -11,9 +10,7 @@
|
||||
type = "gpt";
|
||||
partitions = {
|
||||
ESP = {
|
||||
# Each generation stores a kernel and initrd here and the CachyOS kernel is large.
|
||||
# An exhausted partition fails bootloader installs.
|
||||
size = "2G";
|
||||
size = "512M";
|
||||
type = "EF00";
|
||||
content = {
|
||||
type = "filesystem";
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
{ lib, modulesPath, ... }:
|
||||
# Hardware detected by nixos-generate-config on this machine.
|
||||
# disko derives `fileSystems` and the LUKS device, none declared here.
|
||||
# Placeholder: regenerate with nixos-generate-config on the target machine.
|
||||
{
|
||||
imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];
|
||||
|
||||
boot.initrd.availableKernelModules = [
|
||||
"xhci_pci"
|
||||
"thunderbolt"
|
||||
"nvme"
|
||||
"rtsx_pci_sdmmc"
|
||||
"usb_storage"
|
||||
"sd_mod"
|
||||
];
|
||||
boot.initrd.kernelModules = [ ];
|
||||
boot.kernelModules = [ "kvm-intel" ];
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJS+wp7K123+4BT6G4f954R6WyrbWveY7VlpoBUf6I5p neogaia
|
||||
@@ -1 +0,0 @@
|
||||
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCo2wWUKxyAS4J5TqbWf8glDhJvS5XmdRqFhMeJwG3pOB+4AccZ1T8LU7ZN+RjtRi3j2qXBJvIHuzhtQNtmT59TxocvfobYiqOgJpvVO5K6yD8ZoUJs6ziDkIduI9w9mdRIESoi+dBbVu8n24r61cKDVh+jWX+yjzkOcWcOzqDyQhhkjqblZ1WMAdujEMuEPvif1i2LCxStUaZqRGcx09m/ME2fYcaJrpuxxxvX2+CPJNicoo6Rx9i7ZjAoNuvH+jui4KT62DzlQtQtCl2CFUOM0gCPSa+MbNQ9elfHPvGzEcwOIMo2cuy9KURUkQu+sAgaG8S1PEniDDTecskHtuRdmPZawnQGpIhzo919Q6wUgjT8scK4mmSXRWmGmkMt0GNA2tfj5tDks6r5Q8XsYqtWs4rsOEvfmxVSdM771w+fqDBAil99Jsh0ksPK9+Bwgg8cMDzLLFDn8JA5y2G1HocMMom+u5DYKwPXEKnCILkasB8y24+O3PhSu1EuWw277w6EUEXvU03rCf0Ak/ULjxp9a00EGlloEwSmFI7Aub9XHDr87IdbGInEn+PMqyBYADiN+3h6nE2JO+nMa6i/CHdebmT+T7YJvuTKHD9sjFmQsYaghlq03DZrhHcm4hgUvE1dqGojHrhk/WgA3EWTWtK/+BP0Vy2jXaaz+qAx+EGnhQ== neogaia
|
||||
@@ -1,149 +0,0 @@
|
||||
{
|
||||
config,
|
||||
pkgs,
|
||||
registry,
|
||||
...
|
||||
}:
|
||||
# pikachu — AZW ME Pro server.
|
||||
# Disk layout is in ./disk.nix.
|
||||
# `fileSystems` for the root disk are derived from it.
|
||||
{
|
||||
imports = [
|
||||
./hardware-configuration.nix
|
||||
./disk.nix
|
||||
];
|
||||
|
||||
system.stateVersion = "26.05";
|
||||
|
||||
boot.loader.systemd-boot.enable = true;
|
||||
boot.loader.efi.canTouchEfiVariables = true;
|
||||
|
||||
hardware.cpu.intel.updateMicrocode = true;
|
||||
hardware.enableRedistributableFirmware = true;
|
||||
|
||||
zramSwap.enable = true;
|
||||
|
||||
systemd.network = {
|
||||
enable = true;
|
||||
netdevs = {
|
||||
"40-br-vlan20".netdevConfig = {
|
||||
Name = "br-vlan20";
|
||||
Kind = "bridge";
|
||||
};
|
||||
"40-vlan20" = {
|
||||
netdevConfig = {
|
||||
Name = "vlan20";
|
||||
Kind = "vlan";
|
||||
};
|
||||
vlanConfig.Id = 20;
|
||||
};
|
||||
};
|
||||
|
||||
networks = {
|
||||
"10-uplink" = {
|
||||
matchConfig = {
|
||||
Name = "enp2s0";
|
||||
MACAddress = "78:55:36:07:af:49";
|
||||
};
|
||||
networkConfig.DHCP = "yes";
|
||||
vlan = [ "vlan20" ];
|
||||
linkConfig.RequiredForOnline = "routable";
|
||||
};
|
||||
"40-vlan20" = {
|
||||
matchConfig.Name = "vlan20";
|
||||
networkConfig.Bridge = "br-vlan20";
|
||||
linkConfig.RequiredForOnline = "no";
|
||||
};
|
||||
"40-br-vlan20" = {
|
||||
matchConfig.Name = "br-vlan20";
|
||||
linkConfig.RequiredForOnline = "no";
|
||||
};
|
||||
};
|
||||
};
|
||||
networking.useDHCP = false;
|
||||
|
||||
modules.network.vlans.ids = [ 20 ];
|
||||
|
||||
boot.zfs.forceImportRoot = false;
|
||||
|
||||
modules.zfs = {
|
||||
enable = true;
|
||||
hostId = "2346edbd";
|
||||
pools.pikachu = { };
|
||||
};
|
||||
|
||||
registry.address.pikachu.self = "10.23.10.102";
|
||||
|
||||
modules.network.ssh.enable = true;
|
||||
modules.network.ssh.hostKeys.sopsFile = ../../secrets/pikachu.yaml;
|
||||
modules.network.ssh.userKey.sopsFile = ../../secrets/pikachu.yaml;
|
||||
|
||||
guests.actualbudget = {
|
||||
enable = true;
|
||||
vlan = 20;
|
||||
mac = "BC:24:11:C1:CD:28";
|
||||
dataPath = "/pikachu/data/actualbudget";
|
||||
address = "10.23.20.42";
|
||||
reverseProxy = {
|
||||
enable = true;
|
||||
route = {
|
||||
domain = "budget.alexion.dev";
|
||||
port = 5006;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
guests.reverse-proxy = {
|
||||
enable = true;
|
||||
vlan = 20;
|
||||
mac = "02:98:64:5a:07:4f";
|
||||
address = "10.23.20.109";
|
||||
};
|
||||
|
||||
guests.copyparty = {
|
||||
enable = true;
|
||||
vlan = 20;
|
||||
mac = "02:23:20:39:23:01";
|
||||
statePath = "/pikachu/data/copyparty";
|
||||
address = "10.23.20.126";
|
||||
reverseProxy = {
|
||||
enable = true;
|
||||
route = {
|
||||
domain = "files.alexion.dev";
|
||||
port = 3923;
|
||||
};
|
||||
trustedSources = [ "${registry.address.${config.networking.hostName}."reverse-proxy"}/32" ];
|
||||
};
|
||||
volumes = {
|
||||
"/inbox" = {
|
||||
hostPath = "/pikachu/__inbox";
|
||||
path = "/srv/inbox";
|
||||
access.A = [ "alexion" ];
|
||||
flags = [
|
||||
"d2d"
|
||||
"d2t"
|
||||
"dthumb"
|
||||
];
|
||||
};
|
||||
"/media" = {
|
||||
hostPath = "/pikachu/media";
|
||||
path = "/srv/media";
|
||||
access.A = [ "alexion" ];
|
||||
flags = [
|
||||
"d2d"
|
||||
"d2t"
|
||||
"dthumb"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
pciutils
|
||||
smartmontools
|
||||
usbutils
|
||||
];
|
||||
|
||||
time.timeZone = "America/New_York";
|
||||
i18n.defaultLocale = "en_GB.UTF-8";
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{ ... }:
|
||||
# pikachu's install layout for disko: one NVMe boot disk with an EFI system partition and ext4 root.
|
||||
# The existing 8 TB ZFS mirror is imported by name and is never declared here.
|
||||
{
|
||||
disko.devices.disk.main = {
|
||||
type = "disk";
|
||||
device = "/dev/nvme0n1";
|
||||
content = {
|
||||
type = "gpt";
|
||||
partitions = {
|
||||
ESP = {
|
||||
size = "2G";
|
||||
type = "EF00";
|
||||
content = {
|
||||
type = "filesystem";
|
||||
format = "vfat";
|
||||
mountpoint = "/boot";
|
||||
mountOptions = [ "umask=0077" ];
|
||||
};
|
||||
};
|
||||
root = {
|
||||
size = "100%";
|
||||
content = {
|
||||
type = "filesystem";
|
||||
format = "ext4";
|
||||
mountpoint = "/";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{ lib, modulesPath, ... }:
|
||||
# Hardware detected from the Proxmox inventory for this machine.
|
||||
# disko derives the root disk filesystems, none declared here.
|
||||
{
|
||||
imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];
|
||||
|
||||
boot.initrd.availableKernelModules = [
|
||||
"ahci"
|
||||
"nvme"
|
||||
"sd_mod"
|
||||
"xhci_pci"
|
||||
];
|
||||
boot.initrd.kernelModules = [ ];
|
||||
boot.kernelModules = [ "kvm-intel" ];
|
||||
boot.extraModulePackages = [ ];
|
||||
|
||||
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKljRf4pJO+pqEqjpPz08gOYq3g1PpxvE66xVw7uMEnA root@pikachu
|
||||
@@ -1 +0,0 @@
|
||||
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCy/riwm7dflA3mT+3a0/2CIoS2LbAsK/vn35kOoNeuzn0yhiF+imexP6tkB3S2t+H5ybRzkbbuNZcynFfeCqthFc8kvbdCnt8Diqoeg96fZ6ecvh5QE5yH9op8534EySetZ/exakFLnF+6EiWMuWUW3DFwsc2kcgDJObqSE8gTx/d7JK953MiTFmSJBFyg1RtQ3ZnMT+iCrvY2dyCLQai7VeF8koVKF2c0leAq2Hc75rb/L9md8MoJa64iPiz7hwTCin3xoFyaY/5hNVvyqFd5PivgR69gLdJkuVsUYO2mJzhur8cYmJD+pGjJ0U45hyE9TMrCFjeJHHuvSt3+2kph62wv95jLNk0WmMlwgyunISxENCSVVtNYdBMXhUh8VhEAW17QpVUg9EnPvxOdTKEjrvfOZYASWUa51JKbgBgexVgFbxdjDZR88DZa31AVBts/cx/59gXTUahFXMYLdZgssx+5uibZQWnvCyfUV9WLbfmK1lgL6hzReg1VkQ87iGr6skjtQYemJxRaFNA1+Q5f3kmG3KncuK/594a3qXYP4gC6A2blf8om1YZ4aXXh6f+GFKLjoEw1vvM2rJ+rjzfymwDX+pxVQ9L13OEtVZc9Ez76pOkbm1hqdbL0gY45+0cpxodhWV0wMQJBDXL1MHP8qcs+/vw0GxVK5l1SnWBGlw== root@pikachu
|
||||
@@ -1,9 +1,75 @@
|
||||
{ lib }:
|
||||
{
|
||||
lib,
|
||||
inputs,
|
||||
self,
|
||||
}:
|
||||
let
|
||||
files = import ./files.nix { inherit lib; };
|
||||
network = import ./network.nix { inherit lib; };
|
||||
inherit (lib)
|
||||
attrNames
|
||||
filterAttrs
|
||||
genAttrs
|
||||
flatten
|
||||
hasSuffix
|
||||
mapAttrsToList
|
||||
;
|
||||
|
||||
# Recursively collect every `.nix` file under `dir` as a flat list, for a
|
||||
# module's `imports`.
|
||||
collectNixFiles =
|
||||
dir:
|
||||
flatten (
|
||||
mapAttrsToList (
|
||||
name: type:
|
||||
let
|
||||
path = dir + "/${name}";
|
||||
in
|
||||
if type == "directory" then
|
||||
collectNixFiles path
|
||||
else if type == "regular" && hasSuffix ".nix" name then
|
||||
[ path ]
|
||||
else
|
||||
[ ]
|
||||
) (builtins.readDir dir)
|
||||
);
|
||||
|
||||
# Build one host: every module is imported unconditionally (inert until its
|
||||
# `enable` flag is set), alongside home-manager, chaotic, the shared base, and
|
||||
# the host's own directory.
|
||||
mkHost =
|
||||
{
|
||||
hostName,
|
||||
system ? "x86_64-linux",
|
||||
}:
|
||||
inputs.nixpkgs.lib.nixosSystem {
|
||||
inherit system;
|
||||
specialArgs = {
|
||||
inherit inputs;
|
||||
my = self.lib;
|
||||
};
|
||||
modules =
|
||||
(collectNixFiles (self + "/modules"))
|
||||
++ [
|
||||
inputs.home-manager.nixosModules.home-manager
|
||||
inputs.chaotic.nixosModules.default
|
||||
inputs.disko.nixosModules.disko
|
||||
(self + "/system")
|
||||
(self + "/hosts/${hostName}")
|
||||
{ networking.hostName = hostName; }
|
||||
];
|
||||
};
|
||||
|
||||
# Discover every host (a subdirectory of `hostsDir`) and build each one.
|
||||
mkHosts =
|
||||
hostsDir:
|
||||
let
|
||||
hostNames = attrNames (filterAttrs (_name: type: type == "directory") (builtins.readDir hostsDir));
|
||||
in
|
||||
genAttrs hostNames (hostName: mkHost { inherit hostName; });
|
||||
in
|
||||
{
|
||||
inherit (files) collectNixFiles;
|
||||
inherit (network) bridgeName deriveMac;
|
||||
inherit
|
||||
collectNixFiles
|
||||
mkHost
|
||||
mkHosts
|
||||
;
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
{ lib }:
|
||||
let
|
||||
collectNixFiles =
|
||||
dir:
|
||||
lib.flatten (
|
||||
lib.mapAttrsToList (
|
||||
name: type:
|
||||
let
|
||||
path = dir + "/${name}";
|
||||
in
|
||||
if type == "directory" then
|
||||
collectNixFiles path
|
||||
else if type == "regular" && lib.hasSuffix ".nix" name then
|
||||
[ path ]
|
||||
else
|
||||
[ ]
|
||||
) (builtins.readDir dir)
|
||||
);
|
||||
in
|
||||
{
|
||||
inherit collectNixFiles;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{ lib }:
|
||||
{
|
||||
bridgeName = id: "br-vlan${toString id}";
|
||||
|
||||
deriveMac =
|
||||
name:
|
||||
let
|
||||
hash = builtins.hashString "sha256" name;
|
||||
octet = i: builtins.substring (i * 2) 2 hash;
|
||||
in
|
||||
lib.concatStringsSep ":" ([ "02" ] ++ map octet [ 0 1 2 3 4 ]);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# Claude Code for the primary user, configured through home-manager, which ships
|
||||
# the package and manages ~/.claude.
|
||||
# Login credentials are left unmanaged so they survive rebuilds.
|
||||
let
|
||||
cfg = config.modules.agents.claude-code;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.agents.claude-code.enable = lib.mkEnableOption ''
|
||||
Claude Code, Anthropic's CLI, configured via home-manager.
|
||||
|
||||
Enabling this also widens sudo's credential cache, keying it per user rather
|
||||
than per terminal and holding it for 60 minutes, so that a single
|
||||
authentication covers commands the agent issues. No command is made
|
||||
passwordless, but any process running as the primary user can spend the
|
||||
cached credential while it lasts. Suitable for a single-user machine'';
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# Key the credential cache per user rather than per terminal, so one
|
||||
# authentication covers the agent's terminal-less commands.
|
||||
security.sudo.extraConfig = ''
|
||||
Defaults timestamp_type=global
|
||||
Defaults timestamp_timeout=60
|
||||
'';
|
||||
|
||||
home-manager.users.${user} = {
|
||||
# jq parses the tool input handed to the sudo guard hook.
|
||||
home.packages = [ pkgs.jq ];
|
||||
|
||||
programs.claude-code = {
|
||||
enable = true;
|
||||
|
||||
# One directory per skill, symlinked under ~/.claude/skills.
|
||||
skills = ./skills;
|
||||
|
||||
# Installed under ~/.claude/hooks, referenced by the settings below.
|
||||
hooks."agent-sudo-guard.sh" = builtins.readFile ./hooks/agent-sudo-guard.sh;
|
||||
|
||||
settings = {
|
||||
model = "opus";
|
||||
hooks = {
|
||||
PreToolUse = [
|
||||
{
|
||||
matcher = "Bash";
|
||||
hooks = [
|
||||
{
|
||||
type = "command";
|
||||
command = "~/.claude/hooks/agent-sudo-guard.sh";
|
||||
timeout = 10;
|
||||
}
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Refuse a privileged command while sudo's credential cache is cold, naming the
|
||||
# command that warms it.
|
||||
#
|
||||
# Commands arrive here from subprocesses holding no terminal, so an uncached
|
||||
# sudo fails with a bare non-zero exit and no output, reading as an unexplained stall.
|
||||
# The probe below reads a cache keyed per user rather than per terminal,
|
||||
# so an authentication made in the operator's own terminal counts.
|
||||
|
||||
input=$(cat)
|
||||
command=$(printf '%s' "$input" | jq -r '.tool_input.command // ""')
|
||||
|
||||
# Anchored to a command position so a `sudo` appearing as an argument or inside
|
||||
# a string does not trip the guard.
|
||||
if ! printf '%s' "$command" | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*sudo([[:space:]]|$)'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if sudo -n true 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Exit 2 blocks the call and feeds stderr back to the agent.
|
||||
echo 'Blocked: sudo has no cached credential, and this command cannot answer a password prompt.
|
||||
Ask the operator to run `sudo -v` in their own terminal, then retry.
|
||||
Never attempt to supply a password directly.
|
||||
If this still blocks immediately after the operator runs `sudo -v`, the cache is
|
||||
not the cause: check that this hook can reach sudo at all.' >&2
|
||||
exit 2
|
||||
@@ -1,85 +0,0 @@
|
||||
# Alexion's Agent Instructions
|
||||
|
||||
These are common instructions for Alexion's agents across all scenarios.
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- When writing commit messages, NEVER auto-add your agent name as co-author.
|
||||
Omit the `Co-Authored-By:` trailer entirely, with no exceptions.
|
||||
This overrides any default instruction to append one.
|
||||
- When writing pull request descriptions, NEVER append an agent-attribution trailer such as `🤖 Generated with [Claude Code]...`.
|
||||
Leave it out entirely, with no exceptions.
|
||||
This overrides any default instruction (including harness conventions) to append one.
|
||||
- NEVER ask the user a question using the `AskUserQuestion` tool.
|
||||
Ask in plain prose, in your own message, instead, with no exceptions.
|
||||
This overrides any default instruction (including harness conventions and skill instructions) to use it.
|
||||
- Never manually modify `CHANGELOG.md` files or files marked as auto-generated.
|
||||
Detect auto-generated files with a layered check.
|
||||
Trust an explicit in-file marker first, for example `AUTO-GENERATED, DO NOT EDIT`.
|
||||
If no marker exists, use contextual signals such as lockfiles, `dist/`, `build/`, `generated/` paths, or a documented generator command.
|
||||
If the answer stays ambiguous, ask before editing.
|
||||
- In authored prose, do not put multiple sentences on one physical line.
|
||||
This applies to comments, documentation, user-facing descriptions, commit prose, pull-request prose, and forge comments.
|
||||
A sentence can wrap across multiple physical lines when needed.
|
||||
- Keep Markdown formatting changes local.
|
||||
Only format prose you write or change.
|
||||
Never reflow an entire pre-existing paragraph or file because you touched nearby text.
|
||||
- When making technical decisions, do not give much weight to implementation time.
|
||||
Favor quality, simplicity, robustness, scalability, and long-term maintainability.
|
||||
Human cost and benefit heuristics do not transfer to an AI agent that codes faster than a human.
|
||||
Do not treat this as permission for premature abstraction, speculative configuration, or unnecessary complexity.
|
||||
Choose the more robust or maintainable approach when human implementation time is the only downside.
|
||||
- Use lowercase file names unless a valid reason requires uppercase.
|
||||
Established ecosystem or tool conventions count as valid reasons.
|
||||
Examples include `README.md`, `LICENSE`, `CHANGELOG.md`, `Makefile`, `Dockerfile`, and `.github/` files.
|
||||
- Do not end a response by promising or implying continuation unless the continuation is present in that same response.
|
||||
Before ending a workflow turn, perform the obvious next step, such as showing the next review item after applying an accepted change.
|
||||
If the workflow is paused, say that plainly instead of using a dangling transition like "continuing" or "next".
|
||||
- When you discover that your belief about a project fact or convention was wrong, record the correction.
|
||||
Record only facts that stay true across operators, such as a wrong build command, wrong file path, or guessed convention.
|
||||
Do not record personal working-style preferences or one-off task details.
|
||||
Record the correction in the nearest project `AGENTS.md`, not this global file.
|
||||
Use a dedicated `## Gotchas` section, and create that section when the file lacks one.
|
||||
If no project `AGENTS.md` exists, ask before creating one.
|
||||
Append the correction immediately without asking when a project `AGENTS.md` exists.
|
||||
Briefly mention the edit in your response.
|
||||
If an existing entry is wrong or stale, correct or remove it the same way.
|
||||
|
||||
## Comments
|
||||
|
||||
- Write comments only where they earn their place.
|
||||
Keep comments concise.
|
||||
Assume the reader can read code.
|
||||
Explain why, not what.
|
||||
Explain what only when it is genuinely non-obvious.
|
||||
- Keep comments self-contained to their file.
|
||||
Write comments for a reader who sees only that file.
|
||||
Do not explain how another file or tool consumes a value.
|
||||
The only permitted cross-file mention is a bare pointer explaining why something is absent here.
|
||||
- Do not narrate history or future state in comments.
|
||||
Avoid phrases such as "used to be", "now moved here", and "will be".
|
||||
State the present reason a thing exists.
|
||||
- Do not justify a choice against alternatives in comments.
|
||||
State the positive reason the choice exists.
|
||||
Include present consequences when they matter.
|
||||
- Do not reference agent-facing state in comments.
|
||||
This includes `.agents/`, `.claude/`, `AGENTS.md`, and `CLAUDE.md`.
|
||||
- Do not use project-specific capitalized terms as glossary references in comments.
|
||||
Use plain lowercase nouns unless the code requires the exact term.
|
||||
- Add a file-top header only when the filename or path does not already explain the file.
|
||||
The header must be one concise purpose line.
|
||||
Do not write a feature inventory.
|
||||
- Write placeholders plainly.
|
||||
Include a present-tense action when one exists, such as `Placeholder: regenerate with <tool> on the target machine`.
|
||||
Do not write `placeholder for <missing feature>`.
|
||||
- Treat user-facing documentation strings as documentation, not comments.
|
||||
They can describe behavior more fully than code comments.
|
||||
They still must stay self-contained and avoid agent-state references.
|
||||
- Start each sentence of a comment on its own physical line.
|
||||
A comment sentence can wrap when needed.
|
||||
Prefer splitting long comment sentences before wrapping them.
|
||||
Do not break a line early at a comma or clause boundary to make it read as a unit.
|
||||
Do not use semicolons in comments or authored prose.
|
||||
Recast semicolon clauses as separate sentences.
|
||||
Only reformat comments you write or change.
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
# Shared global instructions for agent harnesses.
|
||||
let
|
||||
user = config.user.name;
|
||||
context = builtins.readFile ./AGENTS.md;
|
||||
in
|
||||
{
|
||||
config = lib.mkMerge [
|
||||
(lib.mkIf config.modules.agents.claude-code.enable {
|
||||
home-manager.users.${user}.programs.claude-code.context = context;
|
||||
})
|
||||
|
||||
(lib.mkIf config.modules.agents.pi.enable {
|
||||
home-manager.users.${user}.programs.pi-coding-agent.context = context;
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
||||
|
||||
type QuotaState =
|
||||
| { status: "idle" | "loading" }
|
||||
| { status: "ok"; detail: string; refreshedAt: number; weeklyRemaining?: number; shortRemaining?: number }
|
||||
| { status: "missing" | "error"; detail: string; refreshedAt?: number };
|
||||
|
||||
const CODEX_USAGE_ENDPOINTS = [
|
||||
"https://chatgpt.com/backend-api/wham/usage",
|
||||
"https://chatgpt.com/backend-api/codex/usage",
|
||||
];
|
||||
const QUOTA_REFRESH_MS = 5 * 60 * 1000;
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
let quotaState: QuotaState = { status: "idle" };
|
||||
let quotaRefreshPromise: Promise<void> | null = null;
|
||||
|
||||
function shortCwd(cwd: string): string {
|
||||
const home = process.env.HOME;
|
||||
if (home && cwd.startsWith(`${home}/`)) return `~/${basename(cwd)}`;
|
||||
return basename(cwd) || cwd;
|
||||
}
|
||||
|
||||
function gitBranch(cwd: string): string | null {
|
||||
try {
|
||||
const out = execFileSync("git", ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
return out || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function authPath(): string {
|
||||
return join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "auth.json");
|
||||
}
|
||||
|
||||
function readCodexCredentials(): { access: string; accountId?: string } | null {
|
||||
const file = authPath();
|
||||
if (!existsSync(file)) return null;
|
||||
try {
|
||||
const auth = JSON.parse(readFileSync(file, "utf8"));
|
||||
const credential = auth?.["openai-codex"];
|
||||
if (credential?.type !== "oauth" || typeof credential.access !== "string") return null;
|
||||
if (typeof credential.expires === "number" && credential.expires <= Date.now() + 30_000) return null;
|
||||
return {
|
||||
access: credential.access,
|
||||
accountId: typeof credential.accountId === "string" ? credential.accountId : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
function windowSeconds(raw: Record<string, unknown>): number | undefined {
|
||||
const seconds = numberValue(raw.limit_window_seconds ?? raw.windowSeconds);
|
||||
if (seconds !== undefined) return seconds;
|
||||
const mins = numberValue(raw.windowDurationMins ?? raw.window_duration_mins);
|
||||
return mins === undefined ? undefined : mins * 60;
|
||||
}
|
||||
|
||||
function usedPercent(raw: Record<string, unknown>): number | undefined {
|
||||
const value = numberValue(raw.used_percent ?? raw.usedPercent);
|
||||
if (value === undefined) return undefined;
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function collectWindows(raw: unknown, out: Array<{ seconds?: number; used: number; key: string }> = [], key = "root") {
|
||||
if (Array.isArray(raw)) {
|
||||
raw.forEach((item, index) => collectWindows(item, out, `${key}.${index}`));
|
||||
return out;
|
||||
}
|
||||
const obj = objectValue(raw);
|
||||
if (!obj) return out;
|
||||
const used = usedPercent(obj);
|
||||
if (used !== undefined) out.push({ seconds: windowSeconds(obj), used, key });
|
||||
for (const [childKey, value] of Object.entries(obj)) {
|
||||
if (value && typeof value === "object") collectWindows(value, out, `${key}.${childKey}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickQuotaWindows(raw: unknown): { weeklyRemaining?: number; shortRemaining?: number } | null {
|
||||
const windows = collectWindows(raw);
|
||||
if (windows.length === 0) return null;
|
||||
const weekly = windows.find((window) => window.seconds !== undefined && Math.abs(window.seconds - 604_800) <= 60 * 60)
|
||||
?? windows.find((window) => /week|weekly|secondary/i.test(window.key));
|
||||
const short = windows.find((window) => window.seconds !== undefined && Math.abs(window.seconds - 18_000) <= 60 * 30)
|
||||
?? windows.find((window) => /five|session|primary|short/i.test(window.key));
|
||||
return {
|
||||
weeklyRemaining: weekly ? Math.max(0, Math.min(100, 100 - weekly.used)) : undefined,
|
||||
shortRemaining: short ? Math.max(0, Math.min(100, 100 - short.used)) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function safeFg(theme: any, color: string, text: string): string {
|
||||
try {
|
||||
return theme.fg(color, text);
|
||||
} catch {
|
||||
return theme.fg("accent", text);
|
||||
}
|
||||
}
|
||||
|
||||
function contextColor(percent: number): string {
|
||||
if (percent >= 90) return "error";
|
||||
if (percent >= 70) return "warning";
|
||||
return "success";
|
||||
}
|
||||
|
||||
function quotaColor(percent: number): string {
|
||||
if (percent >= 80) return "error";
|
||||
if (percent >= 50) return "warning";
|
||||
return "border";
|
||||
}
|
||||
|
||||
function bar(theme: any, width: number, percent: number | null, glyph: string, colorForPercent: (percent: number) => string): string {
|
||||
const barWidth = Math.max(12, width);
|
||||
if (percent === null) return theme.fg("muted", glyph.repeat(barWidth));
|
||||
const clamped = Math.max(0, Math.min(100, percent));
|
||||
const filled = Math.max(0, Math.min(barWidth, Math.round((clamped / 100) * barWidth)));
|
||||
const empty = Math.max(0, barWidth - filled);
|
||||
return safeFg(theme, colorForPercent(clamped), glyph.repeat(filled)) + theme.fg("dim", glyph.repeat(empty));
|
||||
}
|
||||
|
||||
async function fetchCodexQuota(force = false): Promise<void> {
|
||||
const fresh = quotaState.status === "ok" && Date.now() - quotaState.refreshedAt < QUOTA_REFRESH_MS;
|
||||
if (!force && fresh) return;
|
||||
if (quotaRefreshPromise) return quotaRefreshPromise;
|
||||
|
||||
quotaState = { status: "loading" };
|
||||
quotaRefreshPromise = (async () => {
|
||||
const credentials = readCodexCredentials();
|
||||
if (!credentials) {
|
||||
quotaState = { status: "missing", detail: "OpenAI Codex OAuth credentials were not found or are expired" };
|
||||
return;
|
||||
}
|
||||
|
||||
let lastError = "quota unavailable";
|
||||
for (const endpoint of CODEX_USAGE_ENDPOINTS) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${credentials.access}` };
|
||||
if (credentials.accountId) headers["ChatGPT-Account-Id"] = credentials.accountId;
|
||||
const response = await fetch(endpoint, { headers, signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
lastError = `${response.status} ${response.statusText}`;
|
||||
continue;
|
||||
}
|
||||
const windows = pickQuotaWindows(await response.json());
|
||||
if (!windows || (windows.weeklyRemaining === undefined && windows.shortRemaining === undefined)) {
|
||||
lastError = "response had no recognized quota windows";
|
||||
continue;
|
||||
}
|
||||
const details = [];
|
||||
if (windows.weeklyRemaining !== undefined) details.push(`weekly ${Math.round(windows.weeklyRemaining)}%`);
|
||||
if (windows.shortRemaining !== undefined) details.push(`short ${Math.round(windows.shortRemaining)}%`);
|
||||
quotaState = {
|
||||
status: "ok",
|
||||
detail: `Codex quota remaining: ${details.join(", ")}`,
|
||||
weeklyRemaining: windows.weeklyRemaining,
|
||||
shortRemaining: windows.shortRemaining,
|
||||
refreshedAt: Date.now(),
|
||||
};
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
quotaState = { status: "error", detail: `Codex quota failed: ${lastError}`, refreshedAt: Date.now() };
|
||||
})().finally(() => {
|
||||
quotaRefreshPromise = null;
|
||||
});
|
||||
return quotaRefreshPromise;
|
||||
}
|
||||
|
||||
function statusLines(ctx: any, theme: any, width: number): string[] {
|
||||
const cwd = ctx.sessionManager?.getCwd?.() ?? ctx.cwd ?? process.cwd();
|
||||
const branch = gitBranch(cwd);
|
||||
const where = branch ? ` ${shortCwd(cwd)} ${branch}` : ` ${shortCwd(cwd)}`;
|
||||
const model = ctx.model?.id ?? process.env.PI_MODEL ?? "no-model";
|
||||
const thinking = ctx.thinkingLevel ?? process.env.PI_REASONING_LEVEL ?? "off";
|
||||
const left = theme.fg("accent", where);
|
||||
const right = theme.fg("dim", `${model} • ${thinking}`);
|
||||
const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
|
||||
const contextPercentRaw = ctx.getContextUsage?.()?.percent;
|
||||
const contextPercent = typeof contextPercentRaw === "number" && Number.isFinite(contextPercentRaw) ? contextPercentRaw : null;
|
||||
const quotaConsumed = quotaState.status === "ok" && quotaState.weeklyRemaining !== undefined
|
||||
? 100 - quotaState.weeklyRemaining
|
||||
: null;
|
||||
return [
|
||||
truncateToWidth(left + pad + right, width),
|
||||
bar(theme, width, contextPercent, "▃", contextColor),
|
||||
bar(theme, width, quotaConsumed, "▔", quotaColor),
|
||||
];
|
||||
}
|
||||
|
||||
function setCompactStatusUi(ctx: any) {
|
||||
if (!ctx.hasUI) return;
|
||||
ctx.ui.setWidget("compact-status", (_tui: any, theme: any) => ({
|
||||
invalidate() {},
|
||||
render(width: number) {
|
||||
return statusLines(ctx, theme, width);
|
||||
},
|
||||
}));
|
||||
ctx.ui.setFooter(() => ({ invalidate() {}, render: () => [] }));
|
||||
}
|
||||
|
||||
export default function compactStatus(pi: ExtensionAPI) {
|
||||
function refreshUi(ctx: any) {
|
||||
setCompactStatusUi(ctx);
|
||||
}
|
||||
|
||||
pi.on("session_start", (_event, ctx) => {
|
||||
refreshUi(ctx);
|
||||
void fetchCodexQuota(false).then(() => refreshUi(ctx));
|
||||
});
|
||||
pi.on("model_select", (_event, ctx) => refreshUi(ctx));
|
||||
pi.on("agent_settled", (_event, ctx) => refreshUi(ctx));
|
||||
|
||||
pi.registerCommand("codex-quota", {
|
||||
description: "Refresh and show ChatGPT Codex quota",
|
||||
handler: async (_args, ctx) => {
|
||||
refreshUi(ctx);
|
||||
await fetchCodexQuota(true);
|
||||
refreshUi(ctx);
|
||||
const level = quotaState.status === "ok" ? "info" : quotaState.status === "missing" ? "warning" : "error";
|
||||
ctx.ui.notify(quotaState.status === "idle" || quotaState.status === "loading" ? "Codex quota refresh in progress" : quotaState.detail, level);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts
|
||||
--- a/packages/tui/src/tui.ts 2026-08-02 00:16:00.000000000 -0400
|
||||
+++ b/packages/tui/src/tui.ts 2026-08-02 00:16:00.000000000 -0400
|
||||
@@ -310,7 +310,7 @@ export class TUI extends Container {
|
||||
private cursorRow = 0; // Logical cursor row (end of rendered content)
|
||||
private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
||||
private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
|
||||
- private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
|
||||
+ private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK !== "0"; // Clear empty rows when content shrinks (default: on)
|
||||
private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
|
||||
private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
|
||||
private fullRedrawCount = 0;
|
||||
|
||||
diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts
|
||||
--- a/packages/coding-agent/src/core/settings-manager.ts 2026-08-02 00:34:00.000000000 -0400
|
||||
+++ b/packages/coding-agent/src/core/settings-manager.ts 2026-08-02 00:34:00.000000000 -0400
|
||||
@@ -1093,11 +1093,11 @@ export class SettingsManager {
|
||||
}
|
||||
|
||||
getClearOnShrink(): boolean {
|
||||
- // Settings takes precedence, then env var, then default false
|
||||
+ // Settings takes precedence, then env var, then default true
|
||||
if (this.settings.terminal?.clearOnShrink !== undefined) {
|
||||
return this.settings.terminal.clearOnShrink;
|
||||
}
|
||||
- return process.env.PI_CLEAR_ON_SHRINK === "1";
|
||||
+ return process.env.PI_CLEAR_ON_SHRINK !== "0";
|
||||
}
|
||||
|
||||
setClearOnShrink(enabled: boolean): void {
|
||||
|
||||
diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
|
||||
--- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts 2026-08-01 18:41:36.963495957 -0400
|
||||
+++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts 2026-08-01 18:43:04.876341236 -0400
|
||||
@@ -210,6 +210,47 @@
|
||||
return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code);
|
||||
}
|
||||
|
||||
+class FlexSpacerBottomLayout implements Component {
|
||||
+ private readonly ui: TUI;
|
||||
+ private readonly flowChildren: Component[];
|
||||
+ private readonly pinnedChildren: Component[];
|
||||
+
|
||||
+ constructor(ui: TUI, flowChildren: Component[], pinnedChildren: Component[]) {
|
||||
+ this.ui = ui;
|
||||
+ this.flowChildren = flowChildren;
|
||||
+ this.pinnedChildren = pinnedChildren;
|
||||
+ }
|
||||
+
|
||||
+ invalidate(): void {
|
||||
+ for (const child of [...this.flowChildren, ...this.pinnedChildren]) {
|
||||
+ child.invalidate();
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ private renderGroup(children: Component[], width: number): string[] {
|
||||
+ const lines: string[] = [];
|
||||
+ for (const child of children) {
|
||||
+ for (const line of child.render(width)) {
|
||||
+ lines.push(line);
|
||||
+ }
|
||||
+ }
|
||||
+ return lines;
|
||||
+ }
|
||||
+
|
||||
+ render(width: number): string[] {
|
||||
+ const flowLines = this.renderGroup(this.flowChildren, width);
|
||||
+ const pinnedLines = this.renderGroup(this.pinnedChildren, width);
|
||||
+ const terminalRows = this.ui.terminal.rows;
|
||||
+ const spacerRows = Math.max(0, terminalRows - flowLines.length - pinnedLines.length);
|
||||
+
|
||||
+ return [
|
||||
+ ...flowLines,
|
||||
+ ...Array.from({ length: spacerRows }, () => ""),
|
||||
+ ...pinnedLines,
|
||||
+ ];
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING =
|
||||
"Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage. Disable this warning in /settings.";
|
||||
|
||||
@@ -335,6 +376,7 @@
|
||||
private fdPath: string | undefined;
|
||||
private editorContainer: Container;
|
||||
private footer: FooterComponent;
|
||||
+ private footerContainer: Container;
|
||||
private footerDataProvider: FooterDataProvider;
|
||||
// Stored so the same manager can be injected into custom editors, selectors, and extension UI.
|
||||
private keybindings: KeybindingsManager;
|
||||
@@ -477,7 +519,9 @@
|
||||
this.editorContainer = new Container();
|
||||
this.editorContainer.addChild(this.editor as Component);
|
||||
this.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd());
|
||||
+ this.footerContainer = new Container();
|
||||
this.footer = new FooterComponent(this.session, this.footerDataProvider);
|
||||
+ this.footerContainer.addChild(this.footer);
|
||||
this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
|
||||
|
||||
// Load hide thinking block setting
|
||||
@@ -704,19 +748,25 @@
|
||||
console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`));
|
||||
}
|
||||
|
||||
- // Add header container as first child. Populate it after applying theme settings.
|
||||
- // Keep loaded resources before chat so restored session messages never precede them.
|
||||
- this.ui.addChild(this.headerContainer);
|
||||
- this.ui.addChild(this.loadedResourcesContainer);
|
||||
-
|
||||
- this.ui.addChild(this.chatContainer);
|
||||
- this.ui.addChild(this.pendingMessagesContainer);
|
||||
- this.ui.addChild(this.statusContainer);
|
||||
this.renderWidgets(); // Initialize with default spacer
|
||||
- this.ui.addChild(this.widgetContainerAbove);
|
||||
- this.ui.addChild(this.editorContainer);
|
||||
- this.ui.addChild(this.widgetContainerBelow);
|
||||
- this.ui.addChild(this.footer);
|
||||
+ this.ui.addChild(
|
||||
+ new FlexSpacerBottomLayout(
|
||||
+ this.ui,
|
||||
+ [
|
||||
+ this.headerContainer,
|
||||
+ this.loadedResourcesContainer,
|
||||
+ this.chatContainer,
|
||||
+ ],
|
||||
+ [
|
||||
+ this.pendingMessagesContainer,
|
||||
+ this.statusContainer,
|
||||
+ this.widgetContainerAbove,
|
||||
+ this.editorContainer,
|
||||
+ this.widgetContainerBelow,
|
||||
+ this.footerContainer,
|
||||
+ ],
|
||||
+ ),
|
||||
+ );
|
||||
this.ui.setFocus(this.editor);
|
||||
|
||||
this.setupKeyHandlers();
|
||||
@@ -2033,25 +2083,25 @@
|
||||
| ((tui: TUI, thm: Theme, footerData: ReadonlyFooterDataProvider) => Component & { dispose?(): void })
|
||||
| undefined,
|
||||
): void {
|
||||
- // Dispose existing custom footer
|
||||
+ // Dispose existing custom footer
|
||||
if (this.customFooter?.dispose) {
|
||||
this.customFooter.dispose();
|
||||
}
|
||||
|
||||
- // Remove current footer from UI
|
||||
+ // Remove current footer from its pinned layout slot.
|
||||
if (this.customFooter) {
|
||||
- this.ui.removeChild(this.customFooter);
|
||||
+ this.footerContainer.removeChild(this.customFooter);
|
||||
} else {
|
||||
- this.ui.removeChild(this.footer);
|
||||
+ this.footerContainer.removeChild(this.footer);
|
||||
}
|
||||
|
||||
if (factory) {
|
||||
// Create and add custom footer, passing the data provider
|
||||
this.customFooter = factory(this.ui, theme, this.footerDataProvider);
|
||||
- this.ui.addChild(this.customFooter);
|
||||
+ this.footerContainer.addChild(this.customFooter);
|
||||
} else {
|
||||
// Restore built-in footer
|
||||
this.customFooter = undefined;
|
||||
- this.ui.addChild(this.footer);
|
||||
+ this.footerContainer.addChild(this.footer);
|
||||
}
|
||||
|
||||
this.ui.requestRender();
|
||||
@@ -1,23 +0,0 @@
|
||||
diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts
|
||||
--- a/packages/coding-agent/src/utils/tools-manager.ts 2026-08-01 18:41:36.970496010 -0400
|
||||
+++ b/packages/coding-agent/src/utils/tools-manager.ts 2026-08-01 18:41:37.028186009 -0400
|
||||
@@ -74,8 +74,8 @@
|
||||
function commandExists(cmd: string): boolean {
|
||||
try {
|
||||
const result = spawnSync(cmd, ["--version"], { stdio: "pipe" });
|
||||
- // Check for ENOENT error (command not found)
|
||||
- return result.error === undefined || result.error === null;
|
||||
+ // Downloaded generic Linux binaries can exist but fail on NixOS before they print a version.
|
||||
+ return (result.error === undefined || result.error === null) && result.status === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -88,7 +87,7 @@
|
||||
|
||||
// Check our tools directory first
|
||||
const localPath = join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : ""));
|
||||
- if (existsSync(localPath)) {
|
||||
+ if (existsSync(localPath) && commandExists(localPath)) {
|
||||
return localPath;
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# Pi, a terminal coding agent, for the primary user, configured through
|
||||
# home-manager, which ships the package and manages ~/.pi/agent.
|
||||
# The login credential is left unmanaged, so it survives rebuilds.
|
||||
let
|
||||
cfg = config.modules.agents.pi;
|
||||
user = config.user.name;
|
||||
piDir = "${config.users.users.${user}.home}/.pi/agent";
|
||||
patchedPi = pkgs.pi-coding-agent.overrideAttrs (old: {
|
||||
patches = (old.patches or [ ]) ++ [
|
||||
./patches/pi-flex-spacer.patch
|
||||
./patches/pi-tool-lookup-validation.patch
|
||||
];
|
||||
});
|
||||
herdrPiIntegration = pkgs.stdenvNoCC.mkDerivation {
|
||||
name = "herdr-pi-integration";
|
||||
nativeBuildInputs = [ pkgs.herdr ];
|
||||
phases = [ "installPhase" ];
|
||||
installPhase = ''
|
||||
mkdir -p $TMPDIR/home/.pi/agent/extensions
|
||||
HOME=$TMPDIR/home herdr integration install pi
|
||||
mkdir -p $out
|
||||
cp $TMPDIR/home/.pi/agent/extensions/herdr-agent-state.ts $out/herdr-agent-state.ts
|
||||
'';
|
||||
};
|
||||
piExtensions = pkgs.stdenvNoCC.mkDerivation {
|
||||
name = "pi-extensions";
|
||||
phases = [ "installPhase" ];
|
||||
installPhase = ''
|
||||
mkdir -p $out
|
||||
cp -R ${./extensions}/. $out/
|
||||
cp ${herdrPiIntegration}/herdr-agent-state.ts $out/herdr-agent-state.ts
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
options.modules.agents.pi = {
|
||||
enable = lib.mkEnableOption ''
|
||||
Pi, a terminal coding agent, configured via home-manager'';
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user} = {
|
||||
programs.pi-coding-agent = {
|
||||
enable = true;
|
||||
package = patchedPi;
|
||||
|
||||
settings = {
|
||||
defaultProvider = "openai-codex";
|
||||
defaultModel = "gpt-5.5";
|
||||
defaultThinkingLevel = "medium";
|
||||
theme = "dark";
|
||||
enableInstallTelemetry = false;
|
||||
enableAnalytics = false;
|
||||
};
|
||||
};
|
||||
|
||||
home.file =
|
||||
{
|
||||
# The first declarative rollout replaces the interactive settings file.
|
||||
# Login state stays in auth.json, which this module does not manage.
|
||||
"${piDir}/settings.json".force = true;
|
||||
|
||||
"${piDir}/extensions" = {
|
||||
source = piExtensions;
|
||||
recursive = true;
|
||||
};
|
||||
|
||||
"${piDir}/prompts" = {
|
||||
source = ./prompts;
|
||||
recursive = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
config,
|
||||
inputs,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# Global agent skills, placed under the skills directory so they are active in
|
||||
# every project.
|
||||
let
|
||||
user = config.user.name;
|
||||
|
||||
# The skills installed globally, as derivations from the skills flake.
|
||||
skills = with inputs.skills.packages.${pkgs.stdenv.hostPlatform.system}; [ ];
|
||||
in
|
||||
{
|
||||
home-manager.sharedModules = [ inputs.skills.homeModules.default ];
|
||||
home-manager.users.${user}.programs.agents.skills = skills;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
# gitea-axi for the primary user, installed through its own home-manager module.
|
||||
let
|
||||
cfg = config.modules.agents.tools.gitea-axi;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.agents.tools.gitea-axi.enable =
|
||||
lib.mkEnableOption "gitea-axi, an agent-ergonomic CLI for Gitea issues and pull requests";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.sharedModules = [ inputs.gitea-axi.homeModules.default ];
|
||||
home-manager.users.${user}.programs.gitea-axi.enable = true;
|
||||
};
|
||||
}
|
||||
37
modules/claude-code/CLAUDE.md
Normal file
37
modules/claude-code/CLAUDE.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Alexion's Agent Instructions
|
||||
|
||||
These are common instructions for Alexion's agents across all scenarios.
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- When writing commit messages, NEVER auto-add your agent name as co-author.
|
||||
Omit the `Co-Authored-By:` trailer entirely, with no exceptions.
|
||||
This overrides any default instruction to append one.
|
||||
- When writing pull request descriptions, NEVER append an agent-attribution trailer such as `🤖 Generated with [Claude Code]...`.
|
||||
Leave it out entirely, with no exceptions.
|
||||
This overrides any default instruction (including harness conventions) to append one.
|
||||
- Never manually modify CHANGELOG.md files or any files that are marked as auto-generated.
|
||||
Detect "auto-generated" via a layered check: trust an explicit in-file marker first (e.g. `AUTO-GENERATED, DO NOT EDIT`).
|
||||
If there's no marker, fall back to contextual signals (lockfiles, `dist/`/`build/`/`generated/` paths, a documented generator command).
|
||||
If it's still ambiguous, ask before editing rather than guessing.
|
||||
- When writing or substantially editing long Markdown files, put each full sentence in its own line.
|
||||
Preserve normal Markdown structure, but avoid wrapping multiple sentences onto one physical line.
|
||||
Apply this to any prose you author, regardless of file length; "long" is not a real threshold.
|
||||
Only format what you're actually writing or changing.
|
||||
Never reflow an entire pre-existing paragraph or file just because you touched something nearby.
|
||||
- When making technical decisions, do not give much weight to development cost.
|
||||
Instead, prefer quality, simplicity, robustness, scalability and long term maintainability.
|
||||
This is specifically about implementation time.
|
||||
Human cost/benefit heuristics ("not worth N extra days of engineering") don't transfer to an AI agent that codes far faster than a human.
|
||||
This is not a license to override standard anti-overengineering guardrails (avoid premature abstraction, no speculative config, etc.); those still apply to unnecessary complexity.
|
||||
It means: don't discount a more robust or maintainable approach just because it would take a human a long time to build.
|
||||
- File names should always be lower case, unless there's a valid reason.
|
||||
Established ecosystem or tool conventions count as a valid reason automatically (e.g. `README.md`, `LICENSE`, `CHANGELOG.md`, `Makefile`, `Dockerfile`, `.github/` files), without needing to ask each time.
|
||||
- When you discover that a belief you held about an objective fact or convention of the current project was wrong, write it down so it isn't relearned next time.
|
||||
This applies whether the user corrected you or you caught the mistake yourself, and only to things that are true regardless of who is operating the project (a wrong build command, a wrong file path, a convention you guessed at instead of checking) — not personal working-style preferences or one-off task details.
|
||||
Record it in that project's own CLAUDE.md, not this global file, under a dedicated `## Gotchas` section (create the section if the file doesn't have one yet).
|
||||
If the project has nested CLAUDE.md files, use the one nearest to where the mistake occurred, falling back to the project's top-level CLAUDE.md.
|
||||
Append to an existing CLAUDE.md immediately, without asking; if no CLAUDE.md exists yet for the project, ask before creating one.
|
||||
Briefly mention the edit in your response rather than making it silently.
|
||||
If an existing entry is later found to be wrong or stale, correct or remove it the same way.
|
||||
|
||||
62
modules/claude-code/claude-code.nix
Normal file
62
modules/claude-code/claude-code.nix
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
# Claude Code for the primary user, configured through home-manager, which ships
|
||||
# the package and manages ~/.claude. Login credentials are left unmanaged so they
|
||||
# survive rebuilds.
|
||||
let
|
||||
cfg = config.modules.claude-code;
|
||||
user = config.user.name;
|
||||
|
||||
# Rings the terminal bell so tmux flags the background pane.
|
||||
bellHook = [
|
||||
{
|
||||
hooks = [
|
||||
{
|
||||
type = "command";
|
||||
command = "~/.claude/hooks/attention-bell.sh";
|
||||
}
|
||||
];
|
||||
}
|
||||
];
|
||||
in
|
||||
{
|
||||
options.modules.claude-code.enable = lib.mkEnableOption "Claude Code, Anthropic's CLI, configured via home-manager";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user}.programs.claude-code = {
|
||||
enable = true;
|
||||
|
||||
# Global agent instructions, rendered to ~/.claude/CLAUDE.md.
|
||||
context = ./CLAUDE.md;
|
||||
|
||||
# One directory per skill, symlinked under ~/.claude/skills.
|
||||
skills = ./skills;
|
||||
|
||||
# Installed at ~/.claude/hooks/attention-bell.sh, referenced by the settings below.
|
||||
hooks."attention-bell.sh" = builtins.readFile ./hooks/attention-bell.sh;
|
||||
|
||||
settings = {
|
||||
model = "opus";
|
||||
hooks = {
|
||||
Stop = bellHook;
|
||||
Notification = bellHook;
|
||||
SessionStart = [
|
||||
{
|
||||
matcher = "";
|
||||
hooks = [
|
||||
{
|
||||
type = "command";
|
||||
command = "gitea-axi";
|
||||
timeout = 10;
|
||||
}
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
21
modules/claude-code/hooks/attention-bell.sh
Executable file
21
modules/claude-code/hooks/attention-bell.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
# attention-bell.sh — ring the terminal bell in this Claude session's tmux pane
|
||||
# so tmux's monitor-bell flags the (background) window red in the status bar.
|
||||
#
|
||||
# Claude Code runs hooks as detached subprocesses: they have no controlling
|
||||
# terminal, so /dev/tty is unavailable here. But the parent-process chain up to
|
||||
# the `claude` process stays intact, and `claude` itself holds the pane's pty.
|
||||
# So we walk ancestry to find it and write the bell straight to that tty.
|
||||
# (Writing a bare BEL to an explicit /dev/pts/N works even from a detached
|
||||
# process — verified against tmux's window_bell_flag.)
|
||||
|
||||
pid=$PPID
|
||||
while [ "$pid" -gt 1 ] 2>/dev/null; do
|
||||
if [ "$(ps -o comm= -p "$pid" 2>/dev/null)" = claude ]; then
|
||||
tty=$(ps -o tty= -p "$pid" 2>/dev/null | tr -d ' ')
|
||||
[ -n "$tty" ] && [ "$tty" != '?' ] && printf '\a' > "/dev/$tty"
|
||||
exit 0
|
||||
fi
|
||||
pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')
|
||||
[ -z "$pid" ] && break
|
||||
done
|
||||
@@ -23,11 +23,11 @@ If the request describes a new workflow, capability, or repeated manual process
|
||||
- Is there already a word — in your prompts, docs, or codebase — that names this behavior? Reach for that **leading word** before coining one.
|
||||
Done when every axis above has an answer, or the user says to just draft something and iterate.
|
||||
|
||||
2. **Write the draft.** First decide where it lives: project-local `.claude/skills/` if the workflow is tied to this one repo, the personal set if it's general-purpose across projects. The personal set is not authored in `~/.claude/skills/` — that tree is generated, and every file under it is a read-only symlink into the Nix store. Write it in the dotfiles repo at `modules/claude-code/skills/<name>/` and rebuild to make it live. Creating files directly under `~/.claude/skills/` looks like it works, because the directories themselves are writable, but the result is untracked by the repo and reaches no other machine. Then follow the **information hierarchy**: steps for what the agent does in order, in-file **reference** for facts every branch needs, and disclose the rest behind a pointer — to a sibling file, or to the existing project docs identified in step 1 rather than restating them. Done when every branch from step 1 has somewhere to live, and no sentence fails the no-op test in isolation (see `No-Op` in GLOSSARY.md).
|
||||
2. **Write the draft.** First decide where it lives: project-local `.claude/skills/` if the workflow is tied to this one repo, `~/.claude/skills/` if it's general-purpose across projects. Then follow the **information hierarchy**: steps for what the agent does in order, in-file **reference** for facts every branch needs, and disclose the rest behind a pointer — to a sibling file, or to the existing project docs identified in step 1 rather than restating them. Done when every branch from step 1 has somewhere to live, and no sentence fails the no-op test in isolation (see `No-Op` in GLOSSARY.md).
|
||||
|
||||
## Audit an existing skill
|
||||
|
||||
1. **Locate it.** Check the current project's `.claude/skills/`, then `~/.claude/skills/`, then `~/.claude/skills/library/`, in that order; ask if the name is ambiguous across locations. A hit under `~/.claude/skills/` is a read-only symlink and cannot be edited in place: its source is the dotfiles repo, at `modules/claude-code/skills/<name>/` for a personal skill or `modules/claude-code/skills/library/<name>/` for a library one. Edit there and rebuild. If it's tracked in a project's `skills-lock.yaml`, mention that editing it here will make it read as locally-customized to `update-skills` — confirm that's actually intended rather than editing the library source.
|
||||
1. **Locate it.** Check the current project's `.claude/skills/`, then `~/.claude/skills/`, then `~/.claude/skills/library/`, in that order; ask if the name is ambiguous across locations. If it's tracked in a project's `skills-lock.yaml`, mention that editing it here will make it read as locally-customized to `update-skills` — confirm that's actually intended rather than editing the library source.
|
||||
|
||||
2. **Apply the checklist.** Read the skill and its disclosed files, then check each against GLOSSARY.md, quoting the offending line for anything that fails:
|
||||
- **Premature completion** — is each completion criterion checkable, and does it demand what the step actually needs?
|
||||
@@ -44,6 +44,6 @@ If the request describes a new workflow, capability, or repeated manual process
|
||||
1. Propose one realistic test prompt — reflecting the trigger phrasing gathered (draft) or the skill's existing purpose (audit) — and get it confirmed or adjusted before spending a run on it.
|
||||
2. Spawn one subagent: give it the skill's path and the confirmed prompt, have it attempt the task using the skill, and report back what happened — including anywhere it hesitated, misread the skill, or did something unexpected.
|
||||
3. Re-read the draft/rewrite against GLOSSARY.md's failure modes in light of that run, and fix whatever either pass turned up. If the fix is substantial, repeat from step 1; otherwise it's done.
|
||||
4. Stage the specific changed or created paths — one path per file, never a wildcard — with `git add <path>`. Do not commit; that's left to the user.
|
||||
4. Stage the specific changed or created paths — one path per file, never a wildcard — with the host project's own staging convention: plain `git add <path>` normally, or e.g. `dot add <path>` in this dotfiles setup (wrap as `fish -c "dot add <path>"` if the invoking shell isn't fish — `dot` is a fish function, not a binary on `$PATH`). Do not commit; that's left to the user.
|
||||
|
||||
Done when the subagent's run succeeded without confusion on the confirmed prompt, the checklist raised nothing outstanding, and every changed path is staged.
|
||||
47
modules/claude-code/skills/domain-modeling/ADR-FORMAT.md
Normal file
47
modules/claude-code/skills/domain-modeling/ADR-FORMAT.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# ADR Format
|
||||
|
||||
ADRs live in `.claude/adr/` and use sequential numbering: `0001-slug.md`, `0002-slug.md`, etc.
|
||||
|
||||
Create the `.claude/adr/` directory lazily — only when the first ADR is needed.
|
||||
|
||||
## Template
|
||||
|
||||
```md
|
||||
# {Short title of the decision}
|
||||
|
||||
{1-3 sentences: what's the context, what did we decide, and why.}
|
||||
```
|
||||
|
||||
That's it. An ADR can be a single paragraph. The value is in recording *that* a decision was made and *why* — not in filling out sections.
|
||||
|
||||
## Optional sections
|
||||
|
||||
Only include these when they add genuine value. Most ADRs won't need them.
|
||||
|
||||
- **Status** frontmatter (`proposed | accepted | deprecated | superseded by ADR-NNNN`) — useful when decisions are revisited
|
||||
- **Considered Options** — only when the rejected alternatives are worth remembering
|
||||
- **Consequences** — only when non-obvious downstream effects need to be called out
|
||||
|
||||
## Numbering
|
||||
|
||||
Scan `.claude/adr/` for the highest existing number and increment by one.
|
||||
|
||||
## When to offer an ADR
|
||||
|
||||
All three of these must be true:
|
||||
|
||||
1. **Hard to reverse** — the cost of changing your mind later is meaningful
|
||||
2. **Surprising without context** — a future reader will look at the code and wonder "why on earth did they do it this way?"
|
||||
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons
|
||||
|
||||
If a decision is easy to reverse, skip it — you'll just reverse it. If it's not surprising, nobody will wonder why. If there was no real alternative, there's nothing to record beyond "we did the obvious thing."
|
||||
|
||||
### What qualifies
|
||||
|
||||
- **Architectural shape.** "We're using a monorepo." "The write model is event-sourced, the read model is projected into Postgres."
|
||||
- **Integration patterns between contexts.** "Ordering and Billing communicate via domain events, not synchronous HTTP."
|
||||
- **Technology choices that carry lock-in.** Database, message bus, auth provider, deployment target. Not every library — just the ones that would take a quarter to swap out.
|
||||
- **Boundary and scope decisions.** "Customer data is owned by the Customer context; other contexts reference it by ID only." The explicit no-s are as valuable as the yes-s.
|
||||
- **Deliberate deviations from the obvious path.** "We're using manual SQL instead of an ORM because X." Anything where a reasonable reader would assume the opposite. These stop the next engineer from "fixing" something that was deliberate.
|
||||
- **Constraints not visible in the code.** "We can't use AWS because of compliance requirements." "Response times must be under 200ms because of the partner API contract."
|
||||
- **Rejected alternatives when the rejection is non-obvious.** If you considered GraphQL and picked REST for subtle reasons, record it — otherwise someone will suggest GraphQL again in six months.
|
||||
30
modules/claude-code/skills/domain-modeling/CONTEXT-FORMAT.md
Normal file
30
modules/claude-code/skills/domain-modeling/CONTEXT-FORMAT.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# CONTEXT.md Format
|
||||
|
||||
## Structure
|
||||
|
||||
```md
|
||||
# {Context Name}
|
||||
|
||||
{One or two sentence description of what this context is and why it exists.}
|
||||
|
||||
## Language
|
||||
|
||||
**Order**:
|
||||
{A one or two sentence description of the term}
|
||||
_Avoid_: Purchase, transaction
|
||||
|
||||
**Invoice**:
|
||||
A request for payment sent to a customer after delivery.
|
||||
_Avoid_: Bill, payment request
|
||||
|
||||
**Customer**:
|
||||
A person or organization that places orders.
|
||||
_Avoid_: Client, buyer, account
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Be opinionated.** When multiple words exist for the same concept, pick the best one and list the others under `_Avoid_`.
|
||||
- **Keep definitions tight.** One or two sentences max. Define what it IS, not what it does.
|
||||
- **Only include terms specific to this project's context.** General programming concepts (timeouts, error types, utility patterns) don't belong even if the project uses them extensively. Before adding a term, ask: is this a concept unique to this context, or a general programming concept? Only the former belongs.
|
||||
- **Group terms under subheadings** when natural clusters emerge. If all terms belong to a single cohesive area, a flat list is fine.
|
||||
56
modules/claude-code/skills/domain-modeling/SKILL.md
Normal file
56
modules/claude-code/skills/domain-modeling/SKILL.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: domain-modeling
|
||||
description: Build and sharpen a project's domain model. Use when the user wants to pin down domain terminology or a ubiquitous language, record an architectural decision, or when another skill needs to maintain the domain model.
|
||||
---
|
||||
|
||||
# Domain Modeling
|
||||
|
||||
Actively build and sharpen the project's domain model as you design. This is the *active* discipline — challenging terms, inventing edge-case scenarios, and writing the glossary and decisions down the moment they crystallise. (Merely *reading* `.claude/CONTEXT.md` for vocabulary is not this skill — that's a one-line habit any skill can do. This skill is for when you're changing the model, not just consuming it.)
|
||||
|
||||
## File structure
|
||||
|
||||
```
|
||||
/
|
||||
├── .claude/
|
||||
│ ├── CONTEXT.md
|
||||
│ └── adr/
|
||||
│ ├── 0001-event-sourced-orders.md
|
||||
│ └── 0002-postgres-for-write-model.md
|
||||
└── src/
|
||||
```
|
||||
|
||||
Create files lazily — only when you have something to write. If no `.claude/CONTEXT.md` exists, create it when the first term is resolved. If no `.claude/adr/` exists, create it when the first ADR is needed.
|
||||
|
||||
## During the session
|
||||
|
||||
### Challenge against the glossary
|
||||
|
||||
When the user uses a term that conflicts with the existing language in `.claude/CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y — which is it?"
|
||||
|
||||
### Sharpen fuzzy language
|
||||
|
||||
When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account' — do you mean the Customer or the User? Those are different things."
|
||||
|
||||
### Discuss concrete scenarios
|
||||
|
||||
When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts.
|
||||
|
||||
### Cross-reference with code
|
||||
|
||||
When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible — which is right?"
|
||||
|
||||
### Update .claude/CONTEXT.md inline
|
||||
|
||||
When a term is resolved, update `.claude/CONTEXT.md` right there. Don't batch these up — capture them as they happen. Use the format in [CONTEXT-FORMAT.md](./CONTEXT-FORMAT.md).
|
||||
|
||||
`.claude/CONTEXT.md` should be totally devoid of implementation details. Do not treat `.claude/CONTEXT.md` as a spec, a scratch pad, or a repository for implementation decisions. It is a glossary and nothing else.
|
||||
|
||||
### Offer ADRs sparingly
|
||||
|
||||
Only offer to create an ADR when all three are true:
|
||||
|
||||
1. **Hard to reverse** — the cost of changing your mind later is meaningful
|
||||
2. **Surprising without context** — a future reader will wonder "why did they do it this way?"
|
||||
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons
|
||||
|
||||
If any of the three is missing, skip the ADR. Use the format in [ADR-FORMAT.md](./ADR-FORMAT.md).
|
||||
48
modules/claude-code/skills/gitea-axi/SKILL.md
Normal file
48
modules/claude-code/skills/gitea-axi/SKILL.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: gitea-axi
|
||||
description: Use when working with a Gitea repository's issues, pull requests, labels, reviews, comments, or milestones — listing, viewing, creating, editing, commenting, reviewing, or merging on a Gitea host such as git.alexion.dev. Prefer this over the `tea` CLI, raw Gitea API calls, or improvised `git` commands for issue/PR/label work.
|
||||
---
|
||||
|
||||
# gitea-axi
|
||||
|
||||
`gitea-axi` is an agent-ergonomic CLI for a Gitea repository's issues and pull requests.
|
||||
Its output is compact TOON built for another program to read, and its errors are structured with actionable suggestions.
|
||||
|
||||
## When to use it
|
||||
|
||||
Reach for `gitea-axi` whenever a task touches a Gitea repository's issues, pull requests, labels, or reviews.
|
||||
|
||||
- **Over `tea`:** `gitea-axi` returns structured output and typed errors instead of human-formatted tables, and it defaults the repository and login from the local checkout.
|
||||
- **Over raw Gitea API calls:** it handles auth, pagination, name-to-ID resolution, and review-decision aggregation for you, so you do not hand-roll HTTP.
|
||||
- **Over improvised `git`:** for anything about issues or pull requests as entities (state, reviews, labels, comments) rather than local commits and branches.
|
||||
|
||||
## Targeting and authentication
|
||||
|
||||
Every command resolves two things: which repository to act on, and which credentials to authenticate with.
|
||||
Get both right on the first call — they are the usual reason a command fails and has to be retried.
|
||||
|
||||
- **Repository.** Inside a Gitea checkout it is taken from the `origin` remote automatically.
|
||||
Outside a checkout you must name it: pass `-R OWNER/NAME` on every command (or set `GITEA_AXI_REPO=OWNER/NAME` once for the session).
|
||||
- **Credentials.** When the environment is pre-configured — `GITEA_AXI_TOKEN` together with `GITEA_AXI_API_URL` — authentication is automatic and you need nothing more.
|
||||
Otherwise credentials come from a `tea` login: pass `--login <name>` (or set `GITEA_AXI_LOGIN=<name>`) unless the checkout's remote already selects one.
|
||||
|
||||
So outside a checkout with the token in the environment, `gitea-axi <command> -R OWNER/NAME …` is all you need; do not go hunting for a config file or a login profile.
|
||||
|
||||
## Command groups
|
||||
|
||||
- `issue` — list, view, create, comment on, edit, close/reopen, pin, and link issues.
|
||||
- `pr` — create, view, comment on, edit, review, merge, check out, diff, and inspect the checks of pull requests.
|
||||
- `label` — list, create, edit, and delete labels.
|
||||
- `search` — full-text search; it takes a subcommand, so search issues with `search issues "<query>"` and pull requests with `search prs "<query>"` (a bare `search "<query>"` is not valid).
|
||||
- `setup` — install this skill (`setup`) and, opt-in, the SessionStart dashboard hook (`setup hooks`).
|
||||
|
||||
To read one issue's fields, reach straight for `issue view <number>`: it shows labels and state by default, and takes `--fields assignees,milestone,…` for the rest.
|
||||
You rarely need `issue list` to answer a question about a single issue.
|
||||
|
||||
## Discovery
|
||||
|
||||
This skill is a pointer, not a command reference — the CLI is the single source of truth for its own interface.
|
||||
|
||||
- Run `gitea-axi` with no arguments for the repository dashboard (open issues and pull requests).
|
||||
Add `--full` for the open-PR table and issue counts by label.
|
||||
- Run `gitea-axi <command> --help` (or `gitea-axi <group> <command> --help`) for the exact flags of any command.
|
||||
20
modules/claude-code/skills/grill/SKILL.md
Normal file
20
modules/claude-code/skills/grill/SKILL.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: grill
|
||||
description: Interview the user relentlessly about a plan or design, capturing the resolved terms and decisions into the project's domain model as you go if one exists. Use when the user wants to stress-test a plan before building, or uses any 'grill' trigger phrase.
|
||||
---
|
||||
|
||||
Interview me relentlessly about every aspect of this plan or design. Walk down each branch of the design tree, resolving dependencies between decisions one by one, and give your recommended answer for each question. Keep going until every branch carries an explicit decision and no dependency between decisions is left open — not merely until it feels like "we understand each other."
|
||||
|
||||
Ask the questions one at a time, waiting for feedback on each before continuing. Asking several at once is bewildering.
|
||||
|
||||
If a question can be answered by exploring the codebase, explore the codebase instead of asking it.
|
||||
|
||||
**Never start implementation during or after the interview without an explicit instruction from the user.** This applies at every point — mid-interview and after the final question alike.
|
||||
|
||||
## Closing the interview
|
||||
|
||||
When every branch carries an explicit decision and no dependency is left open, produce a concise summary of all decisions reached, then stop and wait for the user's next instruction.
|
||||
|
||||
## Tracking the domain model as you go
|
||||
|
||||
If a `.claude/CONTEXT.md` file exists in the project, also run [`domain-modeling`](../domain-modeling/SKILL.md) alongside this interview: resolve each term into `.claude/CONTEXT.md` the moment it crystallizes, and offer an ADR using that skill's own criteria — hard to reverse, surprising without context, and the result of a real trade-off. If no `.claude/CONTEXT.md` exists, run the interview alone with no doc side effects.
|
||||
@@ -10,12 +10,6 @@ the current project's `.claude/skills/`, tracked in
|
||||
Only ever adds — checking already-installed skills for updates is
|
||||
[`update-skills`](../update-skills/SKILL.md)'s job, not this one's.
|
||||
|
||||
The library is a tree of read-only symlinks into the Nix store, so every
|
||||
copy out of it must dereference (`cp -rL`) and then restore write
|
||||
permission (`chmod -R u+w`). A plain `cp -r` copies the symlinks
|
||||
themselves, putting store paths into the project that break on any other
|
||||
machine.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Read `.claude/skills-lock.yaml` in the current project, if it exists.
|
||||
@@ -41,8 +35,7 @@ machine.
|
||||
skill already lives there and isn't tracked — remove or rename it
|
||||
first if they want the library version).
|
||||
- Otherwise, copy `~/.claude/skills/library/<name>/` to
|
||||
`.claude/skills/<name>/` in the project with
|
||||
`cp -rL` followed by `chmod -R u+w`, run
|
||||
`.claude/skills/<name>/` in the project, run
|
||||
`~/.claude/skills/setup-skills/hash-dir.sh .claude/skills/<name>`,
|
||||
and append `{name, hash: <output>}` to `.claude/skills-lock.yaml`
|
||||
(create the file, an empty YAML list, if it doesn't exist yet).
|
||||
@@ -36,11 +36,8 @@ skill that isn't already there — that's
|
||||
already included an explicit go-ahead argument (e.g. `-y`, `yes`), in
|
||||
which case apply them without asking. Applying means: delete
|
||||
`.claude/skills/<name>/` entirely and copy
|
||||
`~/.claude/skills/library/<name>/` in its place with `cp -rL` followed
|
||||
by `chmod -R u+w` (the library is read-only symlinks into the Nix store;
|
||||
a plain `cp -r` would put store paths into the project), so no file the
|
||||
project copy had but the library no longer has can survive — then
|
||||
recompute its
|
||||
`~/.claude/skills/library/<name>/` in its place, so no file the project
|
||||
copy had but the library no longer has can survive — then recompute its
|
||||
hash and overwrite that entry's `hash` in `.claude/skills-lock.yaml` in
|
||||
place.
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
# The shared foundation for every host and guest.
|
||||
let
|
||||
inherit (lib) mkOption types;
|
||||
user = config.user;
|
||||
|
||||
pinArgs = prev: {
|
||||
inherit (prev.stdenv.hostPlatform) system;
|
||||
config.allowUnfree = true;
|
||||
};
|
||||
in
|
||||
{
|
||||
imports = [ inputs.home-manager.nixosModules.home-manager ];
|
||||
|
||||
options.user = {
|
||||
name = mkOption {
|
||||
type = types.str;
|
||||
default = "alexion";
|
||||
description = ''
|
||||
Primary interactive user for this system.
|
||||
This drives the system account and Home Manager user together.
|
||||
'';
|
||||
};
|
||||
description = mkOption {
|
||||
type = types.str;
|
||||
default = "Alexion";
|
||||
description = "Human-readable description for the primary user.";
|
||||
};
|
||||
};
|
||||
|
||||
config = {
|
||||
nixpkgs.overlays = [
|
||||
(_final: prev: {
|
||||
unstable = import inputs.nixpkgs-unstable (pinArgs prev);
|
||||
stable = import inputs.nixpkgs-stable (pinArgs prev);
|
||||
})
|
||||
];
|
||||
nixpkgs.config.allowUnfree = true;
|
||||
|
||||
nix.settings.experimental-features = [
|
||||
"nix-command"
|
||||
"flakes"
|
||||
];
|
||||
|
||||
users.users.${user.name} = {
|
||||
isNormalUser = true;
|
||||
description = user.description;
|
||||
extraGroups = [
|
||||
"wheel"
|
||||
"storage"
|
||||
];
|
||||
};
|
||||
|
||||
users.groups.storage.gid = 10000;
|
||||
|
||||
home-manager = {
|
||||
useGlobalPkgs = true;
|
||||
useUserPackages = true;
|
||||
extraSpecialArgs = {
|
||||
inherit inputs;
|
||||
};
|
||||
users.${user.name} = {
|
||||
home.username = user.name;
|
||||
home.homeDirectory = "/home/${user.name}";
|
||||
home.stateVersion = "26.05";
|
||||
home.packages = with pkgs; [
|
||||
eza
|
||||
wget
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
registry.shell-shortcut.${config.networking.hostName} = {
|
||||
ls = {
|
||||
command = "eza -alg --color=always --group-directories-first --icons=always";
|
||||
abbreviation = false;
|
||||
};
|
||||
la = {
|
||||
command = "eza -a --color=always --group-directories-first --icons=always";
|
||||
abbreviation = false;
|
||||
};
|
||||
ll = {
|
||||
command = "eza -lg --color=always --group-directories-first --icons=always";
|
||||
abbreviation = false;
|
||||
};
|
||||
lt = {
|
||||
command = "eza -aT -I '.git' --color=always --group-directories-first --icons=always";
|
||||
abbreviation = false;
|
||||
};
|
||||
"l." = {
|
||||
command = "eza -a | grep -e '^\\.'";
|
||||
abbreviation = false;
|
||||
};
|
||||
|
||||
".." = "cd ..";
|
||||
"..." = "cd ../..";
|
||||
"...." = "cd ../../..";
|
||||
|
||||
cp = "cp -v";
|
||||
tarnow = "tar -acf ";
|
||||
untar = "tar -zxvf ";
|
||||
wget = "wget -c ";
|
||||
grep = "grep --color=auto";
|
||||
fgrep = "fgrep --color=auto";
|
||||
egrep = "egrep --color=auto";
|
||||
jctl = "journalctl -p 3 -xb";
|
||||
|
||||
update = "sudo nixos-rebuild switch --flake .#${config.networking.hostName}";
|
||||
cleanup = "sudo nix-collect-garbage -d";
|
||||
};
|
||||
|
||||
modules.shell.fish.enable = lib.mkDefault true;
|
||||
modules.shell.fish.defaultShell = lib.mkDefault true;
|
||||
modules.shell.tmux.enable = lib.mkDefault true;
|
||||
modules.shell.nvim.enable = lib.mkDefault true;
|
||||
modules.shell.git.enable = lib.mkDefault true;
|
||||
modules.shell.direnv.enable = lib.mkDefault true;
|
||||
modules.shell.yazi.enable = lib.mkDefault true;
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# Alacritty as the desktop terminal.
|
||||
let
|
||||
cfg = config.modules.desktop.apps.alacritty;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.desktop.apps.alacritty.enable = lib.mkEnableOption "Alacritty as the desktop terminal";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user}.home.packages = [ pkgs.alacritty ];
|
||||
};
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
{
|
||||
config,
|
||||
inputs,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# Firefox as the desktop browser: stock mainline, hardened and de-monetized by policy.
|
||||
let
|
||||
cfg = config.modules.desktop.apps.firefox;
|
||||
user = config.user.name;
|
||||
firefoxAddons = inputs.firefox-addons.packages.${pkgs.stdenv.hostPlatform.system};
|
||||
in
|
||||
{
|
||||
options.modules.desktop.apps.firefox.enable = lib.mkEnableOption "Firefox as the desktop browser";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user} = hm: {
|
||||
programs.firefox = {
|
||||
enable = true;
|
||||
|
||||
# Enterprise policies: enforced and unchangeable from the browser UI.
|
||||
policies = {
|
||||
DisableTelemetry = true;
|
||||
DisableFirefoxStudies = true;
|
||||
DisablePocket = true;
|
||||
OfferToSaveLogins = false;
|
||||
DontCheckDefaultBrowser = true;
|
||||
DisableFirefoxAccounts = true;
|
||||
|
||||
# Strip the monetized surfaces from the new-tab page.
|
||||
FirefoxHome = {
|
||||
SponsoredTopSites = false;
|
||||
SponsoredPocket = false;
|
||||
Snippets = false;
|
||||
};
|
||||
};
|
||||
|
||||
profiles.default = {
|
||||
isDefault = true;
|
||||
|
||||
extensions = {
|
||||
packages = with firefoxAddons; [
|
||||
ublock-origin
|
||||
proton-pass
|
||||
sponsorblock
|
||||
];
|
||||
|
||||
# The Nord chrome theme is a declared extension setting, so home-manager
|
||||
# owns the extension-settings store, overwriting runtime changes to it.
|
||||
force = true;
|
||||
};
|
||||
|
||||
# Stylix's Nord mapping paints the selected address-bar result a
|
||||
# near-white grey, leaving its light text unreadable. Darken that one
|
||||
# highlight to the Nord selection grey, from the same scheme.
|
||||
extensions.settings."FirefoxColor@mozilla.com".settings.theme.colors.popup_highlight =
|
||||
let
|
||||
c = hm.config.lib.stylix.colors;
|
||||
in
|
||||
lib.mkForce {
|
||||
r = c."base03-rgb-r";
|
||||
g = c."base03-rgb-g";
|
||||
b = c."base03-rgb-b";
|
||||
};
|
||||
|
||||
settings = {
|
||||
# Scale the UI and page by a fixed factor.
|
||||
# Left at auto (-1), Firefox reads the panel's 1.5x and inflates its
|
||||
# whole chrome while point-sized apps stay put.
|
||||
# A shade under that brings it into line without dropping to true
|
||||
# 1:1, which reads too small at this DPI.
|
||||
"layout.css.devPixelsPerPx" = "1.25";
|
||||
|
||||
# Auto-enable the sideloaded Firefox Color add-on carrying the Nord
|
||||
# chrome theme, which Firefox otherwise leaves disabled.
|
||||
"extensions.autoDisableScopes" = 0;
|
||||
|
||||
# Sponsored surfaces the policies above do not reach.
|
||||
"browser.urlbar.suggest.quicksuggest.sponsored" = false;
|
||||
"browser.newtabpage.activity-stream.showSponsored" = false;
|
||||
"browser.newtabpage.activity-stream.showSponsoredTopSites" = false;
|
||||
};
|
||||
|
||||
search = {
|
||||
# Declaring search overwrites Firefox's own engine list wholesale,
|
||||
# so engines added later in the UI do not survive a rebuild.
|
||||
force = true;
|
||||
default = "ddg";
|
||||
|
||||
# The general-purpose commercial engines, hidden to leave a lean
|
||||
# DuckDuckGo-and-Wikipedia list. They stay reachable through bangs.
|
||||
# An engine carrying only metaData is treated as a builtin.
|
||||
engines = {
|
||||
google.metaData.hidden = true;
|
||||
bing.metaData.hidden = true;
|
||||
ebay.metaData.hidden = true;
|
||||
"amazondotcom-us".metaData.hidden = true;
|
||||
};
|
||||
order = [ "ddg" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Nord chrome for the one profile, applied through the Stylix-managed
|
||||
# Firefox Color add-on that colorTheme enables.
|
||||
stylix.targets.firefox = {
|
||||
enable = true;
|
||||
profileNames = [ "default" ];
|
||||
colorTheme.enable = true;
|
||||
};
|
||||
|
||||
# Links opened from other applications land in Firefox.
|
||||
xdg.mimeApps = {
|
||||
enable = true;
|
||||
defaultApplications = {
|
||||
"text/html" = "firefox.desktop";
|
||||
"application/xhtml+xml" = "firefox.desktop";
|
||||
"x-scheme-handler/http" = "firefox.desktop";
|
||||
"x-scheme-handler/https" = "firefox.desktop";
|
||||
};
|
||||
};
|
||||
|
||||
# Firefox writes profiles.ini itself on first launch, so home-manager is
|
||||
# told to own the file rather than fail activation refusing to clobber it.
|
||||
home.file."${hm.config.programs.firefox.configPath}/profiles.ini".force = true;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# Ghostty as the desktop terminal.
|
||||
let
|
||||
cfg = config.modules.desktop.apps.ghostty;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.desktop.apps.ghostty.enable = lib.mkEnableOption "Ghostty as the desktop terminal";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user}.home.packages = [ pkgs.ghostty ];
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# Obsidian as the desktop note-taking vault.
|
||||
let
|
||||
cfg = config.modules.desktop.apps.obsidian;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.desktop.apps.obsidian.enable = lib.mkEnableOption "Obsidian as the desktop note-taking vault";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user}.home.packages = [ pkgs.obsidian ];
|
||||
};
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# Steam game launcher and runtime integration.
|
||||
let
|
||||
cfg = config.modules.desktop.apps.steam;
|
||||
in
|
||||
{
|
||||
options.modules.desktop.apps.steam.enable = lib.mkEnableOption "Steam game launcher";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
programs.steam = {
|
||||
enable = true;
|
||||
package = pkgs.steam.override {
|
||||
extraEnv.STEAM_FORCE_DESKTOPUI_SCALING = "1.5";
|
||||
};
|
||||
};
|
||||
|
||||
hardware.steam-hardware.enable = true;
|
||||
};
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{ config, lib, ... }:
|
||||
# The desktop aggregator: one flag brings up the whole graphical session.
|
||||
let
|
||||
cfg = config.modules.desktop;
|
||||
in
|
||||
{
|
||||
options.modules.desktop.enable = lib.mkEnableOption "the keyboard-driven Hyprland desktop";
|
||||
|
||||
# Each piece is turned on at default priority, so a host can still override
|
||||
# any one of them while the single flag above enables the whole desktop.
|
||||
config = lib.mkIf cfg.enable {
|
||||
modules.desktop.apps.firefox.enable = lib.mkDefault true;
|
||||
modules.desktop.apps.ghostty.enable = lib.mkDefault true;
|
||||
modules.desktop.session.hyprland.enable = lib.mkDefault true;
|
||||
modules.desktop.session.hyprland.hypridle.enable = lib.mkDefault true;
|
||||
modules.desktop.session.hyprland.hyprlock.enable = lib.mkDefault true;
|
||||
modules.desktop.session.login.enable = lib.mkDefault true;
|
||||
modules.desktop.session.mako.enable = lib.mkDefault true;
|
||||
modules.desktop.session.portals.enable = lib.mkDefault true;
|
||||
modules.desktop.session.rofi.enable = lib.mkDefault true;
|
||||
modules.desktop.session.waybar.enable = lib.mkDefault true;
|
||||
modules.desktop.utilities.audio.enable = lib.mkDefault true;
|
||||
modules.desktop.utilities.clipboard.enable = lib.mkDefault true;
|
||||
modules.desktop.utilities.osd.enable = lib.mkDefault true;
|
||||
modules.desktop.utilities.recording.enable = lib.mkDefault true;
|
||||
modules.desktop.utilities.screenshot.enable = lib.mkDefault true;
|
||||
modules.desktop.utilities.theming.enable = lib.mkDefault true;
|
||||
modules.desktop.utilities.userdirs.enable = lib.mkDefault true;
|
||||
};
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# The Hyprland compositor.
|
||||
let
|
||||
cfg = config.modules.desktop.session.hyprland;
|
||||
user = config.user.name;
|
||||
cursor = config.stylix.cursor;
|
||||
|
||||
# Numbered-workspace switch and move for 1..9, the operator's i3 muscle memory.
|
||||
workspaceBinds = lib.concatMap (n: [
|
||||
"$mod, ${toString n}, workspace, ${toString n}"
|
||||
"$mod SHIFT, ${toString n}, movetoworkspace, ${toString n}"
|
||||
]) (lib.range 1 9);
|
||||
|
||||
# Flip the tiling strategy between the two built-in layouts, since neither a
|
||||
# dispatcher nor a keyword toggles it on its own.
|
||||
toggleLayout = pkgs.writeShellScript "hypr-toggle-layout" ''
|
||||
if [ "$(hyprctl getoption -j general:layout | ${pkgs.jq}/bin/jq -r .str)" = dwindle ]; then
|
||||
hyprctl keyword general:layout master
|
||||
else
|
||||
hyprctl keyword general:layout dwindle
|
||||
fi
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.modules.desktop.session.hyprland = {
|
||||
enable = lib.mkEnableOption "the Hyprland compositor";
|
||||
|
||||
blur = lib.mkEnableOption ''
|
||||
window blur. Off by default as the single biggest battery cost on a
|
||||
laptop, left on for a host with the headroom to spend it'';
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# This program integration owns the session, portals, and polkit, launched
|
||||
# through the universal Wayland session manager.
|
||||
programs.hyprland = {
|
||||
enable = true;
|
||||
withUWSM = true;
|
||||
};
|
||||
|
||||
home-manager.users.${user}.wayland.windowManager.hyprland = {
|
||||
enable = true;
|
||||
|
||||
# One package drives the whole session, so there is never a version split.
|
||||
# The program integration above installs it and the portal, leaving home-
|
||||
# manager to write only the config.
|
||||
package = null;
|
||||
portalPackage = null;
|
||||
|
||||
# uwsm owns the systemd graphical-session targets.
|
||||
systemd.enable = false;
|
||||
|
||||
# Write the native hyprlang hyprland.conf, whose variable and bind syntax
|
||||
# the settings below are expressed in.
|
||||
configType = "hyprlang";
|
||||
|
||||
settings = {
|
||||
"$mod" = "SUPER";
|
||||
"$terminal" = "ghostty";
|
||||
|
||||
# Session variables handed to the compositor directly.
|
||||
# UWSM launches the session without the shell profile that would carry
|
||||
# them, so a variable the compositor or its children must see is set
|
||||
# here rather than through home-manager's sessionVariables.
|
||||
env =
|
||||
[
|
||||
# Chromium and Electron apps read this to select native Wayland;
|
||||
# nixpkgs wrappers (Obsidian's included) gate their Wayland flags on
|
||||
# it, so without it they run under XWayland and blur at this DPI.
|
||||
"NIXOS_OZONE_WL,1"
|
||||
]
|
||||
# Bibata ships XCursor only.
|
||||
# The hyprcursor variables name the same theme, which Hyprland
|
||||
# resolves through its XCursor fallback.
|
||||
++ lib.optionals (cursor != null) [
|
||||
"XCURSOR_THEME,${cursor.name}"
|
||||
"XCURSOR_SIZE,${toString cursor.size}"
|
||||
"HYPRCURSOR_THEME,${cursor.name}"
|
||||
"HYPRCURSOR_SIZE,${toString cursor.size}"
|
||||
];
|
||||
|
||||
input = {
|
||||
kb_layout = "us";
|
||||
# Caps is a second Escape.
|
||||
# Shift+Caps still toggles a real CapsLock.
|
||||
kb_options = "caps:escape_shifted_capslock";
|
||||
# Snappy key repeat.
|
||||
repeat_delay = 250;
|
||||
repeat_rate = 45;
|
||||
accel_profile = "flat";
|
||||
touchpad = {
|
||||
natural_scroll = true;
|
||||
tap-to-click = true;
|
||||
disable_while_typing = true;
|
||||
};
|
||||
};
|
||||
|
||||
general = {
|
||||
gaps_in = 4;
|
||||
gaps_out = 8;
|
||||
border_size = 2;
|
||||
layout = "dwindle";
|
||||
};
|
||||
|
||||
decoration = {
|
||||
rounding = 6;
|
||||
blur.enabled = cfg.blur;
|
||||
};
|
||||
|
||||
# XWayland clients render at the panel's native resolution instead of
|
||||
# being raster-scaled by the compositor at the fractional monitor scale.
|
||||
xwayland.force_zero_scaling = true;
|
||||
|
||||
animations = {
|
||||
enabled = true;
|
||||
bezier = [ "ease, 0.25, 0.1, 0.25, 1.0" ];
|
||||
# Durations are in centiseconds.
|
||||
# Short values keep the motion subtle.
|
||||
animation = [
|
||||
"windows, 1, 3, ease"
|
||||
"fade, 1, 3, ease"
|
||||
# Layer surfaces like the launcher fade in a touch quicker than windows.
|
||||
"layersIn, 1, 2, ease"
|
||||
"fadeLayersIn, 1, 2, ease"
|
||||
"workspaces, 1, 3, ease"
|
||||
"border, 1, 3, ease"
|
||||
];
|
||||
};
|
||||
|
||||
dwindle = {
|
||||
preserve_split = true;
|
||||
};
|
||||
|
||||
bind = [
|
||||
"$mod, Return, exec, $terminal"
|
||||
|
||||
# Move focus.
|
||||
"$mod, H, movefocus, l"
|
||||
"$mod, J, movefocus, d"
|
||||
"$mod, K, movefocus, u"
|
||||
"$mod, L, movefocus, r"
|
||||
|
||||
# Move the window within the layout.
|
||||
"$mod SHIFT, H, movewindow, l"
|
||||
"$mod SHIFT, J, movewindow, d"
|
||||
"$mod SHIFT, K, movewindow, u"
|
||||
"$mod SHIFT, L, movewindow, r"
|
||||
|
||||
# Resize the active window.
|
||||
"$mod ALT, H, resizeactive, -40 0"
|
||||
"$mod ALT, J, resizeactive, 0 40"
|
||||
"$mod ALT, K, resizeactive, 0 -40"
|
||||
"$mod ALT, L, resizeactive, 40 0"
|
||||
|
||||
"$mod, Space, togglefloating,"
|
||||
"$mod, F, fullscreen,"
|
||||
# togglesplit is a dwindle layout message, reached through layoutmsg.
|
||||
"$mod, T, layoutmsg, togglesplit"
|
||||
"$mod SHIFT, T, exec, ${toggleLayout}"
|
||||
"$mod SHIFT, Q, killactive,"
|
||||
"$mod CTRL, Q, forcekillactive,"
|
||||
]
|
||||
++ workspaceBinds;
|
||||
|
||||
# Volume and brightness keys repeat while held, each raising a popup
|
||||
# through the OSD client.
|
||||
# Volume is capped at 100 percent.
|
||||
# The client floors brightness so a full hold cannot black the screen out.
|
||||
binde = [
|
||||
", XF86AudioRaiseVolume, exec, ${pkgs.swayosd}/bin/swayosd-client --output-volume raise --max-volume 100"
|
||||
", XF86AudioLowerVolume, exec, ${pkgs.swayosd}/bin/swayosd-client --output-volume lower"
|
||||
", XF86MonBrightnessUp, exec, ${pkgs.swayosd}/bin/swayosd-client --brightness raise"
|
||||
", XF86MonBrightnessDown, exec, ${pkgs.swayosd}/bin/swayosd-client --brightness lower"
|
||||
];
|
||||
|
||||
# Mute and media transport still fire while the session is locked.
|
||||
bindl = [
|
||||
", XF86AudioMute, exec, ${pkgs.swayosd}/bin/swayosd-client --output-volume mute-toggle"
|
||||
", XF86AudioPlay, exec, ${pkgs.playerctl}/bin/playerctl play-pause"
|
||||
", XF86AudioNext, exec, ${pkgs.playerctl}/bin/playerctl next"
|
||||
", XF86AudioPrev, exec, ${pkgs.playerctl}/bin/playerctl previous"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# hypridle: idle-triggered locking and display power management.
|
||||
let
|
||||
cfg = config.modules.desktop.session.hyprland.hypridle;
|
||||
user = config.user.name;
|
||||
|
||||
hyprctl = "${config.programs.hyprland.package}/bin/hyprctl";
|
||||
hyprlock = "${config.home-manager.users.${user}.programs.hyprlock.package}/bin/hyprlock";
|
||||
|
||||
# The guard drops the call when a locker is already up, so no idle trigger
|
||||
# stacks a second hyprlock over the first.
|
||||
lockCmd = "${pkgs.procps}/bin/pidof hyprlock || ${hyprlock}";
|
||||
in
|
||||
{
|
||||
options.modules.desktop.session.hyprland.hypridle = {
|
||||
enable = lib.mkEnableOption "hypridle idle management";
|
||||
|
||||
lockTimeout = lib.mkOption {
|
||||
type = lib.types.ints.positive;
|
||||
default = 300;
|
||||
description = "Seconds of inactivity before the screen locks.";
|
||||
};
|
||||
|
||||
screenOffTimeout = lib.mkOption {
|
||||
type = lib.types.ints.positive;
|
||||
default = 360;
|
||||
description = "Seconds of inactivity before the displays are powered off.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# Closing the lid suspends, and every suspend locks first through the
|
||||
# before_sleep_cmd below, so the lid always lands at a locked screen.
|
||||
services.logind.settings.Login.HandleLidSwitch = "suspend";
|
||||
|
||||
home-manager.users.${user}.services.hypridle = {
|
||||
enable = true;
|
||||
|
||||
settings = {
|
||||
general = {
|
||||
lock_cmd = lockCmd;
|
||||
before_sleep_cmd = "loginctl lock-session";
|
||||
# Waking restores the displays the screen-off listener may have cut.
|
||||
after_sleep_cmd = "${hyprctl} dispatch dpms on";
|
||||
};
|
||||
|
||||
listener = [
|
||||
# Lock on idle.
|
||||
{
|
||||
timeout = cfg.lockTimeout;
|
||||
on-timeout = "loginctl lock-session";
|
||||
}
|
||||
# Power the displays off a little later, restoring them on any activity.
|
||||
{
|
||||
timeout = cfg.screenOffTimeout;
|
||||
on-timeout = "${hyprctl} dispatch dpms off";
|
||||
on-resume = "${hyprctl} dispatch dpms on";
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# The lock screen: a hyprlock session-lock surface the compositor owns.
|
||||
let
|
||||
cfg = config.modules.desktop.session.hyprland.hyprlock;
|
||||
user = config.user.name;
|
||||
|
||||
# The hyprlock this module installs, used by the lock keybind below.
|
||||
hyprlock = "${config.home-manager.users.${user}.programs.hyprlock.package}/bin/hyprlock";
|
||||
|
||||
sleepIcon = builtins.fromJSON ''"\udb81\udcb2"'';
|
||||
sleepOffIcon = builtins.fromJSON ''"\udb81\udcb3"'';
|
||||
|
||||
idleLockToggle = pkgs.writeShellScript "lockscreen-idle-inhibit-toggle" ''
|
||||
unit=lockscreen-idle-inhibit.service
|
||||
|
||||
refresh_waybar() {
|
||||
${pkgs.procps}/bin/pkill -RTMIN+9 waybar >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
if ${pkgs.systemd}/bin/systemctl --user is-active --quiet "$unit"; then
|
||||
${pkgs.systemd}/bin/systemctl --user stop "$unit"
|
||||
refresh_waybar
|
||||
${pkgs.swayosd}/bin/swayosd-client --custom-message "${sleepIcon}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ${pkgs.systemd}/bin/systemd-run \
|
||||
--user \
|
||||
--collect \
|
||||
--unit=lockscreen-idle-inhibit \
|
||||
--description="Disable idle-triggered screen locking" \
|
||||
${pkgs.systemd}/bin/systemd-inhibit \
|
||||
--what=idle \
|
||||
--who=lockscreen-hotkey \
|
||||
--why="lock screen disabled by hotkey" \
|
||||
${pkgs.coreutils}/bin/sleep infinity >/dev/null; then
|
||||
refresh_waybar
|
||||
${pkgs.swayosd}/bin/swayosd-client --custom-message "${sleepOffIcon}"
|
||||
fi
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.modules.desktop.session.hyprland.hyprlock.enable = lib.mkEnableOption "the hyprlock lock screen";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user} = {
|
||||
# Colors and the lock-screen background come from Stylix's hyprlock target,
|
||||
# so only geometry and behaviour are set here.
|
||||
programs.hyprlock = {
|
||||
enable = true;
|
||||
|
||||
settings = {
|
||||
general = {
|
||||
hide_cursor = true;
|
||||
# No progress bar flashes before the field is ready to take input.
|
||||
disable_loading_bar = true;
|
||||
};
|
||||
|
||||
# A centered password field.
|
||||
# Its colors come from the Stylix hyprlock target.
|
||||
input-field = {
|
||||
size = "260, 52";
|
||||
rounding = 8;
|
||||
position = "0, -100";
|
||||
halign = "center";
|
||||
valign = "center";
|
||||
};
|
||||
|
||||
# The current time, above the field.
|
||||
label = {
|
||||
text = "$TIME";
|
||||
font_size = 48;
|
||||
position = "0, 120";
|
||||
halign = "center";
|
||||
valign = "center";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Lock on Super+X.
|
||||
# The guard drops the keypress when a locker is already up, so a second
|
||||
# hyprlock never stacks over the first.
|
||||
wayland.windowManager.hyprland.settings.bind = [
|
||||
"$mod, X, exec, ${pkgs.procps}/bin/pidof hyprlock || ${hyprlock}"
|
||||
"$mod CTRL, X, exec, ${idleLockToggle}"
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user