<feed xmlns='http://www.w3.org/2005/Atom'>
<title>archsetup/scripts, branch main</title>
<subtitle>Builds a full dev workstation from a bare Arch Linux install.
</subtitle>
<id>https://git.cjennings.net/archsetup/atom?h=main</id>
<link rel='self' href='https://git.cjennings.net/archsetup/atom?h=main'/>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/archsetup/'/>
<updated>2026-08-21T14:25:23+00:00</updated>
<entry>
<title>feat(bluetooth): restore the radio after a sleep cycle</title>
<updated>2026-08-21T14:25:23+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-08-21T14:25:23+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/archsetup/commit/?id=d1b5ee1fdc07400e4308db7d346c5a8be0309c7a'/>
<id>urn:sha1:d1b5ee1fdc07400e4308db7d346c5a8be0309c7a</id>
<content type='text'>
Masking systemd-rfkill so TLP can own radios leaves the resume edge with no owner at all. TLP's sleep hook runs `tlp resume`, but its setting is DEVICES_TO_ENABLE_ON_STARTUP, and TLP has no ON_RESUME to pair with it. WiFi survives anyway because NetworkManager unblocks itself. Bluetooth has no equivalent, so it comes back soft-blocked and stays that way.

After a hibernate the controller is wedged as well, which is why unblocking alone is not the fix. I cleared rfkill by hand on velox this morning and scanning still returned nothing. Zero devices, in a room that gave seventeen a minute after the driver was reloaded. bluetoothd had logged "Failed to set mode" and "Failed to add device" at the instant of resume.

So the hook reloads btusb on a hibernate-class wake, then unblocks. That order matters: a freshly loaded btusb can come up blocked and would undo an earlier unblock. A plain suspend only unblocks, since it brings USB back intact and reloading there would tear down a working adapter for nothing.

It acts only where TLP's own config asks for bluetooth, so it re-asserts a declared intent instead of inventing one and a machine that deliberately keeps the radio off keeps it off. It exits zero on every path, because a failing sleep hook logs noise that outlives the cause.

The install sits next to the mask that creates the gap rather than beside the other installs, and creates /etc/systemd/system-sleep first. Arch does not ship it and install_executable is a plain cp, so without the mkdir a fresh machine would warn and end up with no hook.

Ten tests, and I checked they bite: dropping the TLP intent guard fails three, moving the unblock before the reload fails one.

This surfaced because hibernate was switched back on. The gap predates that and would have shown up the first time the laptop slept.
</content>
</entry>
<entry>
<title>fix(cmail): let Bridge setup finish without cmail-action</title>
<updated>2026-08-21T09:09:35+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-08-21T09:09:35+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/archsetup/commit/?id=7ed8e5701c83c5e26c2416b9a0365c12f801f7ed'/>
<id>urn:sha1:7ed8e5701c83c5e26c2416b9a0365c12f801f7ed</id>
<content type='text'>
I moved the cmail-action install out of cmail-setup-finish.sh and left behind a guard that aborts when the tool is missing. The script mentions cmail-action five times and invokes it zero — every hit is a comment or that check. So a Bridge provisioning script hard-failed over a mail client it never touches, and a fresh machine got Bridge ready, the cert exportable, the service startable, and a script that refused to configure any of it.

It warns with the same remedy and carries on now. That also drops an ordering neither repo needs: Bridge setup no longer requires rulesets cloned and installed first.

The sequence around it hadn't caught up either. Both the checklist and the installer's completion message still opened with "clone claude-templates to ~/projects/claude-templates", the same dead path that broke the script, and neither mentioned rulesets at all. So the documented order was: clone a stale repo to a path that doesn't exist, log into Bridge, then run a script that aborts pointing at a directory nothing created. Both now run Bridge first and name rulesets as what it is — not a prerequisite for those steps, but the thing mbsync wants.

I also rephrased the header claim that the symlink "arrives on its own". True on a machine that runs agent sessions, since session start runs make install, and false on one that doesn't. It states the condition now instead of the consequence.
</content>
</entry>
<entry>
<title>fix(cmail): stop installing a tool that rulesets now owns</title>
<updated>2026-08-20T18:14:13+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-08-20T18:04:58+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/archsetup/commit/?id=27d1a2c00c6c4dd918b2dad8585b65b0b4e9eb81'/>
<id>urn:sha1:27d1a2c00c6c4dd918b2dad8585b65b0b4e9eb81</id>
<content type='text'>
cmail-setup-finish.sh symlinked cmail-action from ~/projects/claude-templates, a path that stopped existing when claude-templates folded into rulesets as a subtree. The guard fired first and said "clone claude-templates first", which sent you looking for a repo that was present the whole time.

