Files
command-center/omp-docs/tools/dotfiles-deploy.md
Frank f3ce42eb80 fix(deploy): report what enable actually did, and arm the tier's timers
Two lies in the unit loop, both reported as success:

- `systemctl --user enable live-sync.service` fails on every deploy — the file has
  no [Install] section, because live-sync.timer is what arms it. The result was
  swallowed by `|| true` and the tool printed ENABLE anyway, so the line an
  operator reads after a rebuild was false on every box, on the repo's own
  heartbeat. The [Install] section is readable from the file being deployed, so
  the tool now checks it and prints INERT with the reason instead of making a
  call that cannot succeed; a real enable failure prints INERT too.
- The loop walked *.service only. live-sync.timer was never enabled by anything,
  so a rebuilt box got the heartbeat and no schedule for it. Timers are walked
  now; a timer has no ExecStart, so the "does this unit belong on this box" test
  runs against the service it activates.

The systemctl stub models systemd's refusal (no [Install] section -> non-zero),
which is what makes the new tests red against the old loop; both new tests were
run red first, and the five existing unit tests still pin #44's semantics.

Refs #60
2026-10-21 00:59:13 -07:00

279 lines
17 KiB
Markdown

---
type: reference
title: Dotfiles deployment tier
description: How dotfiles/ packages become live configuration — deploy.sh modes, host override packages, and the tier's hard-won laws, from media and generated theme output to systemd enable/start semantics, dconf dumps, and idle-lock watcher triage.
tags:
- dotfiles
- deployment
- systemd
- noctalia
timestamp: 2026-10-20
verified: 2026-10-21
---
# Dotfiles deployment tier
`dotfiles/` holds home-relative trees and `scripts/deploy.sh` is a stow-equivalent: the
repo checkout **is** the deployment, and deploying replaces live files with symlinks into
the repo, so `git pull` + `deploy.sh` is the whole propagation story. One directory per
tool (`niri`, `ghostty`, `gtk-3.0`, `systemd-user`, …); a package named `zz-$(hostname)`
is stowed last and its files replace symlinks coming from base packages.
| Mode | Effect |
|---|---|
| `deploy.sh --check` | dry run — adopts identicals, reports what would be linked, lists divergents, writes nothing. Run this first, always |
| `deploy.sh` (sync) | adopt identicals, link new, quarantine divergents into `.quarantine/` and replace them |
| `deploy.sh --adopt` | like sync, but divergents are reported and skipped rather than quarantined |
Two package shapes are not symlinks. `dconf/dump.txt` is loaded with `dconf load /`. The
`systemd-user` tier gets `daemon-reload`, then **every `*.service` and `*.timer` whose start
command resolves on this box** is enabled — a timer is the schedule that makes a oneshot service
run at all, and it carries no `ExecStart` of its own, so the belonging test is applied to the
service it activates. A unit is **started** (`--now`) only when systemd reports it as nothing-yet
(`""`/`disabled`), and never when its binary is absent here, so a unit belonging to another box
stays inert instead of crash-looping. Three outcomes are printed, and each says what actually
happened: `ENABLE` only when `systemctl enable` returned success, `INERT` when it could not or did
not, `SKIP` when the unit is foreign. Refs #44, #60.
Starting something already installed is what makes `deploy.sh` unsafe to run beside a live
session — see *Never start a unit a session may already be running*.
**The tracked unit is the file, not the directory.** A live directory can be a real
directory and still be fully tracked, because every file inside it is a symlink into the
repo (`niri` is 8 tracked / 2 box-only; `gtk-3.0` is 3/0). Any audit that classifies by
directory mislabels the tree — classify per file with `readlink -f`. The live census and
its classifier command are in [home-config-census](/inventory/home-config-census.md).
## Law: media the theme pipeline consumes is content, not config
Wallpapers live at `dotfiles/noctalia/.local/share/noctalia/wallpapers/` and are
gitignored. They were tracked until 2026-10-20 — 23 files, 80 MiB — which meant every
clone of a *configuration* repo carried a media library no diff can review, and the
payload only ever grows because deleting a tracked blob from the tree does not delete it
from history. The topology is not what one would guess: [Noctalia](noctalia-shell.md)
keeps its own library at `~/.local/share/noctalia/wallpapers/` as a **real directory of
real files**, so the tracked tree was a duplicate of it and never its source — measured
2026-10-20, when the merge of #41 deleted the repo copies and the desktop did not
flicker. Two consequences to know before untracking media: the copy that survives belongs
to the application, not to the repo; and untracking deletes the file in every other
checkout, `git rm --cached` on the branch notwithstanding.
Restore on a fresh box: the library lives in `~/.local/share/noctalia/wallpapers/`, a path
`deploy.sh` does not manage. Every filename is `wallhaven_<id>.<ext>` and the ID is the
whole address — but `https://wallhaven.cc/w/<id>` answers `text/html`, the web page, so
resolve the direct CDN URL through the API and download that:
```bash
id=73g8xo # the wallhaven_<id> stem of any file in the set
url=$(curl -s "https://wallhaven.cc/api/v1/w/$id" | grep -o '"path":"[^"]*' | cut -d'"' -f4)
curl -L -o ~/.local/share/noctalia/wallpapers/"${url##*/}" "$url"
```
`data.path` already ends in `wallhaven_<id>.<ext>`, so the filename keeps itself. Losing it
is an acceptable outcome; 80 MB per clone forever is not.
## Law: never `systemctl --user enable` a unit from a worktree
`enable` writes `~/.config/systemd/user/default.target.wants/<unit>` as a symlink to the
unit path it was handed, and that path is absolute. The heartbeat reaps `wt/*` worktrees
older than seven days ([live-sync](live-sync.md)), so a unit enabled from a worktree turns
into a dangling symlink while `systemctl --user is-enabled` still answers `enabled` — the
unit is dead and the status lies about it. Found on 2026-10-20:
`blankpad-desk-autoswitch.service` pointed into the reaped `wt/de-redesign-20261009`, so
desk auto-switching had been silently off since the reap.
Check the link target before the unit file:
```bash
ls -l ~/.config/systemd/user/default.target.wants/
# every target must resolve; a dangling one is a reaped worktree, not a config bug
```
Fix is to relink into the deployment checkout (`~/.omp/dotfiles/…`), `systemctl --user
daemon-reload`, then confirm with `is-active` — `is-enabled` proves nothing.
## Law: a generated file must never be a required include
`~/.config/niri/config.kdl` ends with `include "noctalia.kdl"` — no `optional=true`.
That file is noctalia's palette output (`#ff8c00`, `#f43841`, one re-stamp
`2026-10-18 17:36:27`), so it is machine state and correctly untracked. But on a fresh
box `deploy.sh` links `config.kdl` first, and niri then refuses to start until noctalia
has run once and written the file — the compositor is bricked by the order of operations,
not by a bad config. Close it at the include, which survives someone skipping the theme
pipeline: mark generated includes `optional=true`, as the `display.kdl` and
`switch-events.kdl` lines in the same file already are.
## Desk choice is live state, never config
`~/.config/niri/cfg/display.kdl` is a relative symlink to whichever profile is active
(`desk-1-home.kdl`, `desk-2-portable.kdl`), written by `desk.sh`. Both profiles are
tracked in `dotfiles/zz-blankpad/`; the pointer is not, because which desk the box sits on
is not a fact about the repo. A fresh box therefore starts with no output configuration —
`config.kdl` includes `display.kdl` with `optional=true`, so niri comes up usable but with
default monitors until `desk.sh` is run once.
## Law: generated theme output is never deployed
noctalia rewrites palette output in place — `noctalia.*`, `themes/noctalia*`,
`colors/noctalia*`, `noctalia-*` (the classes the [census](/inventory/home-config-census.md)
proves by same-millisecond mtime). A tracked copy of one is a stale snapshot of a colour
scheme, and the failure is quiet: `dotfiles/zz-blankpad/niri/.config/niri/noctalia.kdl`
(commit `b3d3df4`, 2026-10-18) sat byte-identical to the live file for two days, harmless,
while `--check` cheerfully proposed `ADOPT … noctalia.kdl`. The moment the palette changed,
the next sync would have quarantined the live file and restored 2026-10-18's colours —
a tool silently reverting a user-visible theme choice.
`deploy.sh` now refuses the class (`SKIP … (generated theme output)`), `.gitignore` carries
the matching patterns so it cannot be re-added, and `tests/deploy.bats` pins both
directions: generated output is never adopted, and an ordinary identical config still is.
Untracked in #43, Refs #42.
## Law: a `zz` override older than the file it overrides is a time bomb
Stow order means `zz-$HOST` wins, and when both packages carry the same path, sync relinks
it *silently* — the script's own comment: "managed file changing owners between packages:
silent relink, nothing to quarantine". `dotfiles/zz-blankpad/niri/.config/niri/cfg/layout.kdl`
held focus ring `width 2` / `active-color "#e01b24"` from `7b7dbf5` (2026-10-17) while the
base package absorbed `b3d3df4` (2026-10-18): `width 4`, `"#ff8c00"`. Live was linked to
base, the newer intent, so `--check` printed `RELINK .config/niri/cfg/layout.kdl` on every
run and one unattended `deploy.sh` would have reverted the amber focus ring with no
quarantine and no warning. The duplicate is deleted in #43.
Rule: a `zz` file exists only to differ. When base absorbs the change, delete the `zz` copy
in the same commit.
How to read `--check`, measured on blankpad: it prints `RELINK` for **every** path the `zz`
tier carries, even when the live link already points at the override — `rules.kdl`,
`keybinds.kdl`, `misc.kdl`, `autostart.kdl` and `.zshrc` all did — because the walk
processes the base package first and reports the intermediate owner change. A `RELINK` line
therefore proves a pending change only when `readlink` shows the live target is *not*
already the `zz-$HOST` file. `actions=0` is the only honest summary.
## Law: never start a unit a session may already be running
`sync` used to end with `systemctl --user enable --now` for every unit in `systemd-user/`
whose `ExecStart` resolves. Measured on blankpad before one such run:
|unit|`UnitFileState`|`ActiveState`|
|---|---|---|
|`live-sync.service`|`linked`|`inactive`|
|`shelly-notifications.service`|`enabled`|`active`|
|`swayidle-lock.service`|`enabled`|`inactive`|
`swayidle-lock.service` was installed and **not running** — nothing started it, no swayidle
process existed (`pgrep -c swayidle` = 0), and `autostart.kdl` does not launch one. A
`deploy.sh` run would have armed it: monitors blanking and locking on the idle timer while
the operator was mid-sentence. `live-sync.service` is `linked`, driven by its timer;
`--now` forces a heartbeat commit at an arbitrary moment.
Two rules follow. Install state must be read from the **printed state**, not the exit
status — `is-enabled` exits 1 for `linked` exactly as for `disabled`, so exit status cannot
tell "installed, driven by a timer" apart from "never installed". And a unit is started only
in the latter case: a machine with a session on it has already decided what runs, while a
fresh box — where everything reports `disabled` — still gets its units up. Both directions
are pinned in `tests/deploy.bats` over a stub `systemctl`, because the real one on this box
is exactly what must not be invoked from a test. Refs #44.
**A third state exists, and it is not an error.** A unit file with no `[Install]` section cannot be
enabled at all — `systemctl --user enable` answers with a non-zero exit and a paragraph about
missing `WantedBy`. `live-sync.service` is one on purpose: `live-sync.timer` pulls it in, which is
also why systemd reports it as `linked`. Until 2026-10-21 the deploy swallowed that failure and
printed `ENABLE` anyway, so the line an operator reads to learn what a rebuild armed was false on
every box, on the repo's own heartbeat. The section is readable from the file being deployed, so
the tool checks it and prints `INERT …(no [Install] section — another unit arms it, enable cannot)`
without making the call. Refs #60.
## Law: expand systemd specifiers before calling a unit foreign
`ExecStart=%h/.omp/scripts/live-sync.sh` is valid systemd and meaningless to a shell. The
resolution check handed that literal string to `command -v`, which fails everywhere, so the
deployment concluded the unit belonged to another machine and skipped it — silently, through
the same code path that is supposed to keep a *foreign* unit inert. The unit it kept skipping
was this repo's own heartbeat, and `%h` is precisely where this checkout lives, so the false
verdict was the same on every box that deployed it. Measured on blankpad: the first real
`deploy.sh` run printed `SKIP live-sync.service (ExecStart '%h/.omp/scripts/live-sync.sh'
absent on this host)` while `/home/blank/.omp/scripts/live-sync.sh` sits right there.
Expand the specifiers a user unit can carry — `%h`, `%u`, `%U` — before resolving, and let an
unknown specifier fail rather than guess at it. The probe needs no privileges and writes
nothing:
```bash
bin=$(sed -n 's/^ExecStart=\([^ ]*\).*/\1/p' \
~/.omp/dotfiles/systemd-user/.config/systemd/user/live-sync.service | head -1)
command -v "${bin//%h/$HOME}" # resolves → the unit is this box's; the SKIP was the lie
```
## Law: the dconf dump holds preferences, not per-box state
`dconf load /` writes every key in `dotfiles/dconf/dump.txt`, so the dump carries whatever
box it was taken from. Comparing key by key against live state on blankpad found **six of
24** keys that were not this box's, and not one of them was a preference:
[org/gnome/portal/filechooser/firefox] last-folder-path
repo: '/home/dank/Downloads' live: '/home/blank/Downloads'
[org/gnome/nautilus/window-state] initial-size, initial-size-file-chooser, maximized
[org/gnome/desktop/background] picture-uri, picture-uri-dark
both: 'file:///home/dank/.config/background'
A file-chooser memory, a window geometry, a wallpaper URI: application state that arrived
with the dotfiles import. The last five did not appear as conflicts at all, because
blankpad's **live** tree already held the same `/home/dank` values — an earlier deploy had
written them here. A foreign value that agrees with live is invisible to any diff and still
wrong. All six come out in #44; the two wallpaper URIs are also cleared on this box
(`gsettings set org.gnome.desktop.background picture-uri ''`, and its `-dark` twin), because
niri draws the wallpaper, noctalia owns it, and nothing reads a dead path.
Compare **key by key**, not with a plain `diff`: `dconf load` writes only the keys present,
so live-only keys survive and a diff overstates the damage in one direction and understates
it in the other. Re-dump on the box whose settings are canonical (`dconf dump / >
dotfiles/dconf/dump.txt`), then delete the state sections before committing — the census's
*Never track: app state* class applies inside a tracked file, not just to whole directories.
Refs #44.
## Law: a watcher must restart on a clean exit too
`swayidle-lock.service` was `enabled`, present in `default.target.wants/`, and **dead for eight
days**: `Result=success`, `NRestarts=0`, gone since 2026-10-12 16:22 after 2d 21h of uptime.
`Restart=on-failure` cannot see a clean exit, and `swayidle -w` exits 0 on SIGTERM. The trigger is
attributable to eight seconds: **`niri` and `noctalia` both started `Mon Oct 12 16:22:31 2026`**
(`ps -o lstart=`) while swayidle's last state change was `16:22:23` — the compositor session
restarted, the watcher went with it, and because it was wanted by `default.target` rather than tied
to the session target, nothing brought it back. The user journal keeps no entries for that window,
so the attribution rests on process ages rather than logs, which is its own lesson: diagnose a
lapse within the retention you actually have.
**The severity claim in this page's first draft was wrong, and is corrected here.** It said closing
the lid suspended the machine unlocked, because the `before-sleep` hook lived in the dead unit. But
`systemd-inhibit --list` shows `noctalia … sleep "Lock before sleep" delay` — the shell holds its
own sleep inhibitor and locks on suspend independently of swayidle, so the suspend path was covered
the whole time. What was actually dead for eight days is the **idle** lock: five minutes of
inactivity locked nothing, and `after-resume` never fired. `pgrep -c -u blankpad
'swayidle|swaylock|hyprlock|gtklock'` = 0 remains true; it simply did not mean *no lock path at
all*, because a locker can be a hook inside a running shell rather than a name on the process list.
Read the inhibitor table, not only `pgrep`.
Three settings, each with a job: `Restart=always`, because a supervisor must not assume it can
only die badly; `StartLimitIntervalSec=0`, because a fast loop must never wedge it into `failed` —
a lock that keeps trying beats one that stopped trying; and `PartOf=graphical-session.target`,
because the unit is wanted by `default.target`, so the session coming back did not bring *it*
back.
Verify by behaviour rather than by reading the file — the point is surviving the event that killed
it in October:
```bash
systemctl --user start swayidle-lock.service
kill -TERM "$(systemctl --user show swayidle-lock.service -p MainPID --value)"
sleep 8 && systemctl --user show swayidle-lock.service -p NRestarts -p MainPID -p ActiveState
```
An idle inhibitor also disarms the timeout outright, which surprises people: while an `omp` agent
session is running, swayidle logs `Not enabling timeouts: idle inhibitor found` and arms no idle
timeout at all — `systemd-inhibit --list` shows `omp … idle "omp agent session" block`. A box with
an agent working therefore does not lock on inactivity, by harness design, while `before-sleep` and
`after-resume` keep working. Anyone asking "why did the screen never lock" should read the inhibitor
table before suspecting the unit.
The general shape: `deploy.sh` only ever *enables*. Nothing on any box re-checks that a unit the
system depends on is still running, so a security posture can lapse silently and stay lapsed for a
week. A liveness check for this tier belongs in the heartbeat, not in a human's memory. Refs #47.