Compare commits

...
Author SHA1 Message Date
Frank 4666d0dce0 Correct the lock-watcher law: the trigger is the session restart, and suspend was covered
Two statements in the law added by #48 need measurement rather than inference, and one of them was
wrong.

**Attributed.** `niri` and `noctalia` both started `Mon Oct 12 16:22:31 2026` (`ps -o lstart=`);
swayidle's last state change was `16:22:23`. The compositor session restarted and the watcher died
with it — eight seconds of agreement, which is what `PartOf=graphical-session.target` is for. The
fix that #48 justified by reasoning is now justified by the event itself. The user journal holds no
entries for that window, so the evidence is process ages, not logs.

**Corrected.** #48 said closing the lid suspended blankpad unlocked, because `before-sleep` lived in
the dead unit. `systemd-inhibit --list` says otherwise: `noctalia … sleep "Lock before sleep" delay`
— the shell holds its own sleep inhibitor and locks on suspend independently of swayidle, so the
suspend path held all along. What was dead for eight days is the **idle** lock and `after-resume`.
The method error is worth keeping: `pgrep` for lockers came back empty and I read that as "no lock
path", when a locker can be a hook inside a running shell. The inhibitor table is the instrument.

Added with them, because it answers a question people will ask: while an `omp` session runs, swayidle
logs `Not enabling timeouts: idle inhibitor found` and arms no idle timeout (`omp … idle "omp agent
session" block`). A box with an agent working does not lock on inactivity, by harness design.