The step has no work left either way. cmail-action lives in rulesets at claude-templates/bin/, and rulesets' make install links that directory into ~/.local/bin at every session start, so it reaches each machine unattended. velox's symlink was written that way this morning without anyone asking.

So the script checks that the tool arrived instead of placing it, and names the install command if it hasn't. The Bridge state check, the cmailpass decryption and the cert handling all stay; those are still this script's job.

The header advertised the old behaviour too, so I rewrote it rather than leaving the prose describing a step that no longer exists.
</content>
</entry>
<entry>
<title>fix(post-rebuild-check): stop expecting a .claude directory every project names</title>
<updated>2026-08-20T16:45:58+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-08-20T16:45:58+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/archsetup/commit/?id=3fbf3e022a364fe30609fb2d8dbc49ad6c961f70'/>
<id>urn:sha1:3fbf3e022a364fe30609fb2d8dbc49ad6c961f70</id>
<content type='text'>
Check 4 expects a tooling path wherever the project's own .gitignore names it, on the reasoning that the ignore file records what the project means to hold untracked. That holds for .ai and todo.org. It doesn't hold for .claude, because the bootstrap and the gitignore sweep write that line into every gitignore-mode project whether or not one ever exists there. The entry is aspirational, not a promise.

pearl, rsyncshot and yt-sync each name it and none has ever had one. Ratio is missing the identical directory in the identical three, and ratio was never rebuilt, so this is the steady state rather than drift. That is the same test CLAUDE.md is already excluded by, one comment above.

Dropping it costs no real signal. A project that genuinely carries a .claude, with rules and hooks from a language bundle, has it re-synced by sync-language-bundle.sh at every session start, so a true absence heals before this check would run.

velox now reports one finding instead of eight. Seven of the eight were this checker being wrong rather than the machine.
</content>
</entry>
<entry>
<title>feat(post-rebuild-check): let a machine declare which units it means to leave disabled</title>
<updated>2026-08-20T13:01:20+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-08-20T13:01:20+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/archsetup/commit/?id=c588b0841df440e1a5290c7150ee245f90d1788e'/>
<id>urn:sha1:c588b0841df440e1a5290c7150ee245f90d1788e</id>
<content type='text'>
Check 2 treats "enabled" as a proxy for "will actually run", and the proxy is wrong for a unit nobody intends to enable on that box. velox has four: geoclue-agent is redundant because hyprland's exec-once starts the binary itself, emacs is started on demand by emacsclient, obs-record-watchdog only matters while recording, and obsbot-wb-guard needs an OBSBOT the laptop doesn't have. All four reported at every run.

Four permanent lines in front of every real finding teach me to skim the output, which is the argument check 4 already makes about CLAUDE.md. On velox this takes the run from 8 findings to 4.

The list is machine-local, read from $XDG_CONFIG_HOME/post-rebuild-check/units-expected-disabled. It can't live in the unit file, because obsbot-wb-guard is correctly enabled on ratio. One unit, a different right answer per machine.

An entry whose unit is enabled after all is reported too. The main loop can't catch that, since it skips any state that isn't disabled or linked, so the list gets its own pass. Without it a stale exemption sits there suppressing nothing, and the list becomes somewhere real findings go to die.

The exemption is tested last, so it never hides the dangling-symlink finding decided on the filesystem above it.
</content>
</entry>
<entry>
<title>fix(post-rebuild-check): stop overstating an unregistered signal account</title>
<updated>2026-08-20T12:44:23+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-08-20T12:44:23+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/archsetup/commit/?id=f876a717af9ae644320f014e30866bb919fcfe4e'/>
<id>urn:sha1:f876a717af9ae644320f014e30866bb919fcfe4e</id>
<content type='text'>
The finding said agent-text relays into this machine, so a missing account broke paging for the whole fleet. That was true when the check was written and isn't now. agent-text walks AGENT_TEXT_RELAYS in order and skips itself, so a machine with no account pages through a relay that has one.

It's still worth flagging, because the fleet does lose paging when no relay host is registered either. I reworded the finding to say that instead.
</content>
</entry>
<entry>
<title>feat(post-rebuild-check): flag a stopped idle daemon and a read-only remote</title>
<updated>2026-08-20T07:17:09+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-08-20T07:17:09+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/archsetup/commit/?id=87ff0b77cdc1d8a66a063cfe471dab78d1966dc4'/>
<id>urn:sha1:87ff0b77cdc1d8a66a063cfe471dab78d1966dc4</id>
<content type='text'>
Both are states where the machine looks finished and isn't, which is the whole point of this script.

Check 7 asks whether hypridle is running. Nothing else notices when it isn't. Idle lock and suspend stop happening, and the laptop runs until its battery is gone. That's how velox reset its RTC on 2026-08-19, which is what dropped it into check 6's clock and DNS deadlock. The check asks whether the daemon is alive rather than why it might not be, so a crash and a stale caffeine surface alike. It's gated on hypridle being installed, since only Hyprland machines get it.

