···11+# Backups
22+33+Services are automatically backed up nightly using restic to Backblaze B2. Backup targets are auto-discovered from `data.sqlite`/`data.postgres`/`data.files` declarations in mkService modules.
44+55+## Schedule
66+77+- **Time:** 02:00 AM daily
88+- **Random delay:** 0–2 hours (spreads load across services)
99+- **Retention:** 3 snapshots, 7 daily, 5 weekly, 12 monthly
1010+1111+## CLI
1212+1313+The `atelier-backup` command provides an interactive TUI:
1414+1515+```bash
1616+sudo atelier-backup # Interactive menu
1717+sudo atelier-backup status # Show backup status for all services
1818+sudo atelier-backup list # Browse snapshots
1919+sudo atelier-backup backup # Trigger manual backup
2020+sudo atelier-backup restore # Interactive restore wizard
2121+sudo atelier-backup dr # Disaster recovery mode
2222+```
2323+2424+## Service integration
2525+2626+### Automatic (mkService)
2727+2828+Services using `mkService` with `data.*` declarations get automatic backup:
2929+3030+```nix
3131+mkService {
3232+ name = "myapp";
3333+ extraConfig = cfg: {
3434+ atelier.services.myapp.data = {
3535+ sqlite = "${cfg.dataDir}/data/app.db"; # Auto WAL checkpoint + stop/start
3636+ files = [ "${cfg.dataDir}/uploads" ]; # Just backed up, no hooks
3737+ };
3838+ };
3939+}
4040+```
4141+4242+The backup system automatically checkpoints SQLite WAL, stops the service during backup, and restarts after completion.
4343+4444+### Manual registration
4545+4646+For services not using `mkService`:
4747+4848+```nix
4949+atelier.backup.services.myservice = {
5050+ paths = [ "/var/lib/myservice" ];
5151+ exclude = [ "*.log" "cache/*" ];
5252+ preBackup = "systemctl stop myservice";
5353+ postBackup = "systemctl start myservice";
5454+};
5555+```
5656+5757+## Disaster recovery
5858+5959+On a fresh NixOS install:
6060+6161+1. Rebuild from flake: `nixos-rebuild switch --flake .#hostname`
6262+2. Run: `sudo atelier-backup dr`
6363+3. All services restored from latest snapshots
6464+6565+## Setup
6666+6767+1. Create a B2 bucket and application key
6868+2. Create agenix secrets for `restic/password`, `restic/env`, `restic/repo`
6969+3. Enable: `atelier.backup.enable = true;`
7070+7171+See [modules/nixos/services/restic/README.md](https://github.com/taciturnaxolotl/dots/blob/main/modules/nixos/services/restic/README.md) for full setup details.
+63
docs/src/deployment.md
···11+# Deployment
22+33+Two deploy paths: **infrastructure** (NixOS config changes) and **application code** (per-service repos).
44+55+## Infrastructure
66+77+Pushing to `main` triggers `.github/workflows/deploy.yaml` which runs `deploy-rs` over Tailscale to rebuild NixOS on the target machine.
88+99+```sh
1010+# manual deploy
1111+nix run 'github:serokell/deploy-rs' -- --remote-build --ssh-user kierank .
1212+```
1313+1414+## Application Code
1515+1616+Each service repo has a minimal workflow calling the reusable `.github/workflows/deploy-service.yml`. On push to `main`:
1717+1818+1. Connects to Tailscale (`tag:deploy`)
1919+2. SSHes as the **service user** (e.g., `cachet@terebithia`) via Tailscale SSH
2020+3. Snapshots the SQLite DB (if `db_path` is provided)
2121+4. `git pull` + `bun install --frozen-lockfile` + `sudo systemctl restart`
2222+5. Health check (HTTP URL or systemd status fallback)
2323+6. Auto-rollback on failure (restores DB snapshot + reverts to previous commit)
2424+2525+Per-app workflow — copy and change the `with:` values:
2626+2727+```yaml
2828+name: Deploy
2929+on:
3030+ push:
3131+ branches: [main]
3232+ workflow_dispatch:
3333+jobs:
3434+ deploy:
3535+ uses: taciturnaxolotl/dots/.github/workflows/deploy-service.yml@main
3636+ with:
3737+ service: cachet
3838+ health_url: https://cachet.dunkirk.sh/health
3939+ db_path: /var/lib/cachet/data/cachet.db
4040+ secrets:
4141+ TS_OAUTH_CLIENT_ID: ${{ secrets.TS_OAUTH_CLIENT_ID }}
4242+ TS_OAUTH_SECRET: ${{ secrets.TS_OAUTH_SECRET }}
4343+```
4444+4545+Omit `health_url` to fall back to `systemctl is-active`. Omit `db_path` for stateless services.
4646+4747+## mkService
4848+4949+`modules/lib/mkService.nix` standardizes service modules. A call to `mkService { ... }` provides:
5050+5151+- Systemd service with initial git clone (subsequent deploys via GitHub Actions)
5252+- Caddy reverse proxy with TLS via Cloudflare DNS and optional rate limiting
5353+- Data declarations (`sqlite`, `postgres`, `files`) that feed into automatic backups
5454+- Dedicated system user with sudo for restart/stop/start (enables per-user Tailscale ACLs)
5555+- Port conflict detection, security hardening, agenix secrets
5656+5757+### Adding a new service
5858+5959+1. Create a module in `modules/nixos/services/`
6060+2. Enable it in `machines/terebithia/default.nix`
6161+3. Add a deploy workflow to the app repo
6262+6363+See `modules/nixos/services/cachet.nix` for a minimal example.
+72
docs/src/installation.md
···11+# Installation
22+33+> **Warning:** This configuration will not work without changing the [secrets](https://github.com/taciturnaxolotl/dots/tree/main/secrets) since they are encrypted with agenix.
44+55+## macOS with nix-darwin
66+77+1. Install Nix:
88+99+```bash
1010+curl -fsSL https://install.determinate.systems/nix | sh -s -- install
1111+```
1212+1313+2. Clone and apply:
1414+1515+```bash
1616+git clone git@github.com:taciturnaxolotl/dots.git
1717+cd dots
1818+darwin-rebuild switch --flake .#atalanta
1919+```
2020+2121+## Home Manager
2222+2323+Install Nix, copy SSH keys, then:
2424+2525+```bash
2626+curl -fsSL https://install.determinate.systems/nix | sh -s -- install --determinate
2727+git clone git@github.com:taciturnaxolotl/dots.git
2828+cd dots
2929+nix-shell -p home-manager
3030+home-manager switch --flake .#nest
3131+```
3232+3333+Set up [atuin](https://atuin.sh/) for shell history sync:
3434+3535+```bash
3636+atuin login
3737+atuin import
3838+```
3939+4040+## NixOS
4141+4242+### Using nixos-anywhere (recommended for remote)
4343+4444+> Only works with `prattle` and `terebithia` which have disko configs.
4545+4646+```bash
4747+nix run github:nix-community/nixos-anywhere -- \
4848+ --flake .#prattle \
4949+ --generate-hardware-config nixos-facter ./machines/prattle/facter.json \
5050+ --build-on-remote \
5151+ root@<ip-address>
5252+```
5353+5454+### Using the install script
5555+5656+```bash
5757+curl -L https://raw.githubusercontent.com/taciturnaxolotl/dots/main/install.sh -o install.sh
5858+chmod +x install.sh
5959+./install.sh
6060+```
6161+6262+### Post-install
6363+6464+After first boot, log in with user `kierank` and the default password, then:
6565+6666+```bash
6767+passwd kierank
6868+sudo mv /etc/nixos ~/dots
6969+sudo ln -s ~/dots /etc/nixos
7070+sudo chown -R $(id -un):users ~/dots
7171+atuin login && atuin sync
7272+```
+101
docs/src/mkservice.md
···11+# mkService
22+33+`modules/lib/mkService.nix` is the service factory used by most atelier services. It takes a set of parameters and returns a NixOS module with standardized options, systemd service, Caddy reverse proxy, and backup integration.
44+55+## Factory parameters
66+77+| Parameter | Type | Default | Description |
88+|-----------|------|---------|-------------|
99+| `name` | string | *required* | Service identity — used for user, group, systemd unit, and option namespace |
1010+| `description` | string | `"<name> service"` | Human-readable description |
1111+| `defaultPort` | int | `3000` | Default port if not overridden in config |
1212+| `runtime` | string | `"bun"` | `"bun"`, `"node"`, or `"custom"` |
1313+| `entryPoint` | string | `"src/index.ts"` | Script to run (ignored if `startCommand` is set) |
1414+| `startCommand` | string | `null` | Override the full start command |
1515+| `extraOptions` | attrset | `{}` | Additional NixOS options for this service |
1616+| `extraConfig` | function | `cfg: {}` | Additional NixOS config when enabled (receives the service config) |
1717+1818+## Options
1919+2020+Every mkService module creates options under `atelier.services.<name>`:
2121+2222+### Core
2323+2424+| Option | Type | Default | Description |
2525+|--------|------|---------|-------------|
2626+| `enable` | bool | `false` | Enable the service |
2727+| `domain` | string | *required* | Domain for Caddy reverse proxy |
2828+| `port` | port | `defaultPort` | Port the service listens on |
2929+| `dataDir` | path | `"/var/lib/<name>"` | Data storage directory |
3030+| `secretsFile` | path or null | `null` | Agenix secrets environment file |
3131+| `repository` | string or null | `null` | Git repo URL — cloned once on first start |
3232+| `healthUrl` | string or null | `null` | Health check URL for monitoring |
3333+| `environment` | attrset | `{}` | Additional environment variables |
3434+3535+### Data declarations
3636+3737+Used by the backup system to automatically discover what to back up.
3838+3939+| Option | Type | Default | Description |
4040+|--------|------|---------|-------------|
4141+| `data.sqlite` | string or null | `null` | SQLite database path (WAL checkpoint + stop/start during backup) |
4242+| `data.postgres` | string or null | `null` | PostgreSQL database name (pg_dump during backup) |
4343+| `data.files` | list of strings | `[]` | Additional file paths to back up |
4444+| `data.exclude` | list of strings | `["*.log", "node_modules", ...]` | Glob patterns to exclude |
4545+4646+### Caddy
4747+4848+| Option | Type | Default | Description |
4949+|--------|------|---------|-------------|
5050+| `caddy.enable` | bool | `true` | Enable Caddy reverse proxy |
5151+| `caddy.extraConfig` | string | `""` | Additional Caddy directives |
5252+| `caddy.rateLimit.enable` | bool | `false` | Enable rate limiting |
5353+| `caddy.rateLimit.events` | int | `60` | Requests per window |
5454+| `caddy.rateLimit.window` | string | `"1m"` | Rate limit time window |
5555+5656+## What it sets up
5757+5858+- **System user and group** — dedicated user in the `services` group with sudo for `systemctl restart/stop/start/status`
5959+- **Systemd service** — `ExecStartPre` creates dirs as root, `preStart` clones repo and installs deps, `ExecStart` runs the application
6060+- **Caddy virtual host** — TLS via Cloudflare DNS challenge, reverse proxy to localhost port
6161+- **Port conflict detection** — assertions prevent two services from binding the same port
6262+- **Security hardening** — `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome`, `PrivateTmp`
6363+6464+## Example
6565+6666+Minimal service module:
6767+6868+```nix
6969+let
7070+ mkService = import ../../lib/mkService.nix;
7171+in
7272+mkService {
7373+ name = "myapp";
7474+ description = "My application";
7575+ defaultPort = 3000;
7676+ runtime = "bun";
7777+ entryPoint = "src/index.ts";
7878+7979+ extraConfig = cfg: {
8080+ systemd.services.myapp.serviceConfig.Environment = [
8181+ "DATABASE_PATH=${cfg.dataDir}/data/app.db"
8282+ ];
8383+8484+ atelier.services.myapp.data = {
8585+ sqlite = "${cfg.dataDir}/data/app.db";
8686+ };
8787+ };
8888+}
8989+```
9090+9191+Then enable in the machine config:
9292+9393+```nix
9494+atelier.services.myapp = {
9595+ enable = true;
9696+ domain = "myapp.dunkirk.sh";
9797+ repository = "https://github.com/taciturnaxolotl/myapp";
9898+ secretsFile = config.age.secrets.myapp.path;
9999+ healthUrl = "https://myapp.dunkirk.sh/health";
100100+};
101101+```
+22
docs/src/modules/README.md
···11+# Modules
22+33+Custom NixOS and home-manager modules under the `atelier.*` namespace. These wrap and extend upstream packages with opinionated defaults and structured configuration.
44+55+## NixOS modules
66+77+| Module | Namespace | Description |
88+|--------|-----------|-------------|
99+| [tuigreet](./tuigreet.md) | `atelier.apps.tuigreet` | Login greeter with 30+ typed options |
1010+| [wifi](./wifi.md) | `atelier.network.wifi` | Declarative Wi-Fi profiles with eduroam support |
1111+| authentication | `atelier.authentication` | Fingerprint + PAM stack (fprintd, polkit, gnome-keyring) |
1212+1313+## Home-manager modules
1414+1515+| Module | Namespace | Description |
1616+|--------|-----------|-------------|
1717+| [shell](./shell.md) | `atelier.shell` | Zsh + oh-my-posh + Tangled workflow tooling |
1818+| [ssh](./ssh.md) | `atelier.ssh` | SSH config with zmx persistent sessions |
1919+| [helix](./helix.md) | `atelier.apps.helix` | Evil-helix with 15+ LSPs, wakatime, harper |
2020+| [bore (client)](./bore-client.md) | `atelier.bore` | Tunnel client CLI for the bore server |
2121+| [pbnj](./pbnj.md) | `atelier.pbnj` | Pastebin CLI with language detection |
2222+| [wut](./wut.md) | `atelier.shell.wut` | Git worktree manager |
+33
docs/src/modules/bore-client.md
···11+# bore (client)
22+33+Interactive CLI for creating tunnels to the [bore server](../services/bore.md). Built with gum, supports HTTP, TCP, and UDP tunnels.
44+55+## Options
66+77+All options under `atelier.bore`:
88+99+| Option | Type | Default | Description |
1010+|--------|------|---------|-------------|
1111+| `enable` | bool | `false` | Install the bore CLI |
1212+| `serverAddr` | string | `"bore.dunkirk.sh"` | frps server address |
1313+| `serverPort` | port | `7000` | frps server port |
1414+| `domain` | string | `"bore.dunkirk.sh"` | Base domain for constructing public URLs |
1515+| `authTokenFile` | path | — | Path to frp auth token file |
1616+1717+## Usage
1818+1919+```bash
2020+bore # Interactive menu
2121+bore myapp 3000 # Quick HTTP tunnel: myapp.bore.dunkirk.sh → localhost:3000
2222+bore myapp 3000 --auth # With OAuth authentication
2323+bore myapp 3000 --save # Save to bore.toml for reuse
2424+```
2525+2626+Tunnels can also be defined in a `bore.toml`:
2727+2828+```toml
2929+[myapp]
3030+port = 3000
3131+auth = true
3232+labels = ["dev"]
3333+```
+36
docs/src/modules/helix.md
···11+# helix
22+33+Evil-helix (vim-mode fork) with comprehensive LSP setup, wakatime tracking on every language, and harper grammar checking.
44+55+## Options
66+77+All options under `atelier.apps.helix`:
88+99+| Option | Type | Default | Description |
1010+|--------|------|---------|-------------|
1111+| `enable` | bool | `false` | Enable helix configuration |
1212+| `swift` | bool | `false` | Add sourcekit-lsp for Swift (platform-conditional) |
1313+1414+## Language servers
1515+1616+The module configures 15+ language servers out of the box:
1717+1818+| Language | Server |
1919+|----------|--------|
2020+| Nix | nixd + nil |
2121+| TypeScript/JavaScript | typescript-language-server + biome |
2222+| Go | gopls |
2323+| Python | pylsp |
2424+| Rust | rust-analyzer |
2525+| HTML/CSS | vscode-html-language-server, vscode-css-language-server |
2626+| JSON | vscode-json-language-server + biome |
2727+| TOML | taplo |
2828+| Markdown | marksman |
2929+| YAML | yaml-language-server |
3030+| Swift | sourcekit-lsp (when `swift = true`) |
3131+3232+All languages also get:
3333+- **wakatime-ls** — coding time tracking
3434+- **harper-ls** — grammar and spell checking
3535+3636+> **Note:** After install, run `hx -g fetch && hx -g build` to compile tree-sitter grammars.
+25
docs/src/modules/pbnj.md
···11+# pbnj
22+33+Pastebin CLI with automatic language detection, clipboard integration, and agenix auth.
44+55+## Options
66+77+All options under `atelier.pbnj`:
88+99+| Option | Type | Default | Description |
1010+|--------|------|---------|-------------|
1111+| `enable` | bool | `false` | Install the pbnj CLI |
1212+| `host` | string | — | Pastebin instance URL |
1313+| `authKeyFile` | path | — | Path to auth key file (e.g. agenix secret) |
1414+1515+## Usage
1616+1717+```bash
1818+pbnj # Interactive menu
1919+pbnj upload myfile.py # Upload file (auto-detects Python)
2020+cat output.log | pbnj upload # Upload from stdin
2121+pbnj list # List pastes
2222+pbnj delete <id> # Delete a paste
2323+```
2424+2525+Supports 25+ languages via file extension detection. Automatically copies the URL to clipboard (wl-copy/xclip/pbcopy depending on platform).
+30
docs/src/modules/shell.md
···11+# shell
22+33+Zsh configuration with oh-my-posh prompt, syntax highlighting, fzf-tab, zoxide, and Tangled git workflow tooling.
44+55+## Options
66+77+All options under `atelier.shell`:
88+99+| Option | Type | Default | Description |
1010+|--------|------|---------|-------------|
1111+| `enable` | bool | `false` | Enable shell configuration |
1212+1313+### Tangled
1414+1515+Options for the `tangled-setup` and `mkdev` scripts that manage dual-remote git workflows (Tangled knot + GitHub).
1616+1717+| Option | Type | Default | Description |
1818+|--------|------|---------|-------------|
1919+| `tangled.plcId` | string | — | ATProto DID for Tangled identity |
2020+| `tangled.githubUser` | string | — | GitHub username |
2121+| `tangled.knotHost` | string | — | Knot git host (e.g. `knot.dunkirk.sh`) |
2222+| `tangled.domain` | string | — | Tangled domain for repo URLs |
2323+| `tangled.defaultBranch` | string | `"main"` | Default branch name |
2424+2525+### Included tools
2626+2727+- **`tangled-setup`** — configures a repo with `origin` pointing to knot and `github` pointing to GitHub
2828+- **`mkdev`** — creates a new repo on both Tangled and GitHub simultaneously
2929+- **oh-my-posh** — custom prompt showing path, git status (ahead/behind), exec time, nix-shell indicator, ZMX session, SSH hostname
3030+- **Aliases** — `cat=bat`, `ls=eza`, `cd=z` (zoxide), and more
+57
docs/src/modules/ssh.md
···11+# ssh
22+33+Declarative SSH config with per-host options and zmx (persistent tmux-like sessions over SSH) integration.
44+55+## Options
66+77+All options under `atelier.ssh`:
88+99+| Option | Type | Default | Description |
1010+|--------|------|---------|-------------|
1111+| `enable` | bool | `false` | Enable SSH config management |
1212+| `extraConfig` | string | `""` | Raw SSH config appended to the end |
1313+1414+### zmx
1515+1616+| Option | Type | Default | Description |
1717+|--------|------|---------|-------------|
1818+| `zmx.enable` | bool | `false` | Install zmx and autossh |
1919+| `zmx.hosts` | list of strings | `[]` | Host patterns to auto-attach via zmx |
2020+2121+When zmx is enabled for a host, the SSH config injects `RemoteCommand`, `RequestTTY force`, and `ControlMaster`/`ControlPersist` settings. Shell aliases are also added: `zmls`, `zmk`, `zma`, `ash`.
2222+2323+### Hosts
2424+2525+Per-host config under `atelier.ssh.hosts.<name>`:
2626+2727+| Option | Type | Default | Description |
2828+|--------|------|---------|-------------|
2929+| `hostname` | string | — | SSH hostname or IP |
3030+| `port` | int or null | `null` | SSH port |
3131+| `user` | string or null | `null` | SSH user |
3232+| `identityFile` | string or null | `null` | Path to SSH key |
3333+| `forwardAgent` | bool | `false` | Forward SSH agent |
3434+| `zmx` | bool | `false` | Enable zmx for this host |
3535+| `extraOptions` | attrsOf string | `{}` | Arbitrary SSH options |
3636+3737+## Example
3838+3939+```nix
4040+atelier.ssh = {
4141+ enable = true;
4242+ zmx.enable = true;
4343+ zmx.hosts = [ "terebithia" "ember" ];
4444+4545+ hosts = {
4646+ terebithia = {
4747+ hostname = "terebithia";
4848+ user = "kierank";
4949+ forwardAgent = true;
5050+ zmx = true;
5151+ };
5252+ "github.com" = {
5353+ identityFile = "~/.ssh/id_rsa";
5454+ };
5555+ };
5656+};
5757+```
+70
docs/src/modules/tuigreet.md
···11+# tuigreet
22+33+Configures greetd with tuigreet as the login greeter. Exposes nearly every tuigreet CLI flag as a typed Nix option.
44+55+## Options
66+77+All options under `atelier.apps.tuigreet`:
88+99+### Core
1010+1111+| Option | Type | Default | Description |
1212+|--------|------|---------|-------------|
1313+| `enable` | bool | `false` | Enable tuigreet |
1414+| `command` | string | `"Hyprland"` | Session command to run after login |
1515+| `greeting` | string | *(unauthorized access warning)* | Greeting message |
1616+1717+### Display
1818+1919+| Option | Type | Default | Description |
2020+|--------|------|---------|-------------|
2121+| `time` | bool | `false` | Show clock |
2222+| `timeFormat` | string | `"%H:%M"` | Clock format |
2323+| `issue` | bool | `false` | Show `/etc/issue` |
2424+| `width` | int | `80` | UI width |
2525+| `theme` | string | `""` | Theme string |
2626+| `asterisks` | bool | `false` | Show asterisks for password |
2727+| `asterisksChar` | string | `"*"` | Character for password masking |
2828+2929+### Layout
3030+3131+| Option | Type | Default | Description |
3232+|--------|------|---------|-------------|
3333+| `windowPadding` | int | `0` | Window padding |
3434+| `containerPadding` | int | `1` | Container padding |
3535+| `promptPadding` | int | `1` | Prompt padding |
3636+| `greetAlign` | enum | `"center"` | Greeting alignment: `left`, `center`, `right` |
3737+3838+### Session management
3939+4040+| Option | Type | Default | Description |
4141+|--------|------|---------|-------------|
4242+| `remember` | bool | `false` | Remember last username |
4343+| `rememberSession` | bool | `false` | Remember last session |
4444+| `rememberUserSession` | bool | `false` | Per-user session memory |
4545+| `sessions` | string | `""` | Wayland session search path |
4646+| `xsessions` | string | `""` | X11 session search path |
4747+| `sessionWrapper` | string | `""` | Session wrapper command |
4848+4949+### User menu
5050+5151+| Option | Type | Default | Description |
5252+|--------|------|---------|-------------|
5353+| `userMenu` | bool | `false` | Show user selection menu |
5454+| `userMenuMinUid` | int | `1000` | Minimum UID in user menu |
5555+| `userMenuMaxUid` | int | `65534` | Maximum UID in user menu |
5656+5757+### Power commands
5858+5959+| Option | Type | Default | Description |
6060+|--------|------|---------|-------------|
6161+| `powerShutdown` | string | `""` | Shutdown command |
6262+| `powerReboot` | string | `""` | Reboot command |
6363+6464+### Keybindings
6565+6666+| Option | Type | Default | Description |
6767+|--------|------|---------|-------------|
6868+| `kbCommand` | enum | `"F2"` | Key to switch command |
6969+| `kbSessions` | enum | `"F3"` | Key to switch session |
7070+| `kbPower` | enum | `"F12"` | Key for power menu |
+53
docs/src/modules/wifi.md
···11+# wifi
22+33+Declarative Wi-Fi profile manager using NetworkManager. Supports three ways to supply passwords and has built-in eduroam (WPA-EAP) support.
44+55+## Options
66+77+All options under `atelier.network.wifi`:
88+99+| Option | Type | Default | Description |
1010+|--------|------|---------|-------------|
1111+| `enable` | bool | `false` | Enable Wi-Fi management |
1212+| `hostName` | string | — | Sets `networking.hostName` |
1313+| `nameservers` | list of strings | `[]` | Custom DNS servers |
1414+| `envFile` | path | — | Environment file providing PSK variables for all profiles |
1515+1616+### Profiles
1717+1818+Defined under `atelier.network.wifi.profiles.<ssid>`:
1919+2020+| Option | Type | Default | Description |
2121+|--------|------|---------|-------------|
2222+| `psk` | string or null | `null` | Literal WPA-PSK passphrase |
2323+| `pskVar` | string or null | `null` | Environment variable name containing the PSK (from `envFile`) |
2424+| `pskFile` | path or null | `null` | Path to file containing the PSK |
2525+| `eduroam` | bool | `false` | Use WPA-EAP with MSCHAPV2 (for eduroam networks) |
2626+| `identity` | string or null | `null` | EAP identity (required when `eduroam = true`) |
2727+2828+Only one of `psk`, `pskVar`, or `pskFile` should be set per profile.
2929+3030+## Example
3131+3232+```nix
3333+atelier.network.wifi = {
3434+ enable = true;
3535+ hostName = "moonlark";
3636+ nameservers = [ "1.1.1.1" "8.8.8.8" ];
3737+ envFile = config.age.secrets.wifi.path;
3838+3939+ profiles = {
4040+ "Home Network" = {
4141+ pskVar = "HOME_PSK"; # read from envFile
4242+ };
4343+ "eduroam" = {
4444+ eduroam = true;
4545+ identity = "user@university.edu";
4646+ pskVar = "EDUROAM_PSK";
4747+ };
4848+ "Phone Hotspot" = {
4949+ pskFile = config.age.secrets.hotspot.path;
5050+ };
5151+ };
5252+};
5353+```
+43
docs/src/modules/wut.md
···11+# wut
22+33+**W**orktrees **U**nexpectedly **T**olerable — a git worktree manager that keeps worktrees organized under `.worktrees/`.
44+55+## Options
66+77+| Option | Type | Default | Description |
88+|--------|------|---------|-------------|
99+| `atelier.shell.wut.enable` | bool | `false` | Install wut and the zsh shell wrapper |
1010+1111+## Usage
1212+1313+```bash
1414+wut new feat/my-feature # Create worktree + branch under .worktrees/
1515+wut list # Show all worktrees
1616+wut go feat/my-feature # cd into worktree (via shell wrapper)
1717+wut go # Interactive picker
1818+wut path feat/my-feature # Print worktree path
1919+wut rm feat/my-feature # Remove worktree + delete branch
2020+```
2121+2222+## Shell integration
2323+2424+Wut needs to `cd` the calling shell, which a subprocess can't do directly. It works by printing a `__WUT_CD__=/path` marker that a zsh wrapper function intercepts:
2525+2626+```zsh
2727+wut() {
2828+ output=$(/path/to/wut "$@")
2929+ if [[ "$output" == *"__WUT_CD__="* ]]; then
3030+ cd "${output##*__WUT_CD__=}"
3131+ else
3232+ echo "$output"
3333+ fi
3434+}
3535+```
3636+3737+This wrapper is automatically injected into `initContent` when the module is enabled.
3838+3939+## Safety
4040+4141+- `wut rm` refuses to delete worktrees with uncommitted changes (use `--force` to override)
4242+- `wut rm` warns before deleting unmerged branches
4343+- The main/master branch worktree cannot be removed
+55
docs/src/secrets.md
···11+# Secrets
22+33+Secrets are managed using [agenix](https://github.com/ryantm/agenix) — encrypted at rest in the repo and decrypted at activation time to `/run/agenix/`.
44+55+## Usage
66+77+Create or edit a secret:
88+99+```bash
1010+cd secrets && agenix -e myapp.age
1111+```
1212+1313+The secret file contains environment variables, one per line:
1414+1515+```
1616+DATABASE_URL=postgres://...
1717+API_KEY=xxxxx
1818+SECRET_TOKEN=yyyyy
1919+```
2020+2121+## Adding a new secret
2222+2323+1. Add the public key entry to `secrets/secrets.nix`:
2424+2525+```nix
2626+"service-name.age".publicKeys = [ kierank ];
2727+```
2828+2929+2. Create and encrypt the secret:
3030+3131+```bash
3232+agenix -e secrets/service-name.age
3333+```
3434+3535+3. Declare in machine config:
3636+3737+```nix
3838+age.secrets.service-name = {
3939+ file = ../../secrets/service-name.age;
4040+ owner = "service-name";
4141+};
4242+```
4343+4444+4. Reference as `config.age.secrets.service-name.path` in the service module.
4545+4646+## Identity paths
4747+4848+The decryption keys are SSH keys configured per machine:
4949+5050+```nix
5151+age.identityPaths = [
5252+ "/home/kierank/.ssh/id_rsa"
5353+ "/etc/ssh/id_rsa"
5454+];
5555+```
+44
docs/src/services/README.md
···11+# Services
22+33+All services run on **terebithia** (Oracle Cloud aarch64) behind Caddy with Cloudflare DNS TLS.
44+55+## mkService-based
66+77+| Service | Domain | Port | Runtime | Description |
88+|---------|--------|------|---------|-------------|
99+| cachet | cachet.dunkirk.sh | 3000 | bun | Slack emoji/profile cache |
1010+| hn-alerts | hn.dunkirk.sh | 3001 | bun | Hacker News monitoring |
1111+| indiko | indiko.dunkirk.sh | 3003 | bun | IndieAuth/OAuth2 server |
1212+| l4 | l4.dunkirk.sh | 3004 | bun | Image CDN — Slack image optimizer |
1313+| canvas-mcp | canvas.dunkirk.sh | 3006 | bun | Canvas MCP server |
1414+| control | control.dunkirk.sh | 3010 | bun | Admin dashboard for Caddy toggles |
1515+| traverse | traverse.dunkirk.sh | 4173 | bun | Code walkthrough diagram server |
1616+| cedarlogic | cedarlogic.dunkirk.sh | 3100 | custom | Circuit simulator |
1717+1818+## Multi-instance
1919+2020+| Service | Domain | Port | Description |
2121+|---------|--------|------|-------------|
2222+| emojibot-hackclub | hc.emojibot.dunkirk.sh | 3002 | Emojibot for Hack Club |
2323+| emojibot-df1317 | df.emojibot.dunkirk.sh | 3005 | Emojibot for df1317 |
2424+2525+## Custom / external
2626+2727+| Service | Domain | Description |
2828+|---------|--------|-------------|
2929+| bore (frps) | bore.dunkirk.sh | HTTP/TCP/UDP tunnel proxy |
3030+| herald | herald.dunkirk.sh | Git SSH hosting + email |
3131+| knot | knot.dunkirk.sh | Tangled git hosting |
3232+| spindle | spindle.dunkirk.sh | Tangled CI |
3333+| battleship-arena | battleship.dunkirk.sh | Battleship game server |
3434+| n8n | n8n.dunkirk.sh | Workflow automation |
3535+3636+## Architecture
3737+3838+Each mkService module provides:
3939+4040+- **Systemd service** — initial git clone for scaffolding, subsequent deploys via GitHub Actions
4141+- **Caddy reverse proxy** — TLS via Cloudflare DNS challenge, optional rate limiting
4242+- **Data declarations** — `sqlite`, `postgres`, `files` feed into automatic backups
4343+- **Dedicated user** — sudo for restart/stop/start, per-user Tailscale SSH ACLs
4444+- **Port conflict detection** — assertions prevent two services binding the same port
+21
docs/src/services/battleship-arena.md
···11+# battleship-arena
22+33+Battleship game server with web interface and SSH-based bot submission.
44+55+**Domain:** `battleship.dunkirk.sh` · **Web Port:** 8081 · **SSH Port:** 2222
66+77+This is a **custom module** — it does not use mkService.
88+99+## Options
1010+1111+| Option | Type | Default | Description |
1212+|--------|------|---------|-------------|
1313+| `enable` | bool | `false` | Enable battleship-arena |
1414+| `domain` | string | `"battleship.dunkirk.sh"` | Domain for Caddy reverse proxy |
1515+| `sshPort` | port | `2222` | SSH port for bot submissions |
1616+| `webPort` | port | `8081` | Web interface port |
1717+| `uploadDir` | string | `"/var/lib/battleship-arena/submissions"` | Bot upload directory |
1818+| `resultsDb` | string | `"/var/lib/battleship-arena/results.db"` | SQLite results database path |
1919+| `adminPasscode` | string | `"battleship-admin-override"` | Admin passcode |
2020+| `secretsFile` | path or null | `null` | Agenix secrets file |
2121+| `package` | package | — | Battleship-arena package (from flake input) |
+43
docs/src/services/bore.md
···11+# bore (server)
22+33+Lightweight tunneling server built on frp. Supports HTTP (wildcard subdomains), TCP, and UDP tunnels with optional OAuth authentication via Indiko.
44+55+**Domain:** `bore.dunkirk.sh` · **frp port:** 7000
66+77+This is a **custom module** — it does not use mkService.
88+99+## Options
1010+1111+| Option | Type | Default | Description |
1212+|--------|------|---------|-------------|
1313+| `enable` | bool | `false` | Enable bore server |
1414+| `domain` | string | — | Base domain for wildcard subdomains |
1515+| `bindAddr` | string | `"0.0.0.0"` | frps bind address |
1616+| `bindPort` | port | `7000` | frps bind port |
1717+| `vhostHTTPPort` | port | `7080` | Virtual host HTTP port |
1818+| `allowedTCPPorts` | list of ports | `20000–20099` | Ports available for TCP tunnels |
1919+| `allowedUDPPorts` | list of ports | `20000–20099` | Ports available for UDP tunnels |
2020+| `authToken` | string or null | `null` | frp auth token (use `authTokenFile` instead) |
2121+| `authTokenFile` | path or null | `null` | Path to file containing frp auth token |
2222+| `enableCaddy` | bool | `true` | Auto-configure Caddy wildcard vhost |
2323+2424+### Authentication
2525+2626+When enabled, all HTTP tunnels are gated behind Indiko OAuth. Users must sign in before accessing tunneled services.
2727+2828+| Option | Type | Default | Description |
2929+|--------|------|---------|-------------|
3030+| `auth.enable` | bool | `false` | Enable bore-auth OAuth middleware |
3131+| `auth.indikoURL` | string | `"https://indiko.dunkirk.sh"` | Indiko server URL |
3232+| `auth.clientID` | string | — | OAuth client ID from Indiko |
3333+| `auth.clientSecretFile` | path | — | Path to OAuth client secret |
3434+| `auth.cookieHashKeyFile` | path | — | 32-byte cookie signing key |
3535+| `auth.cookieBlockKeyFile` | path | — | 32-byte cookie encryption key |
3636+3737+After authentication, these headers are passed to tunneled services:
3838+3939+- `X-Auth-User` — user's profile URL
4040+- `X-Auth-Name` — display name
4141+- `X-Auth-Email` — email address
4242+4343+See [bore (client)](../modules/bore-client.md) for the home-manager client module.
+34
docs/src/services/cedarlogic.md
···11+# cedarlogic
22+33+Browser-based circuit simulator with real-time collaboration via WebSockets.
44+55+**Domain:** `cedarlogic.dunkirk.sh` · **Port:** 3100 · **Runtime:** custom
66+77+## Extra options
88+99+| Option | Type | Default | Description |
1010+|--------|------|---------|-------------|
1111+| `wsPort` | port | `3101` | Hocuspocus WebSocket server for document collaboration |
1212+| `cursorPort` | port | `3102` | Cursor relay WebSocket server for live cursors |
1313+| `branch` | string | `"web"` | Git branch to clone (uses `web` branch, not `main`) |
1414+1515+## Caddy routing
1616+1717+Cedarlogic disables the default mkService Caddy config and uses path-based routing to three backends:
1818+1919+| Path | Backend |
2020+|------|---------|
2121+| `/ws` | `wsPort` (Hocuspocus) |
2222+| `/cursor-ws` | `cursorPort` (cursor relay) |
2323+| `/api/*`, `/auth/*` | main `port` |
2424+| Everything else | Static files from `dist/` |
2525+2626+## Build step
2727+2828+Unlike other services, cedarlogic runs a build during deploy:
2929+3030+```
3131+bun install → parse-gates → bun run build (Vite)
3232+```
3333+3434+The build has a 120s timeout to accommodate Vite compilation.
+40
docs/src/services/control.md
···11+# control
22+33+Admin dashboard for Caddy feature toggles. Provides a web UI to enable/disable paths on other services (e.g. blocking player tracking on the map).
44+55+**Domain:** `control.dunkirk.sh` · **Port:** 3010 · **Runtime:** bun
66+77+## Extra options
88+99+### `flags`
1010+1111+Defines per-domain feature flags that control blocks paths and redacts JSON fields.
1212+1313+```nix
1414+atelier.services.control.flags."map.dunkirk.sh" = {
1515+ name = "Map";
1616+ flags = {
1717+ "block-tracking" = {
1818+ name = "Block Player Tracking";
1919+ description = "Disable real-time player location updates";
2020+ paths = [
2121+ "/sse"
2222+ "/sse/*"
2323+ "/tiles/*/markers/pl3xmap_players.json"
2424+ ];
2525+ redact."/tiles/settings.json" = [ "players" ];
2626+ };
2727+ };
2828+};
2929+```
3030+3131+| Option | Type | Default | Description |
3232+|--------|------|---------|-------------|
3333+| `flags` | attrsOf submodule | `{}` | Services and their feature flags, keyed by domain |
3434+| `flags.<domain>.name` | string | — | Display name for the service |
3535+| `flags.<domain>.flags.<id>.name` | string | — | Display name for the flag |
3636+| `flags.<domain>.flags.<id>.description` | string | — | What the flag does |
3737+| `flags.<domain>.flags.<id>.paths` | list of strings | `[]` | URL paths to block when flag is active |
3838+| `flags.<domain>.flags.<id>.redact` | attrsOf (list of strings) | `{}` | JSON fields to redact from responses, keyed by path |
3939+4040+The flags config is serialized to `flags.json` and passed to control via the `FLAGS_CONFIG` environment variable.
+42
docs/src/services/emojibot.md
···11+# emojibot
22+33+Slack emoji management service. Supports multiple instances for different workspaces.
44+55+**Runtime:** bun · **Stateless** (no database)
66+77+This is a **custom module** — it does not use mkService. Each instance gets its own systemd service, user, and Caddy virtual host.
88+99+## Instance options
1010+1111+Instances are defined under `atelier.services.emojibot.instances.<name>`:
1212+1313+```nix
1414+atelier.services.emojibot.instances = {
1515+ hackclub = {
1616+ enable = true;
1717+ domain = "hc.emojibot.dunkirk.sh";
1818+ port = 3002;
1919+ workspace = "hackclub";
2020+ channel = "C02T3CU03T3";
2121+ repository = "https://github.com/taciturnaxolotl/emojibot";
2222+ secretsFile = config.age.secrets."emojibot/hackclub".path;
2323+ };
2424+};
2525+```
2626+2727+| Option | Type | Default | Description |
2828+|--------|------|---------|-------------|
2929+| `enable` | bool | `false` | Enable this instance |
3030+| `domain` | string | — | Domain for Caddy reverse proxy |
3131+| `port` | port | — | Port to run on |
3232+| `secretsFile` | path | — | Agenix secrets file with Slack credentials |
3333+| `repository` | string | `"https://github.com/taciturnaxolotl/emojibot"` | Git repo URL |
3434+| `workspace` | string or null | `null` | Slack workspace name (for identification) |
3535+| `channel` | string or null | `null` | Slack channel ID |
3636+3737+## Current instances
3838+3939+| Instance | Domain | Port | Workspace |
4040+|----------|--------|------|-----------|
4141+| hackclub | hc.emojibot.dunkirk.sh | 3002 | Hack Club |
4242+| df1317 | df.emojibot.dunkirk.sh | 3005 | df1317 |
+39
docs/src/services/herald.md
···11+# herald
22+33+Git SSH hosting with email notifications. Provides a git push interface over SSH and sends email via SMTP/DKIM.
44+55+**Domain:** `herald.dunkirk.sh` · **SSH Port:** 2223 · **HTTP Port:** 8085
66+77+This is a **custom module** — it does not use mkService.
88+99+## Options
1010+1111+| Option | Type | Default | Description |
1212+|--------|------|---------|-------------|
1313+| `enable` | bool | `false` | Enable herald |
1414+| `domain` | string | — | Domain for Caddy reverse proxy |
1515+| `host` | string | `"0.0.0.0"` | Listen address |
1616+| `sshPort` | port | `2223` | SSH listen port |
1717+| `externalSshPort` | port | `2223` | External SSH port (if behind NAT) |
1818+| `httpPort` | port | `8085` | HTTP API port |
1919+| `dataDir` | path | `"/var/lib/herald"` | Data directory |
2020+| `allowAllKeys` | bool | `true` | Allow all SSH keys |
2121+| `secretsFile` | path | — | Agenix secrets (must contain `SMTP_PASS`) |
2222+| `package` | package | `pkgs.herald` | Herald package |
2323+2424+### SMTP
2525+2626+| Option | Type | Default | Description |
2727+|--------|------|---------|-------------|
2828+| `smtp.host` | string | — | SMTP server hostname |
2929+| `smtp.port` | port | `587` | SMTP server port |
3030+| `smtp.user` | string | — | SMTP username |
3131+| `smtp.from` | string | — | Sender address |
3232+3333+### DKIM
3434+3535+| Option | Type | Default | Description |
3636+|--------|------|---------|-------------|
3737+| `smtp.dkim.selector` | string or null | `null` | DKIM selector |
3838+| `smtp.dkim.domain` | string or null | `null` | DKIM signing domain |
3939+| `smtp.dkim.privateKeyFile` | path or null | `null` | Path to DKIM private key |
+16
docs/src/services/knot-sync.md
···11+# knot-sync
22+33+Mirrors Tangled knot repositories to GitHub on a cron schedule.
44+55+This is a **custom module** — it does not use mkService. Runs as a systemd timer, not a long-running service.
66+77+## Options
88+99+| Option | Type | Default | Description |
1010+|--------|------|---------|-------------|
1111+| `enable` | bool | `false` | Enable knot-sync |
1212+| `repoDir` | string | `"/home/git/did:plc:..."` | Directory containing knot git repos |
1313+| `githubUsername` | string | `"taciturnaxolotl"` | GitHub username to mirror to |
1414+| `secretsFile` | path | — | Agenix secrets (must contain `GITHUB_TOKEN`) |
1515+| `logFile` | string | `"/home/git/knot-sync.log"` | Log file path |
1616+| `interval` | string | `"*/5 * * * *"` | Cron schedule for sync |
···11-# Fish completion for anthropic-manager
22-33-# Helper function to get profile list
44-function __anthropic_manager_profiles
55- set -l config_dir (test -n "$ANTHROPIC_CONFIG_DIR"; and echo $ANTHROPIC_CONFIG_DIR; or echo "$HOME/.config/crush")
66- if test -d "$config_dir"
77- find "$config_dir" -maxdepth 1 -type d -name "anthropic.*" 2>/dev/null | sed 's/.*anthropic\.//' | sort
88- end
99-end
1010-1111-# Main options
1212-complete -c anthropic-manager -s h -l help -d "Show help information"
1313-complete -c anthropic-manager -s i -l init -d "Initialize a new profile" -xa "(__anthropic_manager_profiles)"
1414-complete -c anthropic-manager -s s -l swap -d "Switch to a profile" -xa "(__anthropic_manager_profiles)"
1515-complete -c anthropic-manager -s d -l delete -d "Delete a profile" -xa "(__anthropic_manager_profiles)"
1616-complete -c anthropic-manager -s t -l token -d "Print current bearer token"
1717-complete -c anthropic-manager -s l -l list -d "List all profiles"
1818-complete -c anthropic-manager -s c -l current -d "Show current profile"
···11-#compdef anthropic-manager
22-33-_anthropic_manager() {
44- local config_dir="${ANTHROPIC_CONFIG_DIR:-$HOME/.config/crush}"
55- local -a profiles
66-77- # Get list of profiles
88- if [[ -d "$config_dir" ]]; then
99- profiles=(${(f)"$(find "$config_dir" -maxdepth 1 -type d -name "anthropic.*" 2>/dev/null | sed 's/.*anthropic\.//' | sort)"})
1010- fi
1111-1212- _arguments -C \
1313- '(- *)'{-h,--help}'[Show help information]' \
1414- '(-i --init)'{-i,--init}'[Initialize a new profile]:profile name:' \
1515- '(-s --swap)'{-s,--swap}'[Switch to a profile]:profile:($profiles)' \
1616- '(-d --delete)'{-d,--delete}'[Delete a profile]:profile:($profiles)' \
1717- '(-t --token)'{-t,--token}'[Print current bearer token]' \
1818- '(-l --list)'{-l,--list}'[List all profiles]' \
1919- '(-c --current)'{-c,--current}'[Show current profile]'
2020-}
2121-2222-_anthropic_manager "$@"
-561
modules/home/apps/anthropic-manager/default.nix
···11-{
22- lib,
33- pkgs,
44- config,
55- ...
66-}:
77-let
88- cfg = config.atelier.apps.anthropic-manager;
99-1010- anthropicManagerScript = pkgs.writeShellScript "anthropic-manager" ''
1111- # Manage Anthropic OAuth credential profiles
1212- # Implements the same functionality as anthropic-api-key but with profile management
1313-1414- set -uo pipefail
1515-1616- CONFIG_DIR="''${ANTHROPIC_CONFIG_DIR:-$HOME/.config/crush}"
1717- CLIENT_ID="9d1c250a-e61b-44d9-88ed-5944d1962f5e"
1818-1919- # Utilities
2020- base64url() {
2121- ${pkgs.coreutils}/bin/base64 -w0 | ${pkgs.gnused}/bin/sed 's/=//g; s/+/-/g; s/\//_/g'
2222- }
2323-2424- sha256() {
2525- echo -n "$1" | ${pkgs.openssl}/bin/openssl dgst -binary -sha256
2626- }
2727-2828- pkce_pair() {
2929- verifier=$(${pkgs.openssl}/bin/openssl rand 32 | base64url)
3030- challenge=$(printf '%s' "$verifier" | ${pkgs.openssl}/bin/openssl dgst -binary -sha256 | base64url)
3131- echo "$verifier $challenge"
3232- }
3333-3434- authorize_url() {
3535- local challenge="$1"
3636- local state="$2"
3737- echo "https://claude.ai/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=https://console.anthropic.com/oauth/code/callback&scope=org:create_api_key+user:profile+user:inference+user:sessions:claude_code&code_challenge=$challenge&code_challenge_method=S256&state=$state"
3838- }
3939-4040- clean_pasted_code() {
4141- local input="$1"
4242- input="''${input#code:}"
4343- input="''${input#code=}"
4444- input="''${input#\"}"
4545- input="''${input%\"}"
4646- input="''${input#\'}"
4747- input="''${input%\'}"
4848- input="''${input#\`}"
4949- input="''${input%\`}"
5050- echo "$input" | ${pkgs.gnused}/bin/sed -E 's/[^A-Za-z0-9._~#-]//g'
5151- }
5252-5353- exchange_code() {
5454- local code="$1"
5555- local verifier="$2"
5656- local cleaned
5757- cleaned=$(clean_pasted_code "$code")
5858- local pure="''${cleaned%%#*}"
5959- local state="''${cleaned#*#}"
6060- [[ "$state" == "$pure" ]] && state=""
6161-6262- ${pkgs.curl}/bin/curl -s -X POST \
6363- -H "Content-Type: application/json" \
6464- -H "User-Agent: anthropic-manager/1.0" \
6565- -d "$(${pkgs.jq}/bin/jq -n \
6666- --arg code "$pure" \
6767- --arg state "$state" \
6868- --arg verifier "$verifier" \
6969- '{
7070- code: $code,
7171- state: $state,
7272- grant_type: "authorization_code",
7373- client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
7474- redirect_uri: "https://console.anthropic.com/oauth/code/callback",
7575- code_verifier: $verifier
7676- }')" \
7777- "https://console.anthropic.com/v1/oauth/token"
7878- }
7979-8080- exchange_refresh() {
8181- local refresh_token="$1"
8282- ${pkgs.curl}/bin/curl -s -X POST \
8383- -H "Content-Type: application/json" \
8484- -H "User-Agent: anthropic-manager/1.0" \
8585- -d "$(${pkgs.jq}/bin/jq -n \
8686- --arg refresh "$refresh_token" \
8787- '{
8888- grant_type: "refresh_token",
8989- refresh_token: $refresh,
9090- client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
9191- }')" \
9292- "https://console.anthropic.com/v1/oauth/token"
9393- }
9494-9595- save_tokens() {
9696- local profile_dir="$1"
9797- local access_token="$2"
9898- local refresh_token="$3"
9999- local expires_at="$4"
100100-101101- mkdir -p "$profile_dir"
102102- echo -n "$access_token" > "$profile_dir/bearer_token"
103103- echo -n "$refresh_token" > "$profile_dir/refresh_token"
104104- echo -n "$expires_at" > "$profile_dir/bearer_token.expires"
105105- chmod 600 "$profile_dir/bearer_token" "$profile_dir/refresh_token" "$profile_dir/bearer_token.expires"
106106- }
107107-108108- load_tokens() {
109109- local profile_dir="$1"
110110- [[ -f "$profile_dir/bearer_token" ]] || return 1
111111- [[ -f "$profile_dir/refresh_token" ]] || return 1
112112- [[ -f "$profile_dir/bearer_token.expires" ]] || return 1
113113-114114- cat "$profile_dir/bearer_token"
115115- cat "$profile_dir/refresh_token"
116116- cat "$profile_dir/bearer_token.expires"
117117- return 0
118118- }
119119-120120- get_token() {
121121- local profile_dir="$1"
122122- local print_token="''${2:-true}"
123123-124124- if ! load_tokens "$profile_dir" >/dev/null 2>&1; then
125125- return 1
126126- fi
127127-128128- local bearer refresh expires
129129- read -r bearer < "$profile_dir/bearer_token"
130130- read -r refresh < "$profile_dir/refresh_token"
131131- read -r expires < "$profile_dir/bearer_token.expires"
132132-133133- local now
134134- now=$(date +%s)
135135-136136- # If token valid for more than 60s, return it
137137- if [[ $now -lt $((expires - 60)) ]]; then
138138- [[ "$print_token" == "true" ]] && echo "$bearer"
139139- return 0
140140- fi
141141-142142- # Try to refresh
143143- local response
144144- response=$(exchange_refresh "$refresh")
145145-146146- if ! echo "$response" | ${pkgs.jq}/bin/jq -e '.access_token' >/dev/null 2>&1; then
147147- return 1
148148- fi
149149-150150- local new_access new_refresh new_expires_in
151151- new_access=$(echo "$response" | ${pkgs.jq}/bin/jq -r '.access_token')
152152- new_refresh=$(echo "$response" | ${pkgs.jq}/bin/jq -r '.refresh_token // empty')
153153- new_expires_in=$(echo "$response" | ${pkgs.jq}/bin/jq -r '.expires_in')
154154-155155- [[ -z "$new_refresh" ]] && new_refresh="$refresh"
156156- local new_expires=$((now + new_expires_in))
157157-158158- save_tokens "$profile_dir" "$new_access" "$new_refresh" "$new_expires"
159159- [[ "$print_token" == "true" ]] && echo "$new_access"
160160- return 0
161161- }
162162-163163- oauth_flow() {
164164- local profile_dir="$1"
165165-166166- ${pkgs.gum}/bin/gum style --foreground 212 "Starting OAuth flow..."
167167- echo
168168-169169- read -r verifier challenge < <(pkce_pair)
170170- local state
171171- state=$(${pkgs.openssl}/bin/openssl rand -base64 32 | ${pkgs.gnused}/bin/sed 's/[^A-Za-z0-9]//g')
172172- local auth_url
173173- auth_url=$(authorize_url "$challenge" "$state")
174174-175175- ${pkgs.gum}/bin/gum style --foreground 35 "Opening browser for authorization..."
176176- ${pkgs.gum}/bin/gum style --foreground 117 "$auth_url"
177177- echo
178178-179179- if command -v ${pkgs.xdg-utils}/bin/xdg-open &>/dev/null; then
180180- ${pkgs.xdg-utils}/bin/xdg-open "$auth_url" 2>/dev/null &
181181- elif command -v open &>/dev/null; then
182182- open "$auth_url" 2>/dev/null &
183183- fi
184184-185185- local code
186186- code=$(${pkgs.gum}/bin/gum input --placeholder "Paste the authorization code from Anthropic" --prompt "Code: ")
187187-188188- if [[ -z "$code" ]]; then
189189- ${pkgs.gum}/bin/gum style --foreground 196 "No code provided"
190190- return 1
191191- fi
192192-193193- ${pkgs.gum}/bin/gum style --foreground 212 "Exchanging code for tokens..."
194194-195195- local response
196196- response=$(exchange_code "$code" "$verifier")
197197-198198- if ! echo "$response" | ${pkgs.jq}/bin/jq -e '.access_token' >/dev/null 2>&1; then
199199- ${pkgs.gum}/bin/gum style --foreground 196 "Failed to exchange code"
200200- echo "$response" | ${pkgs.jq}/bin/jq '.' 2>&1 || echo "$response"
201201- return 1
202202- fi
203203-204204- local access_token refresh_token expires_in
205205- access_token=$(echo "$response" | ${pkgs.jq}/bin/jq -r '.access_token')
206206- refresh_token=$(echo "$response" | ${pkgs.jq}/bin/jq -r '.refresh_token')
207207- expires_in=$(echo "$response" | ${pkgs.jq}/bin/jq -r '.expires_in')
208208-209209- local expires_at
210210- expires_at=$(($(date +%s) + expires_in))
211211-212212- save_tokens "$profile_dir" "$access_token" "$refresh_token" "$expires_at"
213213- ${pkgs.gum}/bin/gum style --foreground 35 "✓ Authenticated successfully"
214214- return 0
215215- }
216216-217217- list_profiles() {
218218- ${pkgs.gum}/bin/gum style --bold --foreground 212 "Available Anthropic profiles:"
219219- echo
220220-221221- local current_profile=""
222222- if [[ -L "$CONFIG_DIR/anthropic" ]]; then
223223- current_profile=$(basename "$(readlink "$CONFIG_DIR/anthropic")" | ${pkgs.gnused}/bin/sed 's/^anthropic\.//')
224224- fi
225225-226226- local found_any=false
227227- for profile_dir in "$CONFIG_DIR"/anthropic.*; do
228228- if [[ -d "$profile_dir" ]]; then
229229- found_any=true
230230- local profile_name
231231- profile_name=$(basename "$profile_dir" | ${pkgs.gnused}/bin/sed 's/^anthropic\.//')
232232-233233- local status=""
234234- if get_token "$profile_dir" false 2>/dev/null; then
235235- local expires
236236- read -r expires < "$profile_dir/bearer_token.expires"
237237- local now
238238- now=$(date +%s)
239239- if [[ $now -lt $expires ]]; then
240240- status=" (valid)"
241241- else
242242- status=" (expired)"
243243- fi
244244- else
245245- status=" (invalid)"
246246- fi
247247-248248- if [[ "$profile_name" == "$current_profile" ]]; then
249249- ${pkgs.gum}/bin/gum style --foreground 35 " ✓ $profile_name$status (active)"
250250- else
251251- echo " $profile_name$status"
252252- fi
253253- fi
254254- done
255255-256256- if [[ "$found_any" == "false" ]]; then
257257- ${pkgs.gum}/bin/gum style --foreground 214 "No profiles found. Use 'anthropic-manager --init <name>' to create one."
258258- fi
259259- }
260260-261261- show_current() {
262262- if [[ -L "$CONFIG_DIR/anthropic" ]]; then
263263- local current
264264- current=$(basename "$(readlink "$CONFIG_DIR/anthropic")" | ${pkgs.gnused}/bin/sed 's/^anthropic\.//')
265265- ${pkgs.gum}/bin/gum style --foreground 35 "Current profile: $current"
266266- else
267267- ${pkgs.gum}/bin/gum style --foreground 214 "No active profile"
268268- fi
269269- }
270270-271271- init_profile() {
272272- local profile="$1"
273273-274274- if [[ -z "$profile" ]]; then
275275- profile=$(${pkgs.gum}/bin/gum input --placeholder "Profile name (e.g., work, personal)" --prompt "Profile name: ")
276276- if [[ -z "$profile" ]]; then
277277- ${pkgs.gum}/bin/gum style --foreground 196 "No profile name provided"
278278- exit 1
279279- fi
280280- fi
281281-282282- local profile_dir="$CONFIG_DIR/anthropic.$profile"
283283-284284- if [[ -d "$profile_dir" ]]; then
285285- ${pkgs.gum}/bin/gum style --foreground 214 "Profile '$profile' already exists"
286286- if ${pkgs.gum}/bin/gum confirm "Re-authenticate?"; then
287287- rm -rf "$profile_dir"
288288- else
289289- exit 1
290290- fi
291291- fi
292292-293293- if ! oauth_flow "$profile_dir"; then
294294- rm -rf "$profile_dir"
295295- exit 1
296296- fi
297297-298298- # Ask to set as active
299299- if [[ ! -L "$CONFIG_DIR/anthropic" ]] || ${pkgs.gum}/bin/gum confirm "Set '$profile' as active profile?"; then
300300- [[ -L "$CONFIG_DIR/anthropic" ]] && rm "$CONFIG_DIR/anthropic"
301301- ln -sf "anthropic.$profile" "$CONFIG_DIR/anthropic"
302302- ${pkgs.gum}/bin/gum style --foreground 35 "✓ Set as active profile"
303303- fi
304304- }
305305-306306- delete_profile() {
307307- local target="$1"
308308-309309- if [[ -z "$target" ]]; then
310310- # Interactive selection
311311- local profiles=()
312312- for profile_dir in "$CONFIG_DIR"/anthropic.*; do
313313- if [[ -d "$profile_dir" ]]; then
314314- profiles+=("$(basename "$profile_dir" | ${pkgs.gnused}/bin/sed 's/^anthropic\.//')")
315315- fi
316316- done
317317-318318- if [[ ''${#profiles[@]} -eq 0 ]]; then
319319- ${pkgs.gum}/bin/gum style --foreground 196 "No profiles found"
320320- exit 1
321321- fi
322322-323323- target=$(printf '%s\n' "''${profiles[@]}" | ${pkgs.gum}/bin/gum choose --header "Select profile to delete:")
324324- [[ -z "$target" ]] && exit 0
325325- fi
326326-327327- local target_dir="$CONFIG_DIR/anthropic.$target"
328328- if [[ ! -d "$target_dir" ]]; then
329329- ${pkgs.gum}/bin/gum style --foreground 196 "Profile '$target' does not exist"
330330- exit 1
331331- fi
332332-333333- if ! ${pkgs.gum}/bin/gum confirm "Delete profile '$target'?"; then
334334- exit 0
335335- fi
336336-337337- # Check if this is the active profile
338338- if [[ -L "$CONFIG_DIR/anthropic" ]]; then
339339- local current
340340- current=$(basename "$(readlink "$CONFIG_DIR/anthropic")" | ${pkgs.gnused}/bin/sed 's/^anthropic\.//')
341341- if [[ "$current" == "$target" ]]; then
342342- rm "$CONFIG_DIR/anthropic"
343343- ${pkgs.gum}/bin/gum style --foreground 214 "Unlinked active profile"
344344- fi
345345- fi
346346-347347- rm -rf "$target_dir"
348348- ${pkgs.gum}/bin/gum style --foreground 35 "✓ Deleted profile '$target'"
349349- }
350350-351351- swap_profile() {
352352- local target="$1"
353353-354354- if [[ -n "$target" ]]; then
355355- local target_dir="$CONFIG_DIR/anthropic.$target"
356356- if [[ ! -d "$target_dir" ]]; then
357357- ${pkgs.gum}/bin/gum style --foreground 196 "Profile '$target' does not exist"
358358- echo
359359- list_profiles
360360- exit 1
361361- fi
362362-363363- [[ -L "$CONFIG_DIR/anthropic" ]] && rm "$CONFIG_DIR/anthropic"
364364- ln -sf "anthropic.$target" "$CONFIG_DIR/anthropic"
365365- ${pkgs.gum}/bin/gum style --foreground 35 "✓ Switched to profile '$target'"
366366- exit 0
367367- fi
368368-369369- # Interactive selection
370370- local profiles=()
371371- for profile_dir in "$CONFIG_DIR"/anthropic.*; do
372372- if [[ -d "$profile_dir" ]]; then
373373- profiles+=("$(basename "$profile_dir" | ${pkgs.gnused}/bin/sed 's/^anthropic\.//')")
374374- fi
375375- done
376376-377377- if [[ ''${#profiles[@]} -eq 0 ]]; then
378378- ${pkgs.gum}/bin/gum style --foreground 196 "No profiles found"
379379- ${pkgs.gum}/bin/gum style --foreground 214 "Use 'anthropic-manager --init <name>' to create one"
380380- exit 1
381381- fi
382382-383383- local selected
384384- selected=$(printf '%s\n' "''${profiles[@]}" | ${pkgs.gum}/bin/gum choose --header "Select profile:")
385385-386386- if [[ -n "$selected" ]]; then
387387- [[ -L "$CONFIG_DIR/anthropic" ]] && rm "$CONFIG_DIR/anthropic"
388388- ln -sf "anthropic.$selected" "$CONFIG_DIR/anthropic"
389389- ${pkgs.gum}/bin/gum style --foreground 35 "✓ Switched to profile '$selected'"
390390- fi
391391- }
392392-393393- print_token() {
394394- if [[ ! -L "$CONFIG_DIR/anthropic" ]]; then
395395- echo "Error: No active profile" >&2
396396- exit 1
397397- fi
398398-399399- local profile_dir
400400- profile_dir=$(readlink -f "$CONFIG_DIR/anthropic")
401401-402402- if ! get_token "$profile_dir" true 2>/dev/null; then
403403- echo "Error: Token invalid or expired" >&2
404404- exit 1
405405- fi
406406- }
407407-408408- interactive_menu() {
409409- echo
410410- ${pkgs.gum}/bin/gum style --bold --foreground 212 "Anthropic Profile Manager"
411411- echo
412412-413413- local current_profile=""
414414- if [[ -L "$CONFIG_DIR/anthropic" ]]; then
415415- current_profile=$(basename "$(readlink "$CONFIG_DIR/anthropic")" | ${pkgs.gnused}/bin/sed 's/^anthropic\.//')
416416- ${pkgs.gum}/bin/gum style --foreground 117 "Active: $current_profile"
417417- else
418418- ${pkgs.gum}/bin/gum style --foreground 214 "No active profile"
419419- fi
420420-421421- echo
422422-423423- local choice
424424- choice=$(${pkgs.gum}/bin/gum choose \
425425- "Switch profile" \
426426- "Create new profile" \
427427- "Delete profile" \
428428- "List all profiles" \
429429- "Get current token")
430430-431431- case "$choice" in
432432- "Switch profile")
433433- swap_profile ""
434434- ;;
435435- "Create new profile")
436436- init_profile ""
437437- ;;
438438- "Delete profile")
439439- echo
440440- delete_profile ""
441441- ;;
442442- "List all profiles")
443443- echo
444444- list_profiles
445445- ;;
446446- "Get current token")
447447- echo
448448- print_token
449449- ;;
450450- esac
451451- }
452452-453453- # Main
454454- mkdir -p "$CONFIG_DIR"
455455-456456- case "''${1:-}" in
457457- --init|-i)
458458- init_profile "''${2:-}"
459459- ;;
460460- --list|-l)
461461- list_profiles
462462- ;;
463463- --current|-c)
464464- show_current
465465- ;;
466466- --token|-t|token)
467467- print_token
468468- ;;
469469- --swap|-s|swap)
470470- swap_profile "''${2:-}"
471471- ;;
472472- --delete|-d|delete)
473473- delete_profile "''${2:-}"
474474- ;;
475475- --help|-h|help)
476476- ${pkgs.gum}/bin/gum style --bold --foreground 212 "anthropic-manager - Manage Anthropic OAuth profiles"
477477- echo
478478- echo "Usage:"
479479- echo " anthropic-manager Interactive menu"
480480- echo " anthropic-manager --init [profile] Initialize/create a new profile"
481481- echo " anthropic-manager --swap [profile] Switch to a profile (interactive if no profile given)"
482482- echo " anthropic-manager --delete [profile] Delete a profile (interactive if no profile given)"
483483- echo " anthropic-manager --token Print current bearer token (refresh if needed)"
484484- echo " anthropic-manager --list List all profiles with status"
485485- echo " anthropic-manager --current Show current active profile"
486486- echo " anthropic-manager --help Show this help"
487487- echo
488488- echo "Examples:"
489489- echo " anthropic-manager Open interactive menu"
490490- echo " anthropic-manager --init work Create 'work' profile"
491491- echo " anthropic-manager --swap work Switch to 'work' profile"
492492- echo " anthropic-manager --delete work Delete 'work' profile"
493493- echo " anthropic-manager --token Get current bearer token"
494494- ;;
495495- "")
496496- # No args - check if interactive
497497- if [[ ! -t 0 ]] || [[ ! -t 1 ]]; then
498498- echo "Error: anthropic-manager requires an interactive terminal when called without arguments" >&2
499499- exit 1
500500- fi
501501- interactive_menu
502502- ;;
503503- *)
504504- ${pkgs.gum}/bin/gum style --foreground 196 "Unknown option: $1"
505505- echo "Use --help for usage information"
506506- exit 1
507507- ;;
508508- esac
509509- '';
510510-511511- anthropicManager = pkgs.stdenv.mkDerivation {
512512- pname = "anthropic-manager";
513513- version = "1.0";
514514-515515- dontUnpack = true;
516516-517517- nativeBuildInputs = with pkgs; [ pandoc installShellFiles ];
518518-519519- manPageSrc = ./anthropic-manager.1.md;
520520- bashCompletionSrc = ./completions/anthropic-manager.bash;
521521- zshCompletionSrc = ./completions/anthropic-manager.zsh;
522522- fishCompletionSrc = ./completions/anthropic-manager.fish;
523523-524524- buildPhase = ''
525525- # Convert markdown man page to man format
526526- ${pkgs.pandoc}/bin/pandoc -s -t man $manPageSrc -o anthropic-manager.1
527527- '';
528528-529529- installPhase = ''
530530- mkdir -p $out/bin
531531-532532- # Install binary
533533- cp ${anthropicManagerScript} $out/bin/anthropic-manager
534534- chmod +x $out/bin/anthropic-manager
535535-536536- # Install man page
537537- installManPage anthropic-manager.1
538538-539539- # Install completions
540540- installShellCompletion --bash --name anthropic-manager $bashCompletionSrc
541541- installShellCompletion --zsh --name _anthropic-manager $zshCompletionSrc
542542- installShellCompletion --fish --name anthropic-manager.fish $fishCompletionSrc
543543- '';
544544-545545- meta = with lib; {
546546- description = "Anthropic OAuth profile manager";
547547- homepage = "https://github.com/taciturnaxolotl/dots";
548548- license = licenses.mit;
549549- maintainers = [ ];
550550- };
551551- };
552552-in
553553-{
554554- options.atelier.apps.anthropic-manager.enable = lib.mkEnableOption "Enable anthropic-manager";
555555-556556- config = lib.mkIf cfg.enable {
557557- home.packages = [
558558- anthropicManager
559559- ];
560560- };
561561-}
+21
modules/lib/mkService.nix
···8989 description = "Git repository URL — cloned once on first start for scaffolding";
9090 };
91919292+ healthUrl = lib.mkOption {
9393+ type = lib.types.nullOr lib.types.str;
9494+ default = null;
9595+ description = "Health check URL for monitoring";
9696+ };
9797+9898+ # Internal metadata set by mkService factory — used by services-manifest
9999+ _description = lib.mkOption {
100100+ type = lib.types.str;
101101+ default = description;
102102+ internal = true;
103103+ readOnly = true;
104104+ };
105105+106106+ _runtime = lib.mkOption {
107107+ type = lib.types.str;
108108+ default = runtime;
109109+ internal = true;
110110+ readOnly = true;
111111+ };
112112+92113 # Data declarations for automatic backup
93114 data = {
94115 sqlite = lib.mkOption {