Docs only; the unit file from #48 is unchanged and the live watcher is `active` with `NRestarts=1`
after the SIGTERM proof. `kb-lint.sh omp-docs` 0 violations, `kb-coverage.py` 0 issues, `bats tests/`
71/71. Refs #47.
2026-10-20 17:52:59 -07:00
dank d013795922 Merge pull request 'blankpad has had no screen lock for 8 days; the lid suspends it unlocked' (#48) from fix/47-lock-watcher-resilience into main 2026-10-20 17:25:11 -07:00
Frank 2b7b0bc3cd Make the idle-lock watcher survive a clean exit, and outlive a session restart
`swayidle-lock.service` was `enabled`, in `default.target.wants/`, and **dead for eight days**:

    Active: inactive (dead) since Mon 2026-10-12 16:22:23 PDT; 1 week 1 day ago
    Duration: 2d 21h 57min   Main PID: 3181 (code=exited, status=0/SUCCESS)
    Result=success  NRestarts=0

`Restart=on-failure` cannot see a clean exit, and `swayidle -w` exits 0 on SIGTERM. So the
watcher that owns both the 5-minute idle lock and the `before-sleep` lock stopped existing, and
nothing on the box noticed. The consequence is not cosmetic: with `HandleLidSwitch=suspend` and
`HandleLidSwitchExternalPower=""` (which falls back to it), **closing the lid suspended the
machine unlocked**, because the hook that locks on sleep lived inside the unit that had died. No
alternative was running: `pgrep -c -u blankpad 'swayidle|swaylock|hyprlock|gtklock'` = 0, and
neither `autostart.kdl` launches a locker.

Three settings, each with a job:

- `Restart=always` — a supervisor must not assume it can only die badly.
- `StartLimitIntervalSec=0` — with `always`, a fast loop must never wedge the unit into `failed`;
  a lock that keeps trying beats a lock that stopped trying.
- `PartOf=graphical-session.target` — it is wanted by `default.target`, so a session restart
  brought the target back without it; now the watcher is restarted with the session.

What triggered the 16:22 exit is **unattributed**: the user journal has no entries in that window,
so all that can be said is that swayidle exited with status 0 — precisely what the old policy was
blind to. The fix does not depend on knowing the trigger, which is the point.

Verification is behavioural, per the law recorded with it: start the unit, `kill -TERM` the main
PID, and watch `NRestarts` increment with a new `MainPID` — the October event, survived. Not
performed here: starting it arms a 5-minute idle lock on a machine that has gone a week without
one, and that is Frank's call. `systemd-analyze --user verify` clean, `bats tests/` 71/71,
`kb-lint.sh omp-docs` 0, `kb-coverage.py` 0.

Refs #47
2026-10-20 17:24:32 -07:00
dank 3809e1ed22 Merge pull request 'deploy.sh declared this repo's own heartbeat unit foreign, on every box' (#46) from fix/44-unit-specifier-resolution into main 2026-10-20 17:22:52 -07:00
Frank e278e4b863 Expand systemd specifiers before calling a unit foreign
The first real `deploy.sh` run on blankpad 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. `%h` is expanded by systemd,
never by the shell, so the literal string was handed to `command -v`, failed, and the unit was
declared to belong to another machine — through the same code path whose whole purpose is to
keep a *foreign* unit inert, and silently. The victim was this repo's own heartbeat, and the
verdict was the same on every box that ever deployed it, because `%h` is exactly where the
checkout lives.

Nothing broke tonight only by luck of install state: `live-sync.service` is `linked`, driven
by its timer, so the heartbeat kept running while the deploy lied about it. A unit whose only
install route was this loop would simply never have been enabled anywhere.

`%h`, `%u` and `%U` are now expanded before resolution; an unrecognised specifier is left
alone so it fails loudly rather than being guessed at. Pinned by a test that writes a
`%h/.local/bin/helper` unit into the scratch home — red before (`SKIP`), green after
(`already linked; not started`), which also keeps the #45 rule intact: resolving a unit is not
licence to start it. Refs #44.

Verification: `bats tests/` 71/71, `kb-lint.sh omp-docs` 0 violations, `kb-coverage.py` 0
issues, `bash -n` clean.
2026-10-20 17:19:49 -07:00
dank c05f5ad451 Merge pull request 'deploy.sh is unsafe beside a live session: it starts a screen lock and imports another box settings' (#45) from fix/44-deploy-live-safe into main 2026-10-20 17:16:47 -07:00
Frank ecc089c6ef deploy.sh no longer starts units a live session already runs
`sync` on a box with a live session on it was unsafe in two independent ways, found by
preflighting the run rather than performing it. Refs #44.

**`enable --now` would have armed a screen lock mid-session.** The unit loop started every
`systemd-user/` unit whose `ExecStart` resolves. `swayidle-lock.service` on blankpad is
`enabled`/`inactive` — nothing started it, no swayidle process exists, `autostart.kdl` does
not launch one — so one `deploy.sh` would have begun blanking and locking the monitors while
the operator was mid-sentence. `live-sync.service` is `linked`, driven by its timer, and
`--now` forces a heartbeat commit at an arbitrary moment. A unit is now started only when
systemd reports it as nothing-yet (`""`/`disabled`), which is precisely a fresh box; anything
already installed is merely `enable`d. The state has to come from the **printed state**:
`is-enabled` exits 1 for `linked` exactly as for `disabled`, so exit status cannot
distinguish "installed, timer-driven" from "never installed" — the first version of this fix
got that wrong and the test caught it.

**`--check` output was being read as more than it can mean.** 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`, `.zshrc` all did on blankpad),
because the walk visits the base package first and reports the intermediate owner change.
The sentence committed in #43 — RELINK noise is "the sound of a stale override waiting" — is
too strong and is corrected: the line proves a pending change only when `readlink` shows the
live target is not already the `zz-$HOST` file, and `actions=0` is the honest summary. The
stale-override hazard #43 removed was and is real; the signal for it is the target, not the line.

**The dconf dump carried six of its 24 keys from another box**, none of them preferences:
a Firefox file-chooser `last-folder-path` of `/home/dank/Downloads`, a nautilus
`window-state` section, and `picture-uri`/`picture-uri-dark` pointing at
`file:///home/dank/.config/background`. The last five showed no conflict because blankpad's
live tree *already* held those `/home/dank` values — an earlier deploy wrote them here — and
a foreign value that agrees with live is invisible to any diff while still being wrong. They
are dropped; `picture-options='zoom'` stays, because it is a preference. The live wallpaper
URIs are cleared on the box as part of landing this (niri draws the wallpaper, noctalia owns
it, nothing reads a dead path).

`tests/deploy.bats` grows by four, over a stub `systemctl` — the real one on this box is
exactly what must not be invoked from a test. Red before: an already-`enabled` unit and a
`linked` unit both get `--now`. Green after, including the two directions that keep the
change honest: a `disabled` unit is still `enable --now`'d so a fresh box comes up, and a
unit whose `ExecStart` is absent here is never touched at all.

Verification: `bats tests/` 70/70, `kb-lint.sh omp-docs` 0, `kb-coverage.py` 0. The dump now
reconciles against live state with **0 conflicts and no `/home/` values in 18 keys**, proven
by key-by-key comparison rather than `diff` — `dconf load` writes only the keys present, so a
diff overstates the damage one way and hides it the other.
2026-10-20 17:13:55 -07:00
dank 7523ca5c19 Merge pull request 'Stop the deploy tier from reverting live theme state' (#43) from fix/42-deploy-generated into main 2026-10-20 17:10:03 -07:00
6 changed files with 247 additions and 21 deletions
-10
View File
@@ -9,8 +9,6 @@ window-size=(360, -1)
[org/gnome/desktop/background]
picture-options='zoom'
picture-uri='file:///home/dank/.config/background'
picture-uri-dark='file:///home/dank/.config/background'
[org/gnome/desktop/interface]
color-scheme='prefer-dark'
@@ -26,14 +24,6 @@ default-visible-columns=['name', 'size', 'type', 'date_modified']
default-folder-viewer='list-view'
migrated-gtk-settings=true
[org/gnome/nautilus/window-state]
initial-size=(890, 550)
initial-size-file-chooser=(890, 550)
maximized=false
[org/gnome/portal/filechooser/firefox]
last-folder-path='/home/dank/Downloads'
[org/gtk/gtk4/settings/file-chooser]
show-hidden=false
sort-directories-first=false
@@ -2,6 +2,13 @@
Description=swayidle idle lock (locks via noctalia-shell IPC, suspends on lid)
Documentation=https://github.com/swaywm/swayidle
After=graphical-session.target
# Restart together with the session, not merely at boot: this unit is wanted by
# default.target, so a compositor restart used to leave it orphaned while the target came
# back without it. Refs #47.
PartOf=graphical-session.target
# With Restart=always below, a fast failure loop must never wedge the watcher into `failed`:
# a lock that keeps trying beats a lock that stopped trying. Refs #47.
StartLimitIntervalSec=0
[Service]
# Locks via the noctalia v5 shell; `noctalia msg` resolves the running instance
@@ -11,7 +18,10 @@ ExecStart=/usr/bin/swayidle -w \
timeout 300 '/usr/bin/noctalia msg session lock' \
before-sleep '/usr/bin/noctalia msg session lock' \
after-resume '/usr/bin/noctalia msg session lock'
Restart=on-failure
# `swayidle -w` exits 0 on SIGTERM, and that is how this watcher died on 2026-10-12: a clean
# exit that `Restart=on-failure` cannot see, leaving the box unlocked for a week. A supervisor
# restarts always. Refs #47.
Restart=always
RestartSec=5s
[Install]
+1 -1
View File
@@ -135,7 +135,7 @@ configs).
| Reason | Contents |
|---|---|
| **Generated palette expansion** | Every `themes/noctalia*`, `*/noctalia.*`, `*/colors/noctalia*` / `Noctalia*` (24 files outside app-profile dirs). Machine-written by matugen/noctalia, not hand-authored: the `stat -c '%y %n'` cluster `2026-10-14 19:05:40.330…40.813 -0700` writes 24 files in one second (`alacritty/themes/noctalia.toml` at `.330212397`, `btop/themes/noctalia.theme` at `.334212456`, `tmux/themes/noctalia.conf` at `.813219586`, …); later re-runs re-stamp singles — `bat/themes/noctalia.tmTheme` `2026-10-18 17:11:22`, `niri/noctalia.kdl` `2026-10-18 17:36:27`, `mango/noctalia.conf` `2026-10-17 18:23:37`. A same-second mass-write is generation, never an edit. |
| **App state / profiles** | `mozilla` (5471), `unity3d` RimWorld state (235), `nirimod` (116), `obsidian` (99), `FreeTube` (56), `google-chrome-for-testing` (5); plus per-program state inside tracked dirs: `dconf/user`, `fish/fish_variables`, `micro/syntax/*`+`micro/buffers/history`, `gtk-4.0/servers`, `cachyos/cachyos-hello.log`. |
| **App state / profiles** | `mozilla` (5471), `unity3d` RimWorld state (235), `nirimod` (116), `obsidian` (99), `FreeTube` (56), `google-chrome-for-testing` (5); plus per-program state inside tracked dirs: `dconf/user`, `fish/fish_variables`, `micro/syntax/*`+`micro/buffers/history`, `gtk-4.0/servers`, `cachyos/cachyos-hello.log`. State hides inside a tracked file too: `dconf/dump.txt` carried `last-folder-path`, a nautilus `window-state` section and `picture-uri`/`picture-uri-dark` — all `/home/dank` values, and the wallpaper URIs were live on blankpad, i.e. already deployed here — dropped by #44, see [tools/dotfiles-deploy](/tools/dotfiles-deploy.md). |
| **Skills-CLI installs** | `crush` (37), `devin` (37), `goose` (37), `kimchi/harness` (37) skill trees — provisioned by the skills CLI, owned by the store, not this repo. See [skills/store-wiring](/skills/store-wiring.md). |
| **Secret tier** | `~/.config/secrets/` (`gitea`, `hf_token`, `hf_token_write`, `kagi_api_key` — names only), top-level `frank-job-hunt.env`, `containers/systemd/postgres.env`. Never tracked, values never printed. |
| **Trash committed inside the tree** | `dotfiles/niri/.config/niri/cfg/.AGENTTRASH/display.kdl.superseded` — a retired output config committed 2026-10-09, five days before `.gitignore:54` gained `.AGENTTRASH/` (2026-10-14). Tracked-then-ignored: an ignore rule never reaches an already-tracked path, so the only repair is `git rm --cached`, done by #41. The rule does match at depth — `git check-ignore --no-index -v dotfiles/niri/.config/niri/cfg/.AGENTTRASH/x` resolves to `.gitignore:54` — so a fresh in-tree trash dir is ignored from birth and this does not recur on its own. |
+135 -7
View File
@@ -25,14 +25,16 @@ is stowed last and its files replace symlinks coming from base packages.
| `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 /`, and
`systemd-user` units get `daemon-reload` plus `enable --now` only when their `ExecStart`
binary resolves on the current machine, so a unit belonging to another box stays inert
instead of crash-looping.
Two package shapes are not symlinks. `dconf/dump.txt` is loaded with `dconf load /`, and
`systemd-user` units get `daemon-reload` plus `enable`; a unit is **started** only when
systemd reports it as nothing-yet (`""`/`disabled`), and never when its `ExecStart` binary
is absent here, so a unit belonging to another box stays inert instead of crash-looping.
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 9 tracked / 1 box-only; `gtk-3.0` is 3/0). Any audit that classifies by
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).
@@ -123,5 +125,131 @@ run and one unattended `deploy.sh` would have reverted the amber focus ring with
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 — and never read `RELINK` lines from `--check` as cosmetic noise; they
are the sound of a stale override waiting for someone to run the tool for real.
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.
## 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.
+22 -2
View File
@@ -123,9 +123,29 @@ if [ "$MODE" != "check" ] && [ -d "$PKGS_DIR/systemd-user/.config/systemd/user"
for unit in "$PKGS_DIR"/systemd-user/.config/systemd/user/*.service; do
[ -e "$unit" ] || continue
bin="$(sed -n 's/^ExecStart=\([^ ]*\).*/\1/p' "$unit" | head -1)"
# systemd expands specifiers inside ExecStart; this shell does not. Matching the literal
# `%h/…` makes a unit of THIS box look foreign and skips it in silence — which is exactly
# how live-sync.service, the repo's own heartbeat, has been reported "absent on this
# host" on every machine that deployed it. Expand what a user unit can carry; leave any
# other specifier unmatched rather than guess at it. Refs #44.
bin="${bin//%h/$HOME}"
bin="${bin//%u/$(id -un)}"
bin="${bin//%U/$(id -u)}"
if [ -n "$bin" ] && command -v "$bin" >/dev/null 2>&1; then
systemctl --user enable --now "$(basename "$unit")" || true
echo "ENABLE $(basename "$unit") (ExecStart resolves here)"
name="$(basename "$unit")"
# Read the state, not the exit status: `is-enabled` exits 1 for a `linked` unit exactly
# as for a `disabled` one. Starting something already installed is how a deploy locks a
# live session — swayidle-lock sits enabled/inactive on blankpad and `--now` arms it
# mid-conversation. Only a unit nothing has installed yet gets started. Refs #44.
state="$(systemctl --user is-enabled "$name" 2>/dev/null || true)"
case "$state" in
"" | disabled)
systemctl --user enable --now "$name" || true
echo "ENABLE $name (ExecStart resolves here; nothing running it yet)" ;;
*)
systemctl --user enable "$name" || true
echo "ENABLE $name (already $state; not started — a live session decides what runs)" ;;
esac
else
echo "SKIP $(basename "$unit") (ExecStart '$bin' absent on this host)"
fi
+78
View File
@@ -23,6 +23,24 @@ setup() {
export HOME="$TEST_TMP/home"
mkdir -p "$HOME"
export DEPLOY_REPO="$REPO" DEPLOY_HOST=box
# systemctl stub: records every call and answers is-enabled from a file. The real
# systemctl must never run here — on this box it would start a screen lock.
mkdir -p "$TEST_TMP/bin"
export STUB_LOG="$TEST_TMP/systemctl.log" UNIT_STATE="$TEST_TMP/unit-state"
: > "$STUB_LOG"
cat > "$TEST_TMP/bin/systemctl" <<'STUB'
#!/usr/bin/env bash
echo "systemctl $*" >> "$STUB_LOG"
if [ "$2" = is-enabled ]; then
state="$(cat "$UNIT_STATE" 2>/dev/null || echo disabled)"
printf '%s\n' "$state"
case "$state" in disabled | "") exit 1 ;; *) exit 0 ;; esac
fi
exit 0
STUB
chmod +x "$TEST_TMP/bin/systemctl"
export PATH="$TEST_TMP/bin:$PATH"
}
teardown() { rm -rf "$TEST_TMP"; }
@@ -32,6 +50,13 @@ repo_file() { mkdir -p "$(dirname "$REPO/dotfiles/$1")"; printf '%s\n' "$2" > "$
# live_file <absolute path under $HOME> <content>
live_file() { mkdir -p "$(dirname "$1")"; printf '%s\n' "$2" > "$1"; }
# systemd_unit <name> <ExecStart> — a unit in the systemd-user package
systemd_unit() {
mkdir -p "$REPO/dotfiles/systemd-user/.config/systemd/user"
printf '[Unit]\nDescription=%s\n[Service]\nExecStart=%s\n[Install]\nWantedBy=default.target\n' "$1" "$2" \
> "$REPO/dotfiles/systemd-user/.config/systemd/user/$1"
}
@test "generated theme output is never adopted into a symlink" {
repo_file "ghostty/.config/ghostty/themes/noctalia" 'background = "#f43841"'
live_file "$HOME/.config/ghostty/themes/noctalia" 'background = "#f43841"'
@@ -74,3 +99,56 @@ live_file() { mkdir -p "$(dirname "$1")"; printf '%s\n' "$2" > "$1"; }
grep -q 'theme = "tokyo-night"' "$HOME/.config/ghostty/config.ghostty"
[ ! -d "$REPO/.quarantine" ]
}
@test "a unit already enabled is enabled but not started" {
systemd_unit lock.service /bin/sh
printf 'enabled\n' > "$UNIT_STATE"
run "$REPO/scripts/deploy.sh"
[ "$status" -eq 0 ]
[[ "$output" == *"not started"* ]]
grep -q 'systemctl --user enable lock.service' "$STUB_LOG"
! grep -q -- '--now' "$STUB_LOG"
}
@test "a linked unit is not started either" {
# `is-enabled` exits 1 for a linked unit, exactly as it does for a disabled one, so the
# printed state is the signal — exit status cannot tell them apart.
systemd_unit timer-driven.service /bin/sh
printf 'linked\n' > "$UNIT_STATE"
run "$REPO/scripts/deploy.sh"
[ "$status" -eq 0 ]
! grep -q -- '--now' "$STUB_LOG"
}
@test "a disabled unit is enabled and started, so a fresh box still comes up" {
systemd_unit fresh.service /bin/sh
printf 'disabled\n' > "$UNIT_STATE"
run "$REPO/scripts/deploy.sh"
[ "$status" -eq 0 ]
grep -q 'systemctl --user enable --now fresh.service' "$STUB_LOG"
}
@test "a unit whose ExecStart is absent here stays inert" {
systemd_unit otherbox.service /nonexistent/daemon
printf 'disabled\n' > "$UNIT_STATE"
run "$REPO/scripts/deploy.sh"
[ "$status" -eq 0 ]
[[ "$output" == *"SKIP"*otherbox.service* ]]
! grep -q enable "$STUB_LOG"
}
@test "a unit whose ExecStart uses a systemd specifier still resolves here" {
# `%h` is expanded by systemd, not by the shell, so matching the literal string makes a
# unit belonging to THIS box look foreign and skip it in silence. The victim is this repo's
# own heartbeat: `ExecStart=%h/.omp/scripts/live-sync.sh`, skipped on every box. Refs #44.
mkdir -p "$HOME/.local/bin"
printf '#!/bin/sh\ntrue\n' > "$HOME/.local/bin/helper"
chmod +x "$HOME/.local/bin/helper"
systemd_unit heartbeat.service '%h/.local/bin/helper'
printf 'linked\n' > "$UNIT_STATE"
run "$REPO/scripts/deploy.sh"
[ "$status" -eq 0 ]
[[ "$output" == *"heartbeat.service (already linked"* ]]
grep -q 'systemctl --user enable heartbeat.service' "$STUB_LOG"
! grep -q -- '--now' "$STUB_LOG"
}