chore(dotfiles): streamline workstation configuration workflows

This commit is contained in:
2026-08-25 00:00:55 +02:00
parent a81bbf5115
commit 4a20edba5c
12 changed files with 279 additions and 18 deletions
+26 -6
View File
@@ -61,9 +61,27 @@ does not explain a package. System and user units use the same naming:
## Commands
From the repository root, run common workflows through the
`./system-config/decman` wrapper, which uses fixed Decman arguments:
```bash
./system-config/decman verify
./system-config/decman dry-run
./system-config/decman apply
```
`dry-run` and `apply` first run `verify`. They invoke Decman with `--no-hooks`,
matching the deliberately review-first direct commands below. `apply` changes
system state; use it only after reviewing the dry run.
The wrapper locates its configuration relative to itself, so its commands run
from the `system-config` directory. Command output and errors stream directly
to the terminal. If a child command fails, the wrapper prints the failed command
and returns its exit status. A missing executable returns `127`, another
execution error (including a permission error) returns `126`, and an
interrupted workflow returns `130`.
```bash
cd ~/system-config
./scripts/verify.py
sudo decman --source source.py --dry-run --no-hooks
sudo decman --source source.py --no-hooks
```
@@ -75,8 +93,10 @@ orphan cleanup from removing dependencies during adoption.
After intentional package changes, regenerate and review concern lists:
```bash
./scripts/split_packages.py
./scripts/refresh_orphan_guards.py
./scripts/verify.py
git diff
./system-config/decman refresh-package-state
yadm diff -- system-config
```
`refresh-package-state` runs `split-packages`, `refresh-orphan-guards`, and
`verify` in that order. Those individual file-changing commands are also
available through the dispatcher when only one is needed.
@@ -1,3 +1,5 @@
# Used by the Noctalia Pi Status Bridge adapter and the Hyprland launcher.
PI_STATUS_BRIDGE_CLIENT=/home/alex/.local/bin/pi-status-bridge-client
PI_STATUS_UI_BINARY=/home/alex/.local/bin/pi-status-ui
# Ctrl+Super+Space starts a fresh Pi session in this worktree. Change as needed.
PI_STATUS_DEFAULT_WORKTREE=/home/alex/projects/pi-status-bridge
@@ -5,6 +5,12 @@
-- stacking/tabbed, and resize-mode behavior is intentionally deferred.
local machine = dofile(os.getenv("HOME") .. "/.config/hypr/machine.lua")
-- Machine-specific session environment must be set before Hyprland initializes
-- its graphics backend. The laptop profile selects only the Intel iGPU here.
for name, value in pairs(machine.environment or {}) do
hl.env(name, value)
end
for _, monitor in ipairs(machine.monitors or {}) do
hl.monitor(monitor)
end
@@ -63,12 +69,14 @@ hl.workspace_rule({
-- Application and session bindings.
local noctalia_ipc = "noctalia msg "
local pi_status_ui_toggle = 'ui="${PI_STATUS_UI_BINARY:-$HOME/.local/bin/pi-status-ui}"; if test -x "$ui"; then setsid -f env TMPDIR=/tmp "$ui" --toggle >/tmp/pi-status-ui.log 2>&1; fi'
local pi_status_ui_new_session = 'ui="${PI_STATUS_UI_BINARY:-$HOME/.local/bin/pi-status-ui}"; if test -x "$ui"; then setsid -f env TMPDIR=/tmp "$ui" --new >/tmp/pi-status-ui.log 2>&1; fi'
-- Tap and release Super on its own to open Noctalia's control-center home.
hl.bind(main_mod .. " + SUPER_L", hl.dsp.exec_cmd(noctalia_ipc .. "panel-toggle control-center"), { release = true })
hl.bind(main_mod .. " + RETURN", hl.dsp.exec_cmd(terminal))
hl.bind(main_mod .. " + CTRL + RETURN", hl.dsp.exec_cmd(terminal .. " -e zellij"))
hl.bind(main_mod .. " + SHIFT + RETURN", hl.dsp.exec_cmd("rofi -show combi"))
hl.bind(main_mod .. " + SPACE", hl.dsp.exec_cmd(pi_status_ui_toggle))
hl.bind(main_mod .. " + CTRL + SPACE", hl.dsp.exec_cmd(pi_status_ui_new_session))
hl.bind(main_mod .. " + N", hl.dsp.exec_cmd(noctalia_ipc .. "panel-toggle alex/knowledge-lookup:panel"))
hl.bind(main_mod .. " + S", hl.dsp.exec_cmd(noctalia_ipc .. "panel-toggle control-center"))
hl.bind(main_mod .. " + 0", hl.dsp.exec_cmd(noctalia_ipc .. "panel-toggle session"))
@@ -2,6 +2,13 @@
-- Noctalia replaces the i3-era Redshift, Polybar, Blueman Applet, and Picom stack.
return {
is_laptop = true,
-- Use the colon-free user-config alias for the stable PCI path rather than
-- cardN, whose numbering can change between boots. AQ_DRM_DEVICES itself
-- uses colons as device separators. This keeps Hyprland off the NVIDIA dGPU
-- so it can enter Runtime D3 when no application is explicitly offloaded.
environment = {
AQ_DRM_DEVICES = os.getenv("HOME") .. "/.config/hypr/intel-drm",
},
input = {
touchpad = {
-- Standard laptop behavior: one/two/three-finger taps map to
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env sh
# Repository-local Decman interface; avoids changing the system `decman` command.
exec "$(dirname "$0")/scripts/decman_cli.py" "$@"
+15
View File
@@ -30,11 +30,13 @@ class UserConfigConcern(decman.Module):
name: str,
files: dict[str, str | tuple[str, int]] | None = None,
directories: dict[str, str] | None = None,
symlinks: dict[str, str] | None = None,
user: str = "alex",
) -> None:
super().__init__(f"user:{name}")
self._files = files or {}
self._directories = directories or {}
self._symlinks = symlinks or {}
self.user = user
def files(self) -> dict[str, decman.File]:
@@ -60,6 +62,12 @@ class UserConfigConcern(decman.Module):
for target, source in self._directories.items()
}
def symlinks(self) -> dict[str, str | decman.Symlink]:
return {
link_name: decman.Symlink(target=target, owner=self.user)
for link_name, target in self._symlinks.items()
}
class SystemConcern(decman.Module):
"""One host/system concern with explicit files and unit manifests."""
@@ -177,6 +185,11 @@ def laptop_user_config() -> list[UserConfigConcern]:
"config/user/laptop/noctalia/99-extend.toml"
),
},
symlinks={
"/home/alex/.config/hypr/intel-drm": (
"/dev/dri/by-path/pci-0000:00:02.0-card"
),
},
)
]
@@ -242,6 +255,8 @@ def laptop_system() -> list[SystemConcern | UserConfigConcern]:
"/etc/keyd/default.conf": "config/system/common/keyd.conf",
"/etc/NetworkManager/conf.d/wifi-powersave.conf": "config/system/laptop/wifi-powersave.conf",
"/etc/modprobe.d/iwlwifi.conf": "config/system/laptop/iwlwifi.conf",
"/etc/modprobe.d/nvidia-runtime-pm.conf": "config/system/laptop/modprobe.d/nvidia-runtime-pm.conf",
"/etc/udev/rules.d/80-nvidia-runtime-pm.rules": "config/system/laptop/udev/80-nvidia-runtime-pm.rules",
"/etc/X11/xorg.conf.d/30-touchpad.conf": "config/system/laptop/touchpad.conf",
"/etc/tlp.conf": "config/system/laptop/power/tlp.conf",
"/etc/systemd/sleep.conf.d/90-laptop.conf": "config/system/laptop/sleep.conf.d/90-laptop.conf",
@@ -16,6 +16,7 @@ go
gperf
ibus
imake
jsoncpp
karchive5
kauth
kauth5
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""Run the repository's common Decman workflows with safe, fixed commands."""
from __future__ import annotations
import argparse
import shlex
import subprocess
import sys
from collections.abc import Callable, Sequence
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "scripts"
def format_command(command: Sequence[str] | str) -> str:
"""Render a shell-safe command for diagnostic output."""
return command if isinstance(command, str) else shlex.join(command)
def run(command: Sequence[str]) -> None:
"""Print and run a command from the repository root."""
print(f"+ {format_command(command)}", file=sys.stderr)
subprocess.run(command, check=True, cwd=ROOT)
def run_script(name: str) -> None:
run([sys.executable, str(SCRIPTS / name)])
def verify() -> None:
"""Check the active profile's package and unit invariants."""
run_script("verify.py")
def dry_run() -> None:
"""Verify the configuration, then preview Decman's changes."""
verify()
run(["sudo", "decman", "--source", "source.py", "--dry-run", "--no-hooks"])
def apply() -> None:
"""Verify the configuration, then apply it without Decman hooks."""
verify()
run(["sudo", "decman", "--source", "source.py", "--no-hooks"])
def split_packages() -> None:
"""Regenerate concern package lists from explicit installed packages."""
run_script("split_packages.py")
def refresh_orphan_guards() -> None:
"""Regenerate dependency-orphan guards from installed packages."""
run_script("refresh_orphan_guards.py")
def refresh_package_state() -> None:
"""Regenerate package data and verify the resulting configuration."""
split_packages()
refresh_orphan_guards()
verify()
COMMANDS: dict[str, Callable[[], None]] = {
"verify": verify,
"dry-run": dry_run,
"apply": apply,
"split-packages": split_packages,
"refresh-orphan-guards": refresh_orphan_guards,
"refresh-package-state": refresh_package_state,
}
def failure_status(error: Exception) -> int | None:
"""Report a launch failure and return its shell-compatible status."""
if isinstance(error, subprocess.CalledProcessError):
status = error.returncode if error.returncode > 0 else 128 - error.returncode
print(
f"error: command exited with status {status}: {format_command(error.cmd)}",
file=sys.stderr,
)
return status
if isinstance(error, FileNotFoundError):
print(f"error: executable not found: {error.filename}", file=sys.stderr)
return 127
if isinstance(error, PermissionError):
print(f"error: executable is not permitted: {error.filename}", file=sys.stderr)
return 126
if isinstance(error, OSError):
print(
f"error: unable to execute {error.filename}: {error.strerror}",
file=sys.stderr,
)
return 126
return None
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"command",
choices=COMMANDS,
help="workflow to run; apply and package-state commands modify system or files",
)
args = parser.parse_args()
try:
COMMANDS[args.command]()
except KeyboardInterrupt:
print("error: interrupted", file=sys.stderr)
return 130
except Exception as error:
status = failure_status(error)
if status is None:
raise
return status
return 0
if __name__ == "__main__":
raise SystemExit(main())