Check 8 asks whether the working repos can push. The installer clones them from the read-only https endpoint. That's right for someone installing archsetup with no key on my server, and wrong for my own machines. Nothing about the tree shows it. velox's dotfiles remote sat that way for four days and announced itself as a 403.

Only my own read-only endpoint is flagged. An https remote elsewhere may push fine through a credential helper, and guessing about hosts this machine doesn't own would stand noise in front of real findings.
</content>
</entry>
<entry>
<title>fix(check): read NTP sources only from files chrony is told to read</title>
<updated>2026-08-19T19:32:02+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-08-19T19:32:02+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/archsetup/commit/?id=ec3a63caca4f2d955e594318a9a690e4c28af19e'/>
<id>urn:sha1:ec3a63caca4f2d955e594318a9a690e4c28af19e</id>
<content type='text'>
Check 6 globbed /etc/chrony.d unconditionally. A drop-in is inert unless chrony.conf names its directory, and Arch's stock chrony.conf names none, so a machine with the IP-addressed source on disk and no confdir line would show the literal and pass. That is a false pass on exactly the misconfiguration the check exists to catch, and it describes a file chrony never opens.

Sources now come only from chrony.conf plus whatever confdir or sourcedir it actually names. The config path is a seam so the confdir logic can be tested against a fixture instead of the real /etc.

This should have been in the previous commit, whose message already describes it. I staged before reviewing, fixed the finding, then committed the stale index.
</content>
</entry>
<entry>
<title>fix(installer): give NTP an IP source so a wrong clock can't kill DNS</title>
<updated>2026-08-19T19:16:46+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-08-19T19:16:46+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/archsetup/commit/?id=afbf011aa0937b5702b6d8c1bfca0809ed809425'/>
<id>urn:sha1:afbf011aa0937b5702b6d8c1bfca0809ed809425</id>
<content type='text'>
The installer wrote both halves of a deadlock. configure_dns pins DNSOverTLS=yes with DNSSEC=yes, and both validate against the wall clock. The chrony step enables chronyd without writing a config, so the machine runs Arch's stock one, whose only source is a pool hostname.

Boot with a wrong clock and DoT validation fails, so nothing resolves. Chrony then can't resolve its pool, so the clock stays wrong. Neither side moves, and recovery takes a second device. Velox hit this on the road and I diagnosed it from a phone.

An address needs no DNS and no certificate, so two IP-addressed sources in a drop-in break the cycle whatever caused the skew. Stock chrony.conf reads no drop-in directory, so it gets a confdir line pointing at one.

post-rebuild-check grows a sixth check for the same property. It reads sources only from files chrony is told to read. A drop-in beside a chrony.conf that never names its directory is one chrony won't open, so counting it would pass the machine while describing a file nothing reads.

The failure taxonomy gains the mode in its DNS layer and a cluster 5 triage line. Its egress-layer clock entry assumed working DNS and offered set-ntp true, which can't recover this. That entry now says so.
</content>
</entry>
<entry>
<title>feat(scripts): add post-rebuild-check for the gaps a reinstall leaves</title>
<updated>2026-08-17T18:28:55+00:00</updated>
<author>
<name>Craig Jennings</name>
<email>c@cjennings.net</email>
</author>
<published>2026-08-17T18:28:55+00:00</published>
<link rel='alternate' type='text/html' href='https://git.cjennings.net/archsetup/commit/?id=4dfecc5f705c9df0d1f8e8245276146f826e16cc'/>
<id>urn:sha1:4dfecc5f705c9df0d1f8e8245276146f826e16cc</id>
<content type='text'>
A rebuilt machine looks finished and isn't. Five gaps surfaced on velox within two days of its reinstall. Three looked fine on inspection: a stowed unit file, an enabled-looking timer, a present git clone. The script runs those five checks and prints a line for each whether or not it finds anything.

Every probe fails closed. A check that cannot run reports a finding rather than a pass, which matters more here than anywhere: a silent no-op in the checker is the exact failure it exists to catch. `systemctl --user` exits 1 with empty output when there is no user bus, so reading that as "no failed units" would call a machine healthy at the moment nothing was checked. Calls are bounded for the same reason. A check that hangs reports nothing at all, and the machine most in need of checking is the one it hangs on.

I suppressed three classes of finding, each because the live run produced them and reality disagreed. A timer-activated service is supposed to sit linked and not enabled. One expected tooling file is seed-only, so most projects legitimately never have one. Vendored package trees ship their own example files. Left in, those were 19 of the first run's 27 findings, and a check nobody reads is a check that isn't run.

The post-install checklist points at it, and 58 tests cover it.
</content>
</entry>
</feed>
