From 793aedac595bae08d05c910146513700ce854879 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 19:21:08 +0000 Subject: [PATCH 1/8] tmux: pin the plugin submodules .gitmodules declared but never added .gitmodules has listed tpm, nord-tmux, and tmux-sensible since #1, but the tree only ever carried a gitlink for dotbot, so `git submodule update --init --recursive` created nothing under tmux/plugins/ and tmux.conf's `run '~/.tmux/plugins/tpm/tpm'` failed on every fresh clone. Add the three gitlinks at their current upstream heads. Claude-Session: https://claude.ai/code/session_01R1D4FTi7umCLFpkuR355od --- tmux/plugins/nord-tmux | 1 + tmux/plugins/tmux-sensible | 1 + tmux/plugins/tpm | 1 + 3 files changed, 3 insertions(+) create mode 160000 tmux/plugins/nord-tmux create mode 160000 tmux/plugins/tmux-sensible create mode 160000 tmux/plugins/tpm diff --git a/tmux/plugins/nord-tmux b/tmux/plugins/nord-tmux new file mode 160000 index 0000000..f7b6da0 --- /dev/null +++ b/tmux/plugins/nord-tmux @@ -0,0 +1 @@ +Subproject commit f7b6da07ab55fe32ee5f7d62da56d8e5ac691a92 diff --git a/tmux/plugins/tmux-sensible b/tmux/plugins/tmux-sensible new file mode 160000 index 0000000..25cb91f --- /dev/null +++ b/tmux/plugins/tmux-sensible @@ -0,0 +1 @@ +Subproject commit 25cb91f42d020f675bb0a2ce3fbd3a5d96119efa diff --git a/tmux/plugins/tpm b/tmux/plugins/tpm new file mode 160000 index 0000000..e261deb --- /dev/null +++ b/tmux/plugins/tpm @@ -0,0 +1 @@ +Subproject commit e261deb1b47614eed3400089ce7197dc68acc4eb From d335fa92663a4cde61c4b20186b0cdce6b053a03 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 19:41:15 +0000 Subject: [PATCH 2/8] install,provision: replace the two halves with modules and roles The repo modelled one axis of variation - desktop or not - as a flag, but the machines it serves vary along more: the new headless VPS is neither half, and --no-desktop still gave it kitty, fonts, xclip, a rust toolchain, and a source-built tree-sitter. Per-machine values (DPI, redshift's location, polybar's hardware ids) were either symlinked to every machine or seeded once and never updated, and the desktop half had no overlay seam at all, which is why the work-laptop profiles #11 moved out came back in #15. Layout: modules// holds one tool's config (install.conf.yaml, sources module-relative) and the provisioning that makes it work (provision.sh, standalone-runnable). roles/{server,workstation,desktop} list modules, nesting via @role; modules may declare `requires`, and a selection that leaves one out is refused up front. ./install and ./provision.sh take --roles/--modules, save the selection to ~/.config/dotfiles/roles, and re-apply it on a bare run; --no-desktop remains as an alias for --roles workstation. lib/roles.sh resolves, lib/common.sh logs, lib/provision-lib.sh keeps every pin and sha256 helper and gains a package layer: pkg_ensure installs only what is missing and refreshes the index once per run on first need, so a converged machine touches neither apt nor sudo; pkg_candidate_version and version_ge let the x11 module take the distro polybar (>= 3.7.0) and picom (>= 13) where they are new enough instead of always source-building 22.04's replacements; require_supported_platform states the Debian-family / x86_64 limit up front. cli-tools goes first in every role because it installs curl. Per-machine values are data: hosts/defaults.env < hosts/.env < untracked ~/.config/dotfiles/local.env, merged by ./install into ~/.config/dotfiles/host.env. A dotbot `render:` directive (lib/dotbot-plugins/render.py) substitutes ${NAME} into the two files that carry such values - Xresources and redshift.conf are now templates copied into place, refreshed on every install - and bspwmrc exports the same file into the session so polybar's backlight/battery ids and network-env.sh's fallback interface come from it too. apps/monitor-manager/ is the Python monitor-profile system, extracted from ~/.config/bspwm where its tests and lockfile were being symlinked: the package is renamed lib -> monitor_manager, monitor-manager.py becomes monitor_manager/cli.py behind a `monitor-manager` console script, and the x11 module installs it as an editable uv tool constrained to uv.lock. bspwmrc, apply-auto.sh, and monitor-switch.sh call the tool instead of three different python/uv invocations. ProfileService reads a search path - ~/.config/bspwm/profiles (the tracked profiles, linked individually so ~/.config/bspwm is a real directory) then ~/.config/bspwm/profiles.d - which is the desktop half's overlay seam. The reconciler's polybar env vars are named in one place (bar_env, POLYBAR_ENV_VARS) and tests/test_polybar_contract.py holds the polybar config to that list. Overlay seams now win: bashrc sources ~/.config/dotfiles/env.sh before the interactive gate (for ssh commands, cron, units), ~/.bash_aliases after every tool block instead of before them, prepends rather than assigns PROMPT_COMMAND, and guards the lsd aliases; gitconfig's include moves to the bottom so ~/.gitconfig.local can override any key; tmux.conf gains `source -q ~/.tmux.conf.local` and kitty.conf `globinclude ~/.config/kitty.local.conf`. clipimg, the remote-side half of the clipboard kitten, moves to cli-tools so servers get it; AGENTS.md is also linked to ~/.codex/AGENTS.md; wacominit is its own per-host module. launch.sh reads font_size from kitty.conf instead of duplicating it and honours the cwd it computes. Dead config goes: polybar's never-included user_modules.ini and the unused wired/wireless-network modules, starship's [custom.foxdev] that the explicit format never rendered, the stray top-level kitty theme (now themes/aquarium_dark.conf). rofi's themes referenced an undefined @ac; colors.rasi now defines it as bspwm's accent. dunstrc resolves rofi and xdg-open on PATH rather than /usr/bin. The networkmanager_dmenu click action pointed at a binary nothing provisions; nm-connection-editor ships with network-manager-gnome. The one test that shells out to xrandr skips without a DISPLAY instead of failing. CI installs the server role from scratch in ubuntu:22.04 and ubuntu:24.04 containers as a non-root user, checks the shell and tools, and re-runs both scripts asserting only [skip]/[note] output; tests verify_sha256's refusal and install_release_binary's refusal to install on a mismatch; tests role resolution; runs the monitor-manager suite and its uv tool install; shellchecks every tracked script. Claude-Session: https://claude.ai/code/session_01R1D4FTi7umCLFpkuR355od --- .github/workflows/ci.yml | 140 ++++++- .gitmodules | 6 +- CLAUDE.md | 180 +++++---- README.md | 216 +++++----- Xresources | 2 - apps/monitor-manager/CLAUDE.md | 195 +++++++++ .../monitor_manager}/__init__.py | 0 .../monitor-manager/monitor_manager/cli.py | 27 +- .../monitor_manager}/coordinator.py | 0 .../monitor_manager}/display.py | 0 .../monitor-manager/monitor_manager}/edid.py | 0 .../monitor_manager}/exceptions.py | 0 .../monitor_manager}/executor.py | 0 .../monitor_manager}/interactive.py | 0 .../monitor-manager/monitor_manager}/ops.py | 0 .../monitor-manager/monitor_manager}/plan.py | 0 .../monitor_manager}/preferences.py | 0 .../monitor_manager}/probe/__init__.py | 0 .../monitor_manager}/probe/bspwm.py | 0 .../monitor_manager}/probe/composite.py | 0 .../monitor_manager}/probe/polybar.py | 0 .../monitor_manager}/probe/xrandr.py | 0 .../monitor_manager}/profile.py | 80 ++-- .../monitor_manager}/reconciler.py | 50 ++- .../monitor_manager}/renderer.py | 0 .../monitor_manager}/safety.py | 0 .../monitor_manager}/simulate.py | 0 .../monitor_manager}/state/__init__.py | 0 .../monitor_manager}/state/desired.py | 0 .../monitor_manager}/state/hardware.py | 0 .../monitor-manager}/pyproject.toml | 9 +- .../scripts/capture-fixture.sh | 4 +- .../monitor-manager}/scripts/redact-edid.py | 0 .../fixtures/bspc/personal-solo-desktops.txt | 0 .../fixtures/bspc/personal-solo-dump.json | 0 .../bspc/personal-solo-eDP-1-desktops.txt | 0 .../fixtures/bspc/personal-solo-monitors.txt | 0 .../tests/fixtures/profiles/defaults.yaml | 0 .../fixtures/profiles/personal-home.yaml | 0 .../fixtures/profiles/personal-solo.yaml | 0 .../fixtures/xrandr/personal-solo-props.txt | 2 +- .../tests/fixtures/xrandr/personal-solo.txt | 0 .../monitor-manager}/tests/test_cli.py | 4 +- .../tests/test_coordinator.py | 8 +- .../monitor-manager}/tests/test_display.py | 2 +- .../monitor-manager}/tests/test_executor.py | 14 +- .../tests/test_integration.py | 26 +- .../tests/test_interactive.py | 40 +- .../monitor-manager}/tests/test_ops.py | 2 +- .../tests/test_plan_policies.py | 2 +- .../tests/test_polybar_contract.py | 59 +++ .../tests/test_probe_bspwm.py | 2 +- .../tests/test_probe_composite.py | 10 +- .../tests/test_probe_polybar.py | 2 +- .../tests/test_probe_xrandr.py | 4 +- .../monitor-manager}/tests/test_profile.py | 6 +- .../tests/test_profile_defaults.py | 2 +- .../tests/test_profile_search_path.py | 101 +++++ .../monitor-manager}/tests/test_reconciler.py | 16 +- .../monitor-manager}/tests/test_renderer.py | 4 +- .../monitor-manager}/tests/test_safety.py | 2 +- .../monitor-manager}/tests/test_simulate.py | 6 +- .../tests/test_state_desired.py | 4 +- .../tests/test_state_hardware.py | 2 +- .../tests/test_tracked_profiles.py | 7 +- .../bspwm => apps/monitor-manager}/uv.lock | 84 ++-- bin/.gitkeep | 0 desktop-environment/bspwm/CLAUDE.md | 154 ------- .../bspwm/scripts/monitor-switch.sh | 13 - .../polybar/shades/user_modules.ini | 143 ------- desktop-environment/provision.sh | 164 -------- docs/desktop-environment-prd.md | 7 +- hosts/defaults.env | 27 ++ install | 99 +++-- install-desktop.conf.yaml | 42 -- install.conf.yaml | 35 -- lib/common.sh | 16 + lib/dotbot-plugins/render.py | 113 ++++++ provision-lib.sh => lib/provision-lib.sh | 143 ++++++- lib/roles.sh | 120 ++++++ .../user => modules/agents}/AGENTS.md | 0 modules/agents/install.conf.yaml | 13 + .../scripts => modules/agents}/status-line.sh | 0 {bash => modules/bash}/bash_aliases | 0 {bash => modules/bash}/bashrc | 47 ++- {bash => modules/bash}/inputrc | 0 modules/bash/install.conf.yaml | 12 + {bash => modules/bash}/profile | 0 modules/bash/provision.sh | 14 + .../bazel}/bazel_completions.bash | 0 modules/bazel/install.conf.yaml | 9 + {bin => modules/cli-tools/bin}/clipimg | 0 modules/cli-tools/install.conf.yaml | 12 + modules/cli-tools/provision.sh | 114 ++++++ {git => modules/git}/git-prompt.sh | 0 {git => modules/git}/gitconfig | 17 +- {git => modules/git}/gitmessage | 0 {git => modules/git}/hooks/identity-guard.sh | 0 {git => modules/git}/hooks/pre-commit | 0 {git => modules/git}/hooks/pre-merge-commit | 0 {git => modules/git}/hooks/pre-push | 0 modules/git/install.conf.yaml | 14 + {git => modules/git}/lazygit.yml | 0 modules/git/provision.sh | 27 ++ modules/go/provision.sh | 34 ++ .../kitty/config}/current-theme.conf | 0 {kitty => modules/kitty/config}/kitty.conf | 4 + {kitty => modules/kitty/config}/launch.sh | 12 +- .../kitty/config/themes/aquarium_dark.conf | 0 .../kitty/config}/themes/diff-frappe.conf | 0 .../kitty/config}/themes/diff-latte.conf | 0 .../kitty/config}/themes/diff-macchiato.conf | 0 .../kitty/config}/themes/diff-mocha.conf | 0 .../kitty/config}/themes/frappe.conf | 0 .../kitty/config}/themes/latte.conf | 0 .../kitty/config}/themes/macchiato.conf | 0 .../kitty/config}/themes/mocha.conf | 0 .../kitty/config}/themes/tokyonight_day.conf | 0 .../kitty/config}/themes/tokyonight_moon.conf | 0 .../config}/themes/tokyonight_night.conf | 0 .../config}/themes/tokyonight_storm.conf | 0 {kitty => modules/kitty/config}/zenmode.py | 0 modules/kitty/install.conf.yaml | 12 + modules/kitty/provision.sh | 58 +++ modules/node/provision.sh | 41 ++ modules/nvim/provision.sh | 89 +++++ modules/starship/install.conf.yaml | 9 + modules/starship/provision.sh | 23 ++ {starship => modules/starship}/starship.toml | 5 - .../tmux/config}/plugins/nord-tmux | 0 .../tmux/config}/plugins/tmux-sensible | 0 {tmux => modules/tmux/config}/plugins/tpm | 0 {tmux => modules/tmux/config}/settings.conf | 0 {tmux => modules/tmux/config}/tmux.conf | 3 + modules/tmux/install.conf.yaml | 11 + modules/tmux/provision.sh | 14 + modules/typst/provision.sh | 33 ++ modules/wacom/install.conf.yaml | 10 + modules/wacom/provision.sh | 14 + modules/wacom/requires | 1 + {bin => modules/wacom}/wacominit | 0 modules/x11/Xresources.tmpl | 4 + {bin => modules/x11/bin}/desktop-session-log | 0 {bin => modules/x11/bin}/lockscreen | 0 .../x11}/bspwm/bspwmrc | 26 +- .../x11}/bspwm/profiles/defaults.yaml | 0 .../x11}/bspwm/profiles/personal-home.yaml | 2 +- .../x11}/bspwm/profiles/personal-solo.yaml | 2 +- .../x11}/bspwm/profiles/work-laptop-home.yaml | 0 .../work-laptop-woodinville-clamshell.yaml | 0 .../profiles/work-laptop-woodinville.yaml | 0 .../x11}/bspwm/scripts/apply-auto.sh | 9 +- modules/x11/bspwm/scripts/monitor-switch.sh | 11 + .../x11}/bspwm/scripts/monocle-border.sh | 0 .../x11}/bspwm/scripts/network-env.sh | 4 +- .../x11}/bspwm/scripts/smart_focus.sh | 0 .../x11}/bspwm/scripts/smart_resize.sh | 0 .../x11}/bspwm/scripts/smart_send.sh | 0 .../x11}/bspwm/scripts/toggle_polybar.sh | 0 {desktop-environment => modules/x11}/dunstrc | 4 +- modules/x11/install.conf.yaml | 28 ++ .../x11}/picom.conf | 0 .../x11}/polybar/shades/bars.ini | 6 +- .../x11}/polybar/shades/colors.ini | 0 .../x11}/polybar/shades/config.ini | 0 .../x11}/polybar/shades/modules.ini | 21 +- .../x11}/polybar/shades/scripts/calendar.sh | 0 .../x11}/polybar/shades/scripts/launcher.sh | 0 .../polybar/shades/scripts/network-label.sh | 0 .../x11}/polybar/shades/scripts/powermenu.sh | 0 .../polybar/shades/scripts/rofi/colors.rasi | 2 + .../polybar/shades/scripts/rofi/confirm.rasi | 0 .../polybar/shades/scripts/rofi/launcher.rasi | 0 .../polybar/shades/scripts/rofi/message.rasi | 0 .../shades/scripts/rofi/powermenu.rasi | 0 modules/x11/provision.sh | 180 +++++++++ .../x11/redshift.conf.tmpl | 11 +- modules/x11/requires | 4 + .../x11}/session/dotfiles-session-log.desktop | 0 {systemd => modules/x11}/swapescape.service | 0 .../x11}/sxhkd/sxhkdrc | 0 provision-shell.sh | 376 ------------------ provision.sh | 52 ++- roles/desktop | 5 + roles/server | 13 + roles/workstation | 9 + 186 files changed, 2383 insertions(+), 1434 deletions(-) delete mode 100644 Xresources create mode 100644 apps/monitor-manager/CLAUDE.md rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/__init__.py (100%) rename desktop-environment/bspwm/monitor-manager.py => apps/monitor-manager/monitor_manager/cli.py (96%) rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/coordinator.py (100%) rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/display.py (100%) rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/edid.py (100%) rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/exceptions.py (100%) rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/executor.py (100%) rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/interactive.py (100%) rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/ops.py (100%) rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/plan.py (100%) rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/preferences.py (100%) rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/probe/__init__.py (100%) rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/probe/bspwm.py (100%) rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/probe/composite.py (100%) rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/probe/polybar.py (100%) rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/probe/xrandr.py (100%) rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/profile.py (86%) rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/reconciler.py (93%) rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/renderer.py (100%) rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/safety.py (100%) rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/simulate.py (100%) rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/state/__init__.py (100%) rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/state/desired.py (100%) rename {desktop-environment/bspwm/lib => apps/monitor-manager/monitor_manager}/state/hardware.py (100%) rename {desktop-environment/bspwm => apps/monitor-manager}/pyproject.toml (80%) rename {desktop-environment/bspwm => apps/monitor-manager}/scripts/capture-fixture.sh (96%) rename {desktop-environment/bspwm => apps/monitor-manager}/scripts/redact-edid.py (100%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/fixtures/bspc/personal-solo-desktops.txt (100%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/fixtures/bspc/personal-solo-dump.json (100%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/fixtures/bspc/personal-solo-eDP-1-desktops.txt (100%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/fixtures/bspc/personal-solo-monitors.txt (100%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/fixtures/profiles/defaults.yaml (100%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/fixtures/profiles/personal-home.yaml (100%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/fixtures/profiles/personal-solo.yaml (100%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/fixtures/xrandr/personal-solo-props.txt (98%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/fixtures/xrandr/personal-solo.txt (100%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/test_cli.py (97%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/test_coordinator.py (94%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/test_display.py (98%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/test_executor.py (97%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/test_integration.py (89%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/test_interactive.py (91%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/test_ops.py (98%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/test_plan_policies.py (96%) create mode 100644 apps/monitor-manager/tests/test_polybar_contract.py rename {desktop-environment/bspwm => apps/monitor-manager}/tests/test_probe_bspwm.py (99%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/test_probe_composite.py (87%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/test_probe_polybar.py (92%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/test_probe_xrandr.py (97%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/test_profile.py (99%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/test_profile_defaults.py (97%) create mode 100644 apps/monitor-manager/tests/test_profile_search_path.py rename {desktop-environment/bspwm => apps/monitor-manager}/tests/test_reconciler.py (97%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/test_renderer.py (97%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/test_safety.py (98%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/test_simulate.py (98%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/test_state_desired.py (98%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/test_state_hardware.py (98%) rename {desktop-environment/bspwm => apps/monitor-manager}/tests/test_tracked_profiles.py (84%) rename {desktop-environment/bspwm => apps/monitor-manager}/uv.lock (99%) delete mode 100644 bin/.gitkeep delete mode 100644 desktop-environment/bspwm/CLAUDE.md delete mode 100755 desktop-environment/bspwm/scripts/monitor-switch.sh delete mode 100644 desktop-environment/polybar/shades/user_modules.ini delete mode 100755 desktop-environment/provision.sh create mode 100644 hosts/defaults.env delete mode 100644 install-desktop.conf.yaml delete mode 100644 install.conf.yaml create mode 100644 lib/common.sh create mode 100644 lib/dotbot-plugins/render.py rename provision-lib.sh => lib/provision-lib.sh (63%) create mode 100644 lib/roles.sh rename {agents-config/user => modules/agents}/AGENTS.md (100%) create mode 100644 modules/agents/install.conf.yaml rename {claude/user/scripts => modules/agents}/status-line.sh (100%) rename {bash => modules/bash}/bash_aliases (100%) rename {bash => modules/bash}/bashrc (81%) rename {bash => modules/bash}/inputrc (100%) create mode 100644 modules/bash/install.conf.yaml rename {bash => modules/bash}/profile (100%) create mode 100755 modules/bash/provision.sh rename {bash => modules/bazel}/bazel_completions.bash (100%) create mode 100644 modules/bazel/install.conf.yaml rename {bin => modules/cli-tools/bin}/clipimg (100%) create mode 100644 modules/cli-tools/install.conf.yaml create mode 100755 modules/cli-tools/provision.sh rename {git => modules/git}/git-prompt.sh (100%) rename {git => modules/git}/gitconfig (63%) rename {git => modules/git}/gitmessage (100%) rename {git => modules/git}/hooks/identity-guard.sh (100%) rename {git => modules/git}/hooks/pre-commit (100%) rename {git => modules/git}/hooks/pre-merge-commit (100%) rename {git => modules/git}/hooks/pre-push (100%) create mode 100644 modules/git/install.conf.yaml rename {git => modules/git}/lazygit.yml (100%) create mode 100755 modules/git/provision.sh create mode 100755 modules/go/provision.sh rename {kitty => modules/kitty/config}/current-theme.conf (100%) rename {kitty => modules/kitty/config}/kitty.conf (94%) rename {kitty => modules/kitty/config}/launch.sh (59%) rename kitty/Aquarium Dark.conf => modules/kitty/config/themes/aquarium_dark.conf (100%) rename {kitty => modules/kitty/config}/themes/diff-frappe.conf (100%) rename {kitty => modules/kitty/config}/themes/diff-latte.conf (100%) rename {kitty => modules/kitty/config}/themes/diff-macchiato.conf (100%) rename {kitty => modules/kitty/config}/themes/diff-mocha.conf (100%) rename {kitty => modules/kitty/config}/themes/frappe.conf (100%) rename {kitty => modules/kitty/config}/themes/latte.conf (100%) rename {kitty => modules/kitty/config}/themes/macchiato.conf (100%) rename {kitty => modules/kitty/config}/themes/mocha.conf (100%) rename {kitty => modules/kitty/config}/themes/tokyonight_day.conf (100%) rename {kitty => modules/kitty/config}/themes/tokyonight_moon.conf (100%) rename {kitty => modules/kitty/config}/themes/tokyonight_night.conf (100%) rename {kitty => modules/kitty/config}/themes/tokyonight_storm.conf (100%) rename {kitty => modules/kitty/config}/zenmode.py (100%) create mode 100644 modules/kitty/install.conf.yaml create mode 100755 modules/kitty/provision.sh create mode 100755 modules/node/provision.sh create mode 100755 modules/nvim/provision.sh create mode 100644 modules/starship/install.conf.yaml create mode 100755 modules/starship/provision.sh rename {starship => modules/starship}/starship.toml (96%) rename {tmux => modules/tmux/config}/plugins/nord-tmux (100%) rename {tmux => modules/tmux/config}/plugins/tmux-sensible (100%) rename {tmux => modules/tmux/config}/plugins/tpm (100%) rename {tmux => modules/tmux/config}/settings.conf (100%) rename {tmux => modules/tmux/config}/tmux.conf (74%) create mode 100644 modules/tmux/install.conf.yaml create mode 100755 modules/tmux/provision.sh create mode 100755 modules/typst/provision.sh create mode 100644 modules/wacom/install.conf.yaml create mode 100755 modules/wacom/provision.sh create mode 100644 modules/wacom/requires rename {bin => modules/wacom}/wacominit (100%) create mode 100644 modules/x11/Xresources.tmpl rename {bin => modules/x11/bin}/desktop-session-log (100%) rename {bin => modules/x11/bin}/lockscreen (100%) rename {desktop-environment => modules/x11}/bspwm/bspwmrc (86%) rename {desktop-environment => modules/x11}/bspwm/profiles/defaults.yaml (100%) rename {desktop-environment => modules/x11}/bspwm/profiles/personal-home.yaml (97%) rename {desktop-environment => modules/x11}/bspwm/profiles/personal-solo.yaml (95%) rename {desktop-environment => modules/x11}/bspwm/profiles/work-laptop-home.yaml (100%) rename {desktop-environment => modules/x11}/bspwm/profiles/work-laptop-woodinville-clamshell.yaml (100%) rename {desktop-environment => modules/x11}/bspwm/profiles/work-laptop-woodinville.yaml (100%) rename {desktop-environment => modules/x11}/bspwm/scripts/apply-auto.sh (82%) create mode 100755 modules/x11/bspwm/scripts/monitor-switch.sh rename {desktop-environment => modules/x11}/bspwm/scripts/monocle-border.sh (100%) rename {desktop-environment => modules/x11}/bspwm/scripts/network-env.sh (87%) rename {desktop-environment => modules/x11}/bspwm/scripts/smart_focus.sh (100%) rename {desktop-environment => modules/x11}/bspwm/scripts/smart_resize.sh (100%) rename {desktop-environment => modules/x11}/bspwm/scripts/smart_send.sh (100%) rename {desktop-environment => modules/x11}/bspwm/scripts/toggle_polybar.sh (100%) rename {desktop-environment => modules/x11}/dunstrc (99%) create mode 100644 modules/x11/install.conf.yaml rename {desktop-environment => modules/x11}/picom.conf (100%) rename {desktop-environment => modules/x11}/polybar/shades/bars.ini (99%) rename {desktop-environment => modules/x11}/polybar/shades/colors.ini (100%) rename {desktop-environment => modules/x11}/polybar/shades/config.ini (100%) rename {desktop-environment => modules/x11}/polybar/shades/modules.ini (97%) rename {desktop-environment => modules/x11}/polybar/shades/scripts/calendar.sh (100%) rename {desktop-environment => modules/x11}/polybar/shades/scripts/launcher.sh (100%) rename {desktop-environment => modules/x11}/polybar/shades/scripts/network-label.sh (100%) rename {desktop-environment => modules/x11}/polybar/shades/scripts/powermenu.sh (100%) rename {desktop-environment => modules/x11}/polybar/shades/scripts/rofi/colors.rasi (82%) rename {desktop-environment => modules/x11}/polybar/shades/scripts/rofi/confirm.rasi (100%) rename {desktop-environment => modules/x11}/polybar/shades/scripts/rofi/launcher.rasi (100%) rename {desktop-environment => modules/x11}/polybar/shades/scripts/rofi/message.rasi (100%) rename {desktop-environment => modules/x11}/polybar/shades/scripts/rofi/powermenu.rasi (100%) create mode 100755 modules/x11/provision.sh rename redshift.conf => modules/x11/redshift.conf.tmpl (68%) create mode 100644 modules/x11/requires rename {desktop-environment => modules/x11}/session/dotfiles-session-log.desktop (100%) rename {systemd => modules/x11}/swapescape.service (100%) rename {desktop-environment => modules/x11}/sxhkd/sxhkdrc (100%) delete mode 100755 provision-shell.sh create mode 100644 roles/desktop create mode 100644 roles/server create mode 100644 roles/workstation diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 104219f..7ef65d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,128 @@ on: pull_request: jobs: + # The thing most likely to break is a fresh install, so it is tested from + # scratch: a clean container, a non-root sudo user (provisioning refuses + # root), provision + install for the server role, then the shell is + # exercised and both scripts are run a second time to prove they converge + # without doing anything. + install-server: + name: install (server) on ${{ matrix.image }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + image: ["ubuntu:22.04", "ubuntu:24.04"] + container: + image: ${{ matrix.image }} + steps: + - name: Container prerequisites + run: | + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo git ca-certificates + useradd -m -s /bin/bash ci + echo 'ci ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/ci + + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Hand the checkout to the ci user + run: | + cp -a "$GITHUB_WORKSPACE" /home/ci/dotfiles + chown -R ci:ci /home/ci/dotfiles + # the workspace is bind-mounted; git refuses to touch a checkout + # owned by someone else without this + sudo -u ci git config --global --add safe.directory '*' + + - name: First provision (server role) + run: sudo -u ci -H bash -lc 'cd ~/dotfiles && ./provision.sh --roles server' + + - name: First install (server role) + run: sudo -u ci -H bash -lc 'cd ~/dotfiles && ./install --roles server' + + - name: The shell works + run: | + sudo -u ci -H bash -lc ' + set -euo pipefail + [ "$(readlink ~/.bashrc)" = "$HOME/dotfiles/modules/bash/bashrc" ] + [ -x ~/.local/bin/clipimg ] + [ ! -e ~/.config/bspwm ] && [ ! -e ~/.config/kitty ] && [ ! -e ~/.local/kitty.app ] + [ "$(cat ~/.config/dotfiles/roles)" = server ] + # an interactive shell sources the whole chain and lands on lsd + bash -ic "type ll" | grep -q lsd + # the provisioned tools resolve on the PATH bashrc builds + bash -ic "starship --version && fzf --version && lsd --version && nvim --version | head -1" + # the tmux plugin submodules exist and tpm resolves + [ -f ~/.tmux/plugins/tpm/tpm ] + tmux -f ~/.tmux.conf start-server \; kill-server + ' + + # A converged machine must be a no-op: no downloads, no apt, only + # [skip]/[note] lines. The absence of any "==> ... (source build)" or + # install messages is checked via the log the run itself writes. + - name: Second run converges + run: | + sudo -u ci -H bash -lc ' + set -euo pipefail + cd ~/dotfiles + : > provision.log + ./provision.sh + ./install + echo "--- second-run provision.log" + cat provision.log + # a converged run prints only section headers and [skip]/[note] + # lines; anything else is a step that re-ran + if grep -vqE "^\s*$|^==>|^\s+\[(skip|note)\]" provision.log; then + echo "second provisioning run did work it should have skipped:" >&2 + grep -vE "^\s*$|^==>|^\s+\[(skip|note)\]" provision.log >&2 + exit 1 + fi + ' + + # The verification the whole supply-chain policy rests on: a wrong sha256 + # must abort, and the helper must never install anything on mismatch. + lib-tests: + name: provision-lib tests + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: verify_sha256 refuses a mismatch + run: | + set -euo pipefail + . ./lib/provision-lib.sh + tmp="$(mktemp -d)" + printf 'hello\n' > "$tmp/file" + good="$(sha256sum "$tmp/file" | cut -d' ' -f1)" + verify_sha256 "$tmp/file" "$good" + if (verify_sha256 "$tmp/file" "0000000000000000000000000000000000000000000000000000000000000000"); then + echo "verify_sha256 accepted a wrong hash" >&2; exit 1 + fi + # install_release_binary must not install on a mismatch either + mkdir -p "$HOME/.local/bin" + tar -czf "$tmp/archive.tgz" -C "$tmp" file + python3 -m http.server --directory "$tmp" 8765 >/dev/null 2>&1 & + sleep 1 + if (install_release_binary "http://127.0.0.1:8765/archive.tgz" "$good" file should-not-exist); then + echo "install_release_binary installed despite a wrong archive hash" >&2; exit 1 + fi + [ ! -e "$HOME/.local/bin/should-not-exist" ] + - name: role resolution + run: | + set -euo pipefail + REPO_ROOT="$PWD" + . ./lib/common.sh + . ./lib/roles.sh + # nesting, order, dedup + [ "$(resolve_modules desktop | tr '\n' ' ')" = "cli-tools bash git tmux starship nvim agents kitty bazel node go typst x11 " ] + # a module reached twice is applied once + [ "$(resolve_modules server workstation | wc -l)" = "$(resolve_modules workstation | wc -l)" ] + # requires: wacom without x11 is refused + if (check_requires wacom); then echo "check_requires let wacom through without x11" >&2; exit 1; fi + check_requires $(resolve_modules desktop wacom) + # unknown names are refused + if (resolve_modules nonsense 2>/dev/null); then echo "unknown role accepted" >&2; exit 1; fi + monitor-manager-tests: name: monitor-manager tests # ubuntu-24.04 matches the newest machine this repo targets @@ -19,23 +141,31 @@ jobs: - name: Install uv (via provision-lib.sh) run: | mkdir -p "$HOME/.local/bin" - . ./provision-lib.sh + . ./lib/provision-lib.sh install_uv echo "$HOME/.local/bin" >> "$GITHUB_PATH" - # One safety-snapshot test shells out to real xrandr, so the suite runs - # under a virtual X display. + # One safety-snapshot test shells out to real xrandr and skips without + # a display; run it for real under a virtual one. - name: Install xvfb + xrandr run: sudo apt-get update -qq && sudo apt-get install -y -qq xvfb x11-xserver-utils - name: Sync venv from lockfile - working-directory: desktop-environment/bspwm + working-directory: apps/monitor-manager run: uv sync --locked - name: Run tests - working-directory: desktop-environment/bspwm + working-directory: apps/monitor-manager run: xvfb-run -a uv run --no-sync pytest -q + # The same install path modules/x11/provision.sh uses + - name: uv tool install works + working-directory: apps/monitor-manager + run: | + uv export --locked --no-dev --no-hashes --no-emit-project -q -o /tmp/constraints.txt + uv tool install --editable -c /tmp/constraints.txt . + monitor-manager --help + shellcheck: name: shellcheck runs-on: ubuntu-24.04 diff --git a/.gitmodules b/.gitmodules index e262d35..a23f1ee 100644 --- a/.gitmodules +++ b/.gitmodules @@ -3,14 +3,14 @@ url = https://github.com/anishathalye/dotbot ignore = dirty [submodule "tmux/plugins/tpm"] - path = tmux/plugins/tpm + path = modules/tmux/config/plugins/tpm url = https://github.com/tmux-plugins/tpm ignore = dirty [submodule "tmux/plugins/nord-tmux"] - path = tmux/plugins/nord-tmux + path = modules/tmux/config/plugins/nord-tmux url = https://github.com/arcticicestudio/nord-tmux ignore = dirty [submodule "tmux/plugins/tmux-sensible"] - path = tmux/plugins/tmux-sensible + path = modules/tmux/config/plugins/tmux-sensible url = https://github.com/tmux-plugins/tmux-sensible ignore = dirty diff --git a/CLAUDE.md b/CLAUDE.md index 624f4f9..7f4e878 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,67 +4,86 @@ Guidance for working on this repository. ## What this repo is -Personal dotfiles: a shell/terminal/dev foundation plus a bspwm-based X11 -desktop, installed via Dotbot from the repo root. The desktop half is -optional: `--no-desktop` on `./install` and `./provision.sh` skips it. +Personal dotfiles installed via Dotbot, organised as modules (one tool's +config plus its provisioning) selected by roles (`server`, `workstation`, +`desktop`). `./install --roles ` and `./provision.sh --roles ` +are the entry points; the selection is saved so later runs need no flags. -Do not confuse `agents-config/user/AGENTS.md` (the file this repo symlinks to -`~/.claude/CLAUDE.md` on install — user-level agent guidance) -with this file (guidance for editing this repo). +Do not confuse `modules/agents/AGENTS.md` (the file this repo symlinks to +`~/.claude/CLAUDE.md` and `~/.codex/AGENTS.md` on install - user-level agent +guidance) with this file (guidance for editing this repo). ## Layout -- `bash/`, `git/`, `tmux/`, `kitty/`, `starship/` — the shell/terminal/dev - foundation. Each is independent of the others, so they get their own - top-level directory rather than nesting under one umbrella. -- `agents-config/` — harness-agnostic agent instructions and content. -- `claude/` — Claude-specific integration such as the user status-line script. -- `desktop-environment/` — the bspwm + sxhkd + polybar X11 stack, plus - picom and dunst. These stay grouped under one directory because they're - developed and tested together (sxhkd's hotkeys and polybar's toggle - scripts reference bspwm's installed config paths directly). -- `redshift.conf`, `Xresources`, `systemd/`, `docs/`, `bin/` — standalone - leaf configs and scripts that don't reference anything else, so they live - at the top level. -- See `README.md` for the full directory-by-directory breakdown. +- `roles/` - one module per line; `@other` pulls in another role. +- `modules//` - `install.conf.yaml` (dotbot, sources relative to the + module), `provision.sh` (standalone-runnable, sources + `lib/provision-lib.sh`), optional `requires`. A module whose whole + directory is linked (`tmux`, `kitty`) keeps its config under `config/` so + the manifest and provisioning don't land in `$HOME`. +- `modules/x11/` - the bspwm + sxhkd + polybar stack plus picom, dunst, + redshift, Xresources, the systemd user unit, and the X11-only scripts. + Grouped because sxhkd's hotkeys and polybar's toggle scripts reference + bspwm's installed config paths directly. `~/.config/bspwm` is a real + directory with `bspwmrc`, `scripts/`, and `profiles/` linked into it, so + an overlay's `profiles.d/` can sit beside them. +- `apps/monitor-manager/` - an application, not a dotfile: the Python + monitor-profile system with its own tests, `pyproject.toml`, `uv.lock`, + and `CLAUDE.md`. Installed by `modules/x11/provision.sh` as a uv tool + (`--editable`, constrained to `uv.lock`). Anything with a test suite is + an app and lives here, not under `modules/`. +- `hosts/` - per-machine values; `lib/` - shared shell helpers and the + dotbot `render` plugin; `docs/` - the desktop PRD and notes. +- See `README.md` for the module-by-module breakdown. ## Architecture -- [Dotbot](https://github.com/anishathalye/dotbot) drives installation via - two root configs — `install.conf.yaml` (shell/terminal/dev, always - applied) and `install-desktop.conf.yaml` (the desktop-environment links - and steps, skipped when `./install` is given `--no-desktop`); `./install` - is the entry point. There is one dotbot submodule, shared by both halves. - When adding a config file, put its entry in whichever half it belongs to; - anything X11-dependent goes in the desktop config. -- Configs are flat root-level files or per-tool directories, symlinked into - place — no templating, no generated files. -- Provisioning is split in two, matching the two stacks: `provision-shell.sh` - (CLI tooling: starship, fzf, lsd, fd, ripgrep, lazygit, glab, node/nvm, - uv, rust/tree-sitter, plus the kitty and neovim bundles) and - `desktop-environment/provision.sh` (X11/WM packages + the bspwm - monitor-manager's venv). Root `provision.sh` runs both in sequence - (`--no-desktop` skips the X11 half); each is also independently runnable - and idempotent, and both source the shared `provision-lib.sh`. Targets - Ubuntu 22.04+ on x86_64 only. -- Tmux plugins (tpm, nord-tmux, tmux-sensible) and dotbot itself are git +- `lib/roles.sh` resolves roles to an ordered, de-duplicated module list + and refuses a selection that violates a module's `requires`. `install` + runs dotbot once per module (`-d modules/`) with `lib/dotbot-plugins` + loaded; `provision.sh` runs each module's `provision.sh` in role order. + When adding a config file, add it to the module it belongs to; when + adding a tool, add a module (or extend the one whose config assumes it) + and list it in the roles that need it. Order within a role matters only + for provisioning; `cli-tools` goes first because it installs curl. +- Configs are symlinked - except the few files that carry per-machine + values, which are `*.tmpl` rendered by the `render:` directive from + `~/.config/dotfiles/host.env` (`hosts/defaults.env` < + `hosts/.env` < untracked `~/.config/dotfiles/local.env`) and + copied into place. Plain `${NAME}` substitution, error on an unknown + name; keep templates free of literal `$`. +- Provisioning helpers (`lib/provision-lib.sh`): `pkg_ensure` installs only + missing distro packages and refreshes the index once per run on first + need, so a converged machine touches neither apt nor sudo; + `pkg_candidate_version` + `version_ge` let a module take the distro + package when it is new enough and source-build otherwise (polybar, + picom). `require_supported_platform` states the limits up front: Debian + family, x86_64. +- The tmux plugins (tpm, nord-tmux, tmux-sensible) and dotbot are git submodules, all anonymous-HTTPS so this repo clones without credentials. +- CI installs the `server` role from scratch in Ubuntu 22.04 and 24.04 + containers and asserts a second run is a no-op (only `[skip]`/`[note]` + lines in `provision.log`). A module step that prints anything else when + already satisfied breaks that check - use `skip`/`note`, and put a + presence check in front of every install. ## Supply-chain / version-pinning policy -Every tool fetched from an upstream release — nvm, uv, glab, lazygit, -starship, fzf, lsd, kitty, neovim, go, typst, and the Nerd Fonts (JetBrainsMono, -Iosevka, FantasqueSansMono) — names an exact version (no fetch-latest) and -is verified against a recorded sha256 before installing (helpers live in -`provision-lib.sh`). +Every tool fetched from an upstream release - nvm, uv, glab, lazygit, +starship, fzf, lsd, kitty, neovim, go, typst, and the Nerd Fonts +(JetBrainsMono, Iosevka, FantasqueSansMono) - names an exact version (no +fetch-latest) and is verified against a recorded sha256 before installing +(helpers live in `lib/provision-lib.sh`; pins live in the module that +installs the tool, except uv's and the fonts', which several modules share +and so sit in the lib). A pin is a floor, not an equality. It is the version a fresh machine gets and the minimum these configs are known to work with; an installed copy at -or above it is left alone, and one that is newer — updated by hand between -runs — is reported and kept rather than rolled back. Re-provisioning is not +or above it is left alone, and one that is newer - updated by hand between +runs - is reported and kept rather than rolled back. Re-provisioning is not supposed to undo a deliberate update, and that note is the cue to raise the floor once the newer version has proven itself. Older, missing, or -unparseable installs the pin. `FORCE_PINS=1 ./provision-shell.sh` restores +unparseable installs the pin. `FORCE_PINS=1 ./provision.sh` restores exact-pin behaviour for a run, which is how a pin gets walked backwards after a bad release. The trade is that machines no longer converge on identical binaries; the guarantee kept is that nothing is ever fetched @@ -73,24 +92,25 @@ unpinned or unverified. Raising a pin is a deliberate, reviewed change: update the version variable and its sha256 (from the upstream release's published checksums where they exist; kitty, neovim, and typst publish none, so their hashes are computed -from the reviewed download), review the upstream diff, then re-run the relevant -`provision*.sh`. The -monitor-manager's Python dependencies are pinned the same way via the -committed `uv.lock`; all syncs run `--locked`. The rust toolchain is pinned the same way -(verified rustup-init, pinned toolchain version), and the tree-sitter CLI -is built from source at a pinned version with it — upstream prebuilts -target glibc 2.39 and won't run on 22.04; cargo verifies every crate -against the crates.io registry checksums. The desktop half source-builds -two more: polybar, pinned by sha256 over the release tarball upstream -uploads (22.04's 3.5.7 predates the `internal/tray` module the bars use), -and picom, the one artifact pinned by git commit rather than hash (22.04 -packages v9) — upstream uploads nothing, and hashing GitHub's generated tag +from the reviewed download), review the upstream diff, then re-run the +module's `provision.sh`. The monitor-manager's Python dependencies are +pinned the same way via the committed `uv.lock`: `uv sync --locked` in CI, +and the tool install exports the lock as a constraints file. The rust +toolchain is pinned the same way (verified rustup-init, pinned toolchain +version), and the tree-sitter CLI is built from source at a pinned version +with it - upstream prebuilts target glibc 2.39 and won't run on 22.04; +cargo verifies every crate against the crates.io registry checksums. The +x11 module source-builds two more where the distro package is too old: +polybar, pinned by sha256 over the release tarball upstream uploads +(22.04's 3.5.7 predates the `internal/tray` module the bars use), and +picom, the one artifact pinned by git commit rather than hash (22.04 +packages v9) - upstream uploads nothing, and hashing GitHub's generated tag archive pins bytes GitHub can regenerate, so the commit id serves as the -content hash and a re-pointed tag aborts the build. The exceptions are tools -taken from distro apt repos (fd, ripgrep, plus the X11/WM packages) and -node, which tracks the current LTS — these follow whatever the package -source provides. protonvpn-app is deliberately unprovisioned (Proton's own -repo; bspwmrc pgrep-guards it). +content hash and a re-pointed tag aborts the build. The exceptions are +tools taken from distro apt repos (fd, ripgrep, tmux, plus the X11/WM +packages) and node, which tracks the current LTS - these follow whatever +the package source provides. protonvpn-app is deliberately unprovisioned +(Proton's own repo; bspwmrc pgrep-guards it). ## Commits @@ -99,19 +119,23 @@ Use scoped commits (`: `, e.g. `kitty: ...`, `bspwm: ## Machine-local overlay seams -`bash/bashrc`, `bash/bash_aliases`, and `git/gitconfig` each reference an -untracked `~/*.local` sidecar behind an existence guard, so a private overlay -repo can inject machine-specific config without modifying these files. Never -add a `*.local` file to this repo; it stays public and self-contained. The same -split applies to config that isn't a sidecar: private work-machine bspwm -profiles live in the overlay, while intentionally public hardware-specific -profiles may live in -`desktop-environment/bspwm/profiles/`. - -`git/hooks/` guards this repo's own history: `./install` points this clone's -`core.hooksPath` at it (repo-local — no other repo on the machine is -affected), and the hooks then refuse any commit or push whose committer is -not one of the two identities this repo is developed under. The allowlist in -`identity-guard.sh` is deliberately hard-coded and closed; never add a work -or otherwise private address to it — this repo is public, so anything -written there is published. +Each config sources an untracked sidecar **last**, behind an existence +guard, so a private overlay repo can override anything without modifying a +tracked file: `~/.config/dotfiles/env.sh` (every bash shell, before the +interactive gate - the one place for env that non-interactive shells must +see), `~/.bashrc.local`, `~/.bash_aliases.local`, `~/.gitconfig.local` +(included at the bottom of gitconfig, so it can override any key), +`~/.tmux.conf.local`, `~/.config/kitty.local.conf` (outside the symlinked +kitty dir), `~/.config/dotfiles/local.env` (per-machine values), and +`~/.config/bspwm/profiles.d/` (private monitor profiles; a name there +shadows a tracked one). Never add a `*.local` file or an overlay profile to +this repo; it stays public and self-contained. Intentionally public +hardware-specific profiles may live in `modules/x11/bspwm/profiles/`. + +`modules/git/hooks/` guards this repo's own history: `./install` points +this clone's `core.hooksPath` at it (repo-local - no other repo on the +machine is affected), and the hooks then refuse any commit or push whose +committer is not one of the two identities this repo is developed under. +The allowlist in `identity-guard.sh` is deliberately hard-coded and closed; +never add a work or otherwise private address to it - this repo is public, +so anything written there is published. diff --git a/README.md b/README.md index cd7a6fc..3a7dae5 100644 --- a/README.md +++ b/README.md @@ -1,111 +1,135 @@ # dotfiles -Personal dotfiles: a shell/terminal/dev foundation plus a bspwm-based X11 -desktop. Everything installs via [Dotbot](https://github.com/anishathalye/dotbot) -from the repo root; the desktop half is skippable with `--no-desktop`. - -## Layout - -Shell/terminal/dev foundation — these tools don't reference each other, so -each gets its own top-level directory: - -- `bash/` — `bashrc`, `bash_aliases`, `profile`, `inputrc`, - `bazel_completions.bash` -- `git/` — `gitconfig`, `gitmessage`, `git-prompt.sh`, `lazygit.yml`, - `hooks/` (committer-identity guard for this repo, wired up by `./install` - via a repo-local `core.hooksPath`) -- `tmux/` — `tmux.conf`, `settings.conf`, plugins via submodules (tpm, - nord-tmux, tmux-sensible) -- `kitty/` — config, themes, `launch.sh`, `zenmode.py` -- `starship/` — `starship.toml` (prompt theming) -- `agents-config/` — harness-agnostic agent instructions and content - deployed through Claude-compatible paths -- `claude/` — Claude-specific integration, including the status-line script - -Desktop: - -- `desktop-environment/` — `bspwm/`, `sxhkd/`, `polybar/`, `picom.conf`, - `dunstrc`. These stay grouped in one directory because they're developed - and tested together: sxhkd's hotkeys and polybar's toggle scripts - reference bspwm's installed `~/.config/bspwm` paths directly. - `bspwm/` also contains the Python monitor-manager (EDID-based - monitor-profile system) — see `desktop-environment/bspwm/CLAUDE.md`. -- `redshift.conf`, `Xresources`, `systemd/` (user units), `docs/` (PRD + - AirPods-on-Linux notes) — standalone leaf configs with no cross-references - to the bspwm stack or to each other, so they live at the top level. - -Shared: - -- `bin/` — helper scripts installed to `~/.local/bin` (`wacominit`, - `lockscreen`, `clipimg`) plus a placeholder for future shell helpers. -- `dotbot/` — the single, shared Dotbot submodule. -- `install.conf.yaml` / `install-desktop.conf.yaml` — the two Dotbot - configs: the shell/terminal/dev half (always applied) and the - desktop-environment half (skipped by `./install --no-desktop`). -- `install` — the Dotbot wrapper script; runs both configs by default. -- `provision.sh` / `provision-shell.sh` / `desktop-environment/provision.sh` - — see below. `provision-lib.sh` holds the helpers both scripts share - (version pinning, sha256 verification, fail-fast install). -- `.github/workflows/ci.yml` — runs the monitor-manager test suite and - shellcheck over every tracked shell script on each push/PR. - -## Install - -Provisioning assumes Ubuntu 22.04+ on x86_64: package names are apt's, -a PPA is added for git, and the pinned release tarballs are the -`x86_64`/`amd64` Linux builds. +Personal dotfiles for four kinds of machine - a headless server, a +workstation, and a workstation running a bspwm-based X11 desktop - installed +via [Dotbot](https://github.com/anishathalye/dotbot) one module at a time. +A machine picks a **role**; a role is a list of **modules**; a module is +one tool's config plus the provisioning that makes it work. ```sh -git clone --recurse-submodules https://github.com/krishna-bala/dotfiles -cd dotfiles -./provision.sh # installs pinned CLI tooling + X11/WM packages -./install # symlinks everything into place via dotbot +git clone --recurse-submodules https://github.com/krishna-bala/dotfiles ~/.dotfiles +cd ~/.dotfiles +./provision.sh --roles server # installs pinned tooling for that role +./install --roles server # links that role's configs into $HOME ``` -`./install` is safe to re-run; it relinks everything via dotbot. Any -arguments other than `--no-desktop` are forwarded to dotbot itself. +The selection is saved to `~/.config/dotfiles/roles`, so afterwards a bare +`./provision.sh` / `./install` re-applies it. Both are safe to re-run. -### Shell-only machines (no desktop) +## Roles -On machines that shouldn't get the X11/bspwm customization — remote boxes, -servers, containers — pass `--no-desktop` to both scripts: +| Role | What it is | Modules | +|---|---|---| +| `server` | a headless box reached over ssh | cli-tools bash git tmux starship nvim agents | +| `workstation` | a machine someone sits at, any desktop | server + kitty bazel node go typst | +| `desktop` | a workstation running this repo's bspwm/X11 stack | workstation + x11 | -```sh -./provision.sh --no-desktop # shell/terminal/dev tooling only -./install --no-desktop # links only the shell half's configs -``` +Roles nest (`roles/workstation` starts with `@server`). Modules outside any +role are picked per host: `./install --roles desktop,wacom`. A module can +declare what it needs (`modules/x11/requires` names `kitty` and +`cli-tools`), and a selection that leaves a requirement out is refused up +front instead of failing at a hotkey. -`--no-desktop` skips `desktop-environment/provision.sh` and the -`install-desktop.conf.yaml` dotbot pass, so no X11 packages are installed -and none of the desktop configs (bspwm/sxhkd/polybar/picom/dunst, -`Xresources`, `swapescape.service`, `wacominit`/`lockscreen`/`clipimg`) are -linked. The two provisioning halves also remain independently runnable: +## Layout -```sh -./provision-shell.sh # shell/terminal/dev tooling only -./desktop-environment/provision.sh # X11/WM packages + bspwm venv only +``` +install links the selected modules' configs (dotbot, one pass per module) +provision.sh runs the selected modules' provision.sh in role order +roles/ server, workstation, desktop - one module name per line +modules// install.conf.yaml (dotbot links, sources relative to the module) + provision.sh (pinned + sha256-verified tooling; standalone-runnable) + requires (optional: modules this one cannot work without) +hosts/ per-machine values (see below) +apps/ applications that live here but are not dotfiles +lib/ provision-lib.sh (pins, sha256, apt), roles.sh, common.sh, + dotbot-plugins/render.py +docs/ the desktop PRD and AirPods-on-Linux notes +dotbot/ the Dotbot submodule ``` -## Machine-local overlay - -A few files source an untracked, machine-local sidecar if present, so a -private overlay (e.g. a company meta-repo) can layer config on top without -forking this repo: - -- `bash/bashrc` sources `~/.bashrc.local` if it exists (e.g. work-specific - env vars) -- `bash/bash_aliases` sources `~/.bash_aliases.local` if it exists (e.g. - work-specific shell functions) -- `git/gitconfig` includes `~/.gitconfig.local` if it exists (identity, - credential helpers) - -None of these sidecars are shipped here; on a personal machine they're -simply absent and every reference is a no-op. +Modules: + +- `bash`, `git`, `tmux`, `starship` - the shell. `tmux/config/` is the + whole `~/.tmux` (plugins are submodules under it). +- `cli-tools` - fzf, ripgrep, fd, lsd, lazygit, jq, uv, glab; and + `bin/clipimg`, the remote-side half of kitty's clipboard kitten. +- `nvim` - neovim plus the rust toolchain and tree-sitter CLI its config + (a separate repo) builds with. +- `node`, `go`, `typst`, `bazel` - language toolchains and completions. +- `kitty` - the terminal emulator and its font. Client-side: not on servers. +- `agents` - `AGENTS.md`, linked to `~/.claude/CLAUDE.md` and + `~/.codex/AGENTS.md`, plus the Claude Code status-line script. +- `x11` - bspwm, sxhkd, polybar (with rofi themes), picom, dunst, + redshift, `Xresources`, the `swapescape` user unit, `lockscreen`, + `desktop-session-log`. These stay together because sxhkd's hotkeys and + polybar's toggle scripts reference bspwm's installed paths directly. +- `wacom` - one tablet's `xsetwacom` mapping; per-host. + +Applications: + +- `apps/monitor-manager/` - the EDID-based monitor-profile system bspwmrc + runs at login (Python, with its own tests, lockfile, and `CLAUDE.md`). + `modules/x11/provision.sh` installs it as a `uv tool` so `monitor-manager` + is on `PATH`; the profiles it applies stay config, in + `modules/x11/bspwm/profiles/`. + +## Per-machine values + +Everything is a symlink except a handful of values that genuinely differ +per machine - a panel's DPI, redshift's location, polybar's backlight and +battery names, the wifi interface to fall back to. Those are merged by +`./install` from three layers into `~/.config/dotfiles/host.env`, later +layers winning: + +1. `hosts/defaults.env` - tracked, complete +2. `hosts/.env` - tracked, for machines it's fine to describe here +3. `~/.config/dotfiles/local.env` - untracked; the private overlay's seam + +Templates (`*.tmpl`, applied with the `render:` directive in a module's +`install.conf.yaml`) substitute `${NAME}` from that file and are copied, +not linked; `bspwmrc` exports it into the desktop session for polybar's +`${env:NAME:fallback}` lookups. -## Used as a submodule +## Machine-local overlay -A private meta-repo can pull this repo in as a submodule, run this repo's -`install` (or its own dotbot pass over this repo's files), and then apply -its overlay last. This repo intentionally does not run `clean: ["~"]` in -its dotbot configs, so a parent meta-repo can own that single pass -without conflicts. +A private overlay (a company meta-repo, say) layers on top of this repo +without forking it through untracked files each config sources last, so the +overlay wins: + +| Seam | Read by | For | +|---|---|---| +| `~/.config/dotfiles/env.sh` | every bash shell, before the interactive gate | PATH, proxies, `EDITOR` - things `ssh host cmd` and cron must also see | +| `~/.bashrc.local` | interactive bash, last | prompt and shell tweaks | +| `~/.bash_aliases.local` | interactive bash, after all tool blocks | aliases and functions | +| `~/.gitconfig.local` | git, included last | identity, credential helpers, any override | +| `~/.tmux.conf.local` | tmux, before tpm loads | bindings, extra `@plugin`s | +| `~/.config/kitty.local.conf` | kitty, last | font size, theme | +| `~/.config/dotfiles/local.env` | `./install` | per-machine values (above) | +| `~/.config/bspwm/profiles.d/*.yaml` | monitor-manager | private monitor profiles, shadowing tracked ones by name | + +None of these are shipped here; where absent, every reference is a no-op. +`./install` runs no `clean:` pass, so a parent meta-repo that consumes this +repo as a submodule can own that single pass without conflicts. + +## Provisioning + +Assumes a Debian-family distro on x86_64 (tested on Ubuntu 22.04 and +24.04): package names are apt's, and the pinned release archives are +`x86_64`/`amd64` Linux builds. Every upstream download names an exact +version and is verified against a recorded sha256; the pin is a floor, so a +newer copy installed by hand is kept and reported rather than rolled back +(`FORCE_PINS=1` restores exact pins for a run). See `CLAUDE.md` for the +policy in full. + +Where a distro package is new enough it is used instead of a source build: +polybar needs >= 3.7.0 (`internal/tray`) and picom >= 13, which Ubuntu +22.04 lacks and later releases may have. + +## CI + +`.github/workflows/ci.yml` installs the `server` role from scratch in +Ubuntu 22.04 and 24.04 containers (non-root user, `./provision.sh` + +`./install`), checks the shell and tools work, and re-runs both to prove a +converged machine is a no-op; tests `provision-lib.sh`'s sha256 refusal +and role resolution; runs the monitor-manager suite; and shellchecks every +tracked script. diff --git a/Xresources b/Xresources deleted file mode 100644 index 5d5155b..0000000 --- a/Xresources +++ /dev/null @@ -1,2 +0,0 @@ -Xcursor.size: 24 -Xft.dpi: 124 diff --git a/apps/monitor-manager/CLAUDE.md b/apps/monitor-manager/CLAUDE.md new file mode 100644 index 0000000..33304fd --- /dev/null +++ b/apps/monitor-manager/CLAUDE.md @@ -0,0 +1,195 @@ +# CLAUDE.md - monitor-manager + +EDID-based monitor-profile management for bspwm. An application that lives +in the dotfiles repo (`apps/monitor-manager`) but is not a dotfile: it has +its own package, tests, and lockfile, and is installed onto `PATH` as +`monitor-manager` by `modules/x11/provision.sh` (`uv tool install +--editable`, constrained to `uv.lock`). The config it applies - the profile +YAMLs, `bspwmrc`, the sxhkd bindings, polybar - stays in `modules/x11/`. + +## Commands + +```bash +uv run pytest # all tests +uv run pytest tests/test_display.py -v # specific test +uv run monitor-manager # run the CLI from the checkout +uv lock # after changing dependencies +``` + +On an installed machine `monitor-manager` is on `PATH` and imports this +checkout (editable), so code changes take effect without re-provisioning; +dependency changes need `./provision.sh --modules x11` (or the uv tool +install line from it) to rebuild the tool venv. + +## Architecture + +### Boot sequence (modules/x11/bspwm/bspwmrc) + +Ordered startup - each step depends on the previous: + +1. **Wait for X** - polls `xrandr --query` for connected monitors (max 6s) +2. **Apply monitor profile** - `monitor-manager match --best` (stderr -> + log, exit code checked), then `apply-all --force`. Fallback: + `xrandr --auto` + notify-send +3. **Configure bspwm** - borders, gaps, window rules (defaults; profiles override these) +4. **Launch background apps** - picom, nitrogen, redshift, nm-applet, blueman, protonvpn (all pgrep-guarded) +5. **Start sxhkd LAST** - `pkill` + 200ms wait + launch. Must be last so X key grabs succeed after monitor setup + +bspwmrc first exports `~/.config/dotfiles/host.env` (per-machine values +merged by `./install`), which is where polybar's `${env:BACKLIGHT_CARD}` +and friends and `network-env.sh`'s fallback interface come from. + +Logs: `$XDG_STATE_HOME/bspwm/bspwm.log`, `$XDG_STATE_HOME/sxhkd/sxhkd.log`, +and `$XDG_STATE_HOME/desktop-session/boot-.log`. The latter captures +filtered GNOME/Mutter, bspwm, Xorg, GDM, NVIDIA, monitor, and failed-unit +events for both GNOME autostart and bspwm startup. + +### Package layout (monitor_manager/) + +Service-oriented architecture with Protocol-based dependency injection for testability. + +**Services:** +- `DisplayService` (display.py) - xrandr parsing and EDID extraction (read-only; application happens in the executor) +- `ProfileService` (profile.py) - YAML loading with defaults.yaml merge, validation, EDID-based matching (+100/monitor match, -10/extra monitor). Reads a **search path**: `~/.config/bspwm/profiles` (the tracked profiles, linked by `./install`) then `~/.config/bspwm/profiles.d` (a real directory for an overlay's private profiles; a name there shadows a tracked one; `defaults.yaml` comes from the first directory that has one) +- `MonitorManagerCoordinator` (coordinator.py) - resolves profile aliases to actual hardware outputs via EDID->output mapping +- `SafetyService` (safety.py) - pre-apply state snapshots (per-monitor desktop lists) to `$XDG_STATE_HOME/bspwm-monitor-manager/snapshots/`, pruned to the newest 20 per kind +- Default-profile preference (preferences.py) - `$XDG_STATE_HOME/bspwm-monitor-manager/state.json` +- `InteractiveMenu` (interactive.py) - simple-term-menu TUI for profile selection + +**Reconciliation pipeline** (the current apply path, used by `plan` and `apply-all`): +- `probe/` - read-only probes (xrandr, bspc, polybar) assembling a `HardwareState` +- `state/` - frozen dataclasses for hardware state, plus `compile_desired(profile, alias_to_output)` -> `DesiredState` (pure) +- `reconciler.py` - diffs current vs desired into a typed `Plan` of ops, simulating each op against shadow state. `bar_env()` / `POLYBAR_ENV_VARS` are the one place the polybar env contract is written down +- `executor.py` - runs the Plan, minting symbolic refs ($M_n) into real ids at runtime +- `renderer.py` - renders the same Plan for preview and execution +- `simulate.py`, `ops.py`, `plan.py` - pure transition function, op types, merge policies + +**CLI entry point:** `cli.py` (`monitor-manager` console script, ~600 lines). Commands: +- Read-only: `detect`, `validate`, `list`, `match` (with `--best` for scripting) +- State: `set-default`, `clear-default` +- Planning: `plan` (dry-run; renders the same Plan apply-all would run) +- Apply: `apply-all` - the single apply path, via the reconciliation pipeline +- Interactive: `interactive` (TUI; plan preview and apply both use the reconciliation pipeline via `apply_profile()`) + +**apply-all sequencing:** After xrandr ops, `WaitForBspwmMonitor` polls for up to 7.5s for expected outputs to appear in bspwm before bspc desktop ops run. + +### Profile system + +YAML files in `modules/x11/bspwm/profiles/` (tracked) and +`~/.config/bspwm/profiles.d/` (overlay). Four sections each: + +```yaml +detection: # EDID fingerprints for hardware matching +display: # xrandr config (resolution, position, rotation, scale, primary) +window_manager: # workspace distribution per monitor + bspwm settings +ui: # polybar bars per monitor (orientation, font_size, modules) +``` + +Profiles use logical aliases (laptop, main, vertical) resolved to actual outputs at runtime. + +Profile matching uses the ACPI lid state when available and skips an +enabled-laptop profile while the lid is closed. It also falls back to +requiring an active Xrandr mode when the lid state is unavailable. That +check is the only thing separating a docked profile from its clamshell +twin, and a work-laptop profile from a personal one that shares the same +external monitors. + +### Shell scripts (modules/x11/bspwm/scripts/) + +- `apply-auto.sh` - re-detect topology, apply best profile via reconciliation (super+alt+r, super+alt+shift+p) +- `smart_focus.sh` - focus node in direction, fall through to monitor at edge +- `smart_send.sh` - swap with neighbor node, or move to adjacent monitor +- `smart_resize.sh` - expand toward direction if neighbor exists, else contract +- `toggle_polybar.sh` - show/hide polybar; saves/restores per-monitor top_padding in `$XDG_STATE_HOME/bspwm/` +- `monocle-border.sh` - event-driven border width (thick in monocle, thin otherwise) +- `monitor-switch.sh` - launches the interactive TUI in a kitty window +- `network-env.sh` - sourced by bspwmrc and apply-auto.sh; exports `NETWORK_INTERFACE` / `NETWORK_LABEL` + +Developer tooling for this app's fixtures lives with the app, in +`apps/monitor-manager/scripts/`: `capture-fixture.sh` captures xrandr/bspc +state as test fixtures and `redact-edid.py` strips EDID bytes before they +touch disk. + +### Polybar + +adi1090x "shades" theme. Single `[bar/main]` definition, customized per-monitor via env vars from the reconciler. `pin-workspaces = true` - each bar shows only its monitor's desktops. + +The env contract: the reconciler launches each bar with `MONITOR`, +`FONT_0`/`FONT_1`, and `MODULES_LEFT/CENTER/RIGHT`; the polybar config +reads them with `${env:NAME:fallback}`. `tests/test_polybar_contract.py` +holds both sides to that list, so renaming one side fails in CI. + +The tray is the `internal/tray` module, which needs polybar >= 3.7.0 - +22.04 packages 3.5.7, where it silently does nothing, so +`modules/x11/provision.sh` builds a pinned 3.7.2 from source there (and +takes the distro package where it is new enough). Profiles do not name +`tray` in their modules: two bars listing it race for tray clients, so +`_reconcile_polybar` appends it to the right block of the bar on the +`primary` output and strips it from every other bar. A profile with no +`primary: true` display therefore gets no tray at all. The tray is destroyed +and rebuilt on every polybar restart, and blueman-applet only registers its +icon at startup, which is why apply-auto.sh restarts it afterwards (nm-applet +re-registers on its own). + +`network-env.sh` exports `NETWORK_INTERFACE` (whichever interface holds the +default route, else `NETWORK_FALLBACK_IFACE` from host.env), and on a +wired link `NETWORK_LABEL` (its NetworkManager connection name). Both are +read by the network module's `polybar/shades/scripts/network-label.sh`, +a custom/script module rather than polybar's `internal/network`. Wi-Fi leaves +`NETWORK_LABEL` unset: the script resolves the live SSID and signal strength +itself, so roaming needs no bar restart. + +### sxhkd (modules/x11/sxhkd/sxhkdrc) + +Key bindings reference `~/.config/bspwm/scripts/` (symlinked by Dotbot). Notable: +- `super+shift+x` - interactive monitor manager +- `super+alt+shift+p` - re-apply current profile (restarts polybar) via apply-auto.sh +- `super+alt+p` - toggle polybar visibility via toggle_polybar.sh +- `super+shift+F1` - emergency laptop display recovery (enables any connected eDP-* output) + +## Testing + +~210 tests across 23 files. Unit tests mock hardware via Protocol +implementations (MockXrandrExecutor, MockBspcExecutor, MockPolybarExecutor). +Integration tests use xrandr fixture files in `tests/fixtures/xrandr/`; one +safety-snapshot test shells out to real `xrandr` and skips without a +`DISPLAY` (CI runs it under `xvfb-run`). + +Tests never read the tracked profiles (production config with real-hardware +EDID pins); they use the synthetic profiles in `tests/fixtures/profiles/` +instead. The one exception is `test_tracked_profiles.py`, which validates +that the real profiles in `modules/x11/bspwm/profiles/` parse, merge with +defaults.yaml, pass validation, and pin only hashed EDIDs. The fixture +profiles' laptop edid must stay equal to the hash of the synthetic EDID in +`tests/fixtures/xrandr/personal-solo-props.txt`; test_coordinator asserts this. + +```bash +uv run pytest # all +uv run pytest tests/test_profile.py -v # specific +uv run pytest --cov=monitor_manager --cov-report=term-missing # coverage +``` + +## Known issues + +- Rollback files are still text instructions; a serialised inverse Plan + (true plan-based rollback) remains future work +- `cli.py` is exercised only indirectly (test_cli.py mirrors its logic + rather than calling it), and the lid-state branch of profile matching has + no unit test +- `rich` is a declared dependency nothing imports + +## xrandr constraints (learned the hard way, 2026-06-11) + +- All xrandr changes go out as ONE invocation (`XrandrApplyLayout` op) with + an explicit `--fb`. Sequential per-output calls wedge the modesetting + driver: once any CRTC has a scale transform active, every subsequent + screen resize fails with RRSetScreenSize BadMatch - even a bare + `xrandr --fb`. Recovery from that state requires resetting the scaled + output to `--scale 1x1` first. +- The probe reads xrandr's summary line, which reports post-transform + geometry and no scale; the reconciler projects desired (mode, scale, + rotation) into that space (`_effective_mode`) before diffing, otherwise + scaled outputs re-modeset on every apply. +- bspwm refuses to remove a monitor's last desktop, but `bspc monitor -r` + takes the monitor down together with one remaining empty desktop, so the + cleanup phase leaves one behind for it. diff --git a/desktop-environment/bspwm/lib/__init__.py b/apps/monitor-manager/monitor_manager/__init__.py similarity index 100% rename from desktop-environment/bspwm/lib/__init__.py rename to apps/monitor-manager/monitor_manager/__init__.py diff --git a/desktop-environment/bspwm/monitor-manager.py b/apps/monitor-manager/monitor_manager/cli.py similarity index 96% rename from desktop-environment/bspwm/monitor-manager.py rename to apps/monitor-manager/monitor_manager/cli.py index 3cd60e7..770811e 100755 --- a/desktop-environment/bspwm/monitor-manager.py +++ b/apps/monitor-manager/monitor_manager/cli.py @@ -5,25 +5,22 @@ import sys from pathlib import Path -# Add lib directory to Python path -sys.path.insert(0, str(Path(__file__).parent)) - -from lib.coordinator import MonitorManagerCoordinator -from lib.display import DisplayService -from lib.exceptions import ( +from monitor_manager.coordinator import MonitorManagerCoordinator +from monitor_manager.display import DisplayService +from monitor_manager.exceptions import ( HardwareDetectionError, ProfileNotFoundError, ProfileValidationError, ) -from lib.executor import Executor -from lib.interactive import run_interactive_menu -from lib.preferences import StateService -from lib.probe.composite import CompositeStateProbe -from lib.profile import ProfileService -from lib.reconciler import Reconciler -from lib.renderer import Renderer -from lib.safety import SafetyService -from lib.state.desired import compile_desired +from monitor_manager.executor import Executor +from monitor_manager.interactive import run_interactive_menu +from monitor_manager.preferences import StateService +from monitor_manager.probe.composite import CompositeStateProbe +from monitor_manager.profile import ProfileService +from monitor_manager.reconciler import Reconciler +from monitor_manager.renderer import Renderer +from monitor_manager.safety import SafetyService +from monitor_manager.state.desired import compile_desired def get_coordinator() -> MonitorManagerCoordinator: diff --git a/desktop-environment/bspwm/lib/coordinator.py b/apps/monitor-manager/monitor_manager/coordinator.py similarity index 100% rename from desktop-environment/bspwm/lib/coordinator.py rename to apps/monitor-manager/monitor_manager/coordinator.py diff --git a/desktop-environment/bspwm/lib/display.py b/apps/monitor-manager/monitor_manager/display.py similarity index 100% rename from desktop-environment/bspwm/lib/display.py rename to apps/monitor-manager/monitor_manager/display.py diff --git a/desktop-environment/bspwm/lib/edid.py b/apps/monitor-manager/monitor_manager/edid.py similarity index 100% rename from desktop-environment/bspwm/lib/edid.py rename to apps/monitor-manager/monitor_manager/edid.py diff --git a/desktop-environment/bspwm/lib/exceptions.py b/apps/monitor-manager/monitor_manager/exceptions.py similarity index 100% rename from desktop-environment/bspwm/lib/exceptions.py rename to apps/monitor-manager/monitor_manager/exceptions.py diff --git a/desktop-environment/bspwm/lib/executor.py b/apps/monitor-manager/monitor_manager/executor.py similarity index 100% rename from desktop-environment/bspwm/lib/executor.py rename to apps/monitor-manager/monitor_manager/executor.py diff --git a/desktop-environment/bspwm/lib/interactive.py b/apps/monitor-manager/monitor_manager/interactive.py similarity index 100% rename from desktop-environment/bspwm/lib/interactive.py rename to apps/monitor-manager/monitor_manager/interactive.py diff --git a/desktop-environment/bspwm/lib/ops.py b/apps/monitor-manager/monitor_manager/ops.py similarity index 100% rename from desktop-environment/bspwm/lib/ops.py rename to apps/monitor-manager/monitor_manager/ops.py diff --git a/desktop-environment/bspwm/lib/plan.py b/apps/monitor-manager/monitor_manager/plan.py similarity index 100% rename from desktop-environment/bspwm/lib/plan.py rename to apps/monitor-manager/monitor_manager/plan.py diff --git a/desktop-environment/bspwm/lib/preferences.py b/apps/monitor-manager/monitor_manager/preferences.py similarity index 100% rename from desktop-environment/bspwm/lib/preferences.py rename to apps/monitor-manager/monitor_manager/preferences.py diff --git a/desktop-environment/bspwm/lib/probe/__init__.py b/apps/monitor-manager/monitor_manager/probe/__init__.py similarity index 100% rename from desktop-environment/bspwm/lib/probe/__init__.py rename to apps/monitor-manager/monitor_manager/probe/__init__.py diff --git a/desktop-environment/bspwm/lib/probe/bspwm.py b/apps/monitor-manager/monitor_manager/probe/bspwm.py similarity index 100% rename from desktop-environment/bspwm/lib/probe/bspwm.py rename to apps/monitor-manager/monitor_manager/probe/bspwm.py diff --git a/desktop-environment/bspwm/lib/probe/composite.py b/apps/monitor-manager/monitor_manager/probe/composite.py similarity index 100% rename from desktop-environment/bspwm/lib/probe/composite.py rename to apps/monitor-manager/monitor_manager/probe/composite.py diff --git a/desktop-environment/bspwm/lib/probe/polybar.py b/apps/monitor-manager/monitor_manager/probe/polybar.py similarity index 100% rename from desktop-environment/bspwm/lib/probe/polybar.py rename to apps/monitor-manager/monitor_manager/probe/polybar.py diff --git a/desktop-environment/bspwm/lib/probe/xrandr.py b/apps/monitor-manager/monitor_manager/probe/xrandr.py similarity index 100% rename from desktop-environment/bspwm/lib/probe/xrandr.py rename to apps/monitor-manager/monitor_manager/probe/xrandr.py diff --git a/desktop-environment/bspwm/lib/profile.py b/apps/monitor-manager/monitor_manager/profile.py similarity index 86% rename from desktop-environment/bspwm/lib/profile.py rename to apps/monitor-manager/monitor_manager/profile.py index cf2cf8b..82999bb 100644 --- a/desktop-environment/bspwm/lib/profile.py +++ b/apps/monitor-manager/monitor_manager/profile.py @@ -1,10 +1,11 @@ """Profile management - loading, validation, and matching.""" import logging +import os import yaml from dataclasses import dataclass, field from pathlib import Path -from typing import Callable, List, Dict, Optional, Tuple, Any +from typing import Callable, List, Dict, Optional, Sequence, Tuple, Any, Union from .exceptions import ProfileNotFoundError, ProfileValidationError @@ -106,27 +107,43 @@ def _deep_merge(base: dict, override: dict) -> dict: class ProfileService: """Service for profile loading, validation, and matching.""" - # Derived from this file's location (lib/profile.py -> ../profiles) so it - # tracks the repo wherever it lives, rather than a hard-coded ~/.dotfiles path. - DEFAULT_PROFILES_DIR = Path(__file__).resolve().parent.parent / "profiles" - # Shared defaults merged under every profile at load time; profiles # only specify what differs. Not a profile itself. DEFAULTS_FILE = "defaults.yaml" + @staticmethod + def default_profiles_dirs() -> List[Path]: + """Where profiles live when no directory is given. + + The dotfiles install links the tracked profiles to + ~/.config/bspwm/profiles; ~/.config/bspwm/profiles.d is a real + directory a private overlay can drop machine-specific profiles into + without touching the public repo. A later directory wins on a name + clash, and defaults.yaml is read from the first that has one. + """ + config_home = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") + base = config_home / "bspwm" + return [base / "profiles", base / "profiles.d"] + def __init__( self, - profiles_dir: Optional[Path] = None, + profiles_dir: Optional[Union[Path, Sequence[Path]]] = None, lid_state_reader: Optional[Callable[[], Optional[bool]]] = None, ): """Initialize profile service. Args: - profiles_dir: Directory containing YAML profiles (default: the repo's bspwm/profiles) + profiles_dir: Directory (or directories, later winning) containing + YAML profiles. Default: default_profiles_dirs(). lid_state_reader: Optional callback returning True when the laptop lid is closed, False when open, or None when unavailable. """ - self.profiles_dir = Path(profiles_dir or self.DEFAULT_PROFILES_DIR) + if profiles_dir is None: + self.profiles_dirs = self.default_profiles_dirs() + elif isinstance(profiles_dir, (str, os.PathLike)): + self.profiles_dirs = [Path(profiles_dir)] + else: + self.profiles_dirs = [Path(d) for d in profiles_dir] self._defaults: Optional[dict] = None self._lid_state_reader = lid_state_reader or ( self._read_lid_state if profiles_dir is None else lambda: None @@ -152,15 +169,31 @@ def _read_lid_state() -> Optional[bool]: return False if saw_state else None + @property + def profiles_dir(self) -> Path: + """The primary profiles directory (first in the search path).""" + return self.profiles_dirs[0] + + def _find_profile_path(self, profile_name: str) -> Optional[Path]: + """The path a profile name resolves to: the last directory in the + search path that has it, so an overlay profile shadows a tracked one.""" + for directory in reversed(self.profiles_dirs): + candidate = directory / f"{profile_name}.yaml" + if candidate.exists(): + return candidate + return None + def _load_defaults(self) -> dict: - """Load profiles/defaults.yaml once; missing file means no defaults.""" + """Load defaults.yaml once, from the first directory in the search + path that has one; none means no defaults.""" if self._defaults is None: - defaults_path = self.profiles_dir / self.DEFAULTS_FILE - if defaults_path.exists(): - with open(defaults_path) as f: - self._defaults = yaml.safe_load(f) or {} - else: - self._defaults = {} + self._defaults = {} + for directory in self.profiles_dirs: + defaults_path = directory / self.DEFAULTS_FILE + if defaults_path.exists(): + with open(defaults_path) as f: + self._defaults = yaml.safe_load(f) or {} + break return self._defaults def load_profile(self, profile_name: str) -> Profile: @@ -176,10 +209,10 @@ def load_profile(self, profile_name: str) -> Profile: ProfileNotFoundError: If profile doesn't exist ProfileValidationError: If YAML is invalid or missing required fields """ - profile_path = self.profiles_dir / f"{profile_name}.yaml" + profile_path = self._find_profile_path(profile_name) - if not profile_path.exists(): - raise ProfileNotFoundError(profile_name, profile_path) + if profile_path is None: + raise ProfileNotFoundError(profile_name, self.profiles_dir / f"{profile_name}.yaml") try: with open(profile_path) as f: @@ -375,12 +408,11 @@ def list_profiles(self) -> List[str]: Returns: List of profile names (without .yaml extension) """ - if not self.profiles_dir.exists(): - return [] - - return sorted( - p.stem for p in self.profiles_dir.glob("*.yaml") if p.stem != "defaults" - ) + names = set() + for directory in self.profiles_dirs: + if directory.exists(): + names.update(p.stem for p in directory.glob("*.yaml") if p.stem != "defaults") + return sorted(names) def match_profiles(self, detected_monitors: List) -> List[Tuple[str, float, Profile]]: """Match detected monitors to available profiles with scoring. diff --git a/desktop-environment/bspwm/lib/reconciler.py b/apps/monitor-manager/monitor_manager/reconciler.py similarity index 93% rename from desktop-environment/bspwm/lib/reconciler.py rename to apps/monitor-manager/monitor_manager/reconciler.py index 5a8592f..9cfbae8 100644 --- a/desktop-environment/bspwm/lib/reconciler.py +++ b/apps/monitor-manager/monitor_manager/reconciler.py @@ -40,7 +40,7 @@ ) from .plan import MergePolicy, PreserveByName from .simulate import simulate -from .state.desired import DesiredState +from .state.desired import DesiredBar, DesiredState from .state.hardware import BspwmMonitor, HardwareState @@ -343,28 +343,40 @@ def _reconcile_polybar(self, b: _PlanBuilder, desired: DesiredState) -> None: b.emit(PolybarKillAll()) primary_outputs = {o.name for o in desired.outputs if o.primary} for bar in desired.bars: - env: Tuple[Tuple[str, str], ...] = ( - ("MONITOR", bar.output), - ( - "FONT_0", - f"JetBrainsMono Nerd Font:pixelsize={bar.font_size};3", - ), - ( - "FONT_1", - f"JetBrainsMono Nerd Font:pixelsize={bar.font_size + 2};3", - ), - ("MODULES_LEFT", bar.modules_left), - ("MODULES_CENTER", bar.modules_center), - ( - "MODULES_RIGHT", - _place_tray(bar.modules_right, bar.output in primary_outputs), - ), - ) b.emit( - PolybarLaunch(output=bar.output, bar_definition="main", env=env) + PolybarLaunch( + output=bar.output, + bar_definition="main", + env=bar_env(bar, owns_tray=bar.output in primary_outputs), + ) ) +# The variables the tracked polybar config (modules/x11/polybar/shades) reads +# with ${env:NAME:fallback}. tests/test_polybar_contract.py holds the two +# sides to this list. +POLYBAR_ENV_VARS = ( + "MONITOR", + "FONT_0", + "FONT_1", + "MODULES_LEFT", + "MODULES_CENTER", + "MODULES_RIGHT", +) + + +def bar_env(bar: DesiredBar, owns_tray: bool) -> Tuple[Tuple[str, str], ...]: + """The environment one polybar instance is launched with.""" + return ( + ("MONITOR", bar.output), + ("FONT_0", f"JetBrainsMono Nerd Font:pixelsize={bar.font_size};3"), + ("FONT_1", f"JetBrainsMono Nerd Font:pixelsize={bar.font_size + 2};3"), + ("MODULES_LEFT", bar.modules_left), + ("MODULES_CENTER", bar.modules_center), + ("MODULES_RIGHT", _place_tray(bar.modules_right, owns_tray)), + ) + + def _place_tray(modules_right: str, owns_tray: bool) -> str: """Append `tray` to the right block, or drop it if this bar isn't the owner. A profile that still names `tray` explicitly gets it normalised to diff --git a/desktop-environment/bspwm/lib/renderer.py b/apps/monitor-manager/monitor_manager/renderer.py similarity index 100% rename from desktop-environment/bspwm/lib/renderer.py rename to apps/monitor-manager/monitor_manager/renderer.py diff --git a/desktop-environment/bspwm/lib/safety.py b/apps/monitor-manager/monitor_manager/safety.py similarity index 100% rename from desktop-environment/bspwm/lib/safety.py rename to apps/monitor-manager/monitor_manager/safety.py diff --git a/desktop-environment/bspwm/lib/simulate.py b/apps/monitor-manager/monitor_manager/simulate.py similarity index 100% rename from desktop-environment/bspwm/lib/simulate.py rename to apps/monitor-manager/monitor_manager/simulate.py diff --git a/desktop-environment/bspwm/lib/state/__init__.py b/apps/monitor-manager/monitor_manager/state/__init__.py similarity index 100% rename from desktop-environment/bspwm/lib/state/__init__.py rename to apps/monitor-manager/monitor_manager/state/__init__.py diff --git a/desktop-environment/bspwm/lib/state/desired.py b/apps/monitor-manager/monitor_manager/state/desired.py similarity index 100% rename from desktop-environment/bspwm/lib/state/desired.py rename to apps/monitor-manager/monitor_manager/state/desired.py diff --git a/desktop-environment/bspwm/lib/state/hardware.py b/apps/monitor-manager/monitor_manager/state/hardware.py similarity index 100% rename from desktop-environment/bspwm/lib/state/hardware.py rename to apps/monitor-manager/monitor_manager/state/hardware.py diff --git a/desktop-environment/bspwm/pyproject.toml b/apps/monitor-manager/pyproject.toml similarity index 80% rename from desktop-environment/bspwm/pyproject.toml rename to apps/monitor-manager/pyproject.toml index 78cf54b..cdeaeec 100644 --- a/desktop-environment/bspwm/pyproject.toml +++ b/apps/monitor-manager/pyproject.toml @@ -1,7 +1,7 @@ [project] -name = "bspwm-monitor-manager" +name = "monitor-manager" version = "0.1.0" -description = "EDID-based monitor management for BSPWM" +description = "EDID-based monitor management for bspwm" requires-python = ">=3.10" dependencies = [ "pyyaml>=6.0", @@ -12,12 +12,15 @@ dependencies = [ [project.optional-dependencies] dev = ["pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.1.0"] +[project.scripts] +monitor-manager = "monitor_manager.cli:main" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["lib"] +packages = ["monitor_manager"] [tool.ruff] line-length = 100 diff --git a/desktop-environment/bspwm/scripts/capture-fixture.sh b/apps/monitor-manager/scripts/capture-fixture.sh similarity index 96% rename from desktop-environment/bspwm/scripts/capture-fixture.sh rename to apps/monitor-manager/scripts/capture-fixture.sh index c357875..fe6d4ca 100755 --- a/desktop-environment/bspwm/scripts/capture-fixture.sh +++ b/apps/monitor-manager/scripts/capture-fixture.sh @@ -14,8 +14,8 @@ fi NAME=$1 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -BSPWM_DIR="$(dirname "$SCRIPT_DIR")" -FIXTURES_DIR="$BSPWM_DIR/tests/fixtures" +APP_DIR="$(dirname "$SCRIPT_DIR")" +FIXTURES_DIR="$APP_DIR/tests/fixtures" echo "Capturing fixture: $NAME" echo "======================================" diff --git a/desktop-environment/bspwm/scripts/redact-edid.py b/apps/monitor-manager/scripts/redact-edid.py similarity index 100% rename from desktop-environment/bspwm/scripts/redact-edid.py rename to apps/monitor-manager/scripts/redact-edid.py diff --git a/desktop-environment/bspwm/tests/fixtures/bspc/personal-solo-desktops.txt b/apps/monitor-manager/tests/fixtures/bspc/personal-solo-desktops.txt similarity index 100% rename from desktop-environment/bspwm/tests/fixtures/bspc/personal-solo-desktops.txt rename to apps/monitor-manager/tests/fixtures/bspc/personal-solo-desktops.txt diff --git a/desktop-environment/bspwm/tests/fixtures/bspc/personal-solo-dump.json b/apps/monitor-manager/tests/fixtures/bspc/personal-solo-dump.json similarity index 100% rename from desktop-environment/bspwm/tests/fixtures/bspc/personal-solo-dump.json rename to apps/monitor-manager/tests/fixtures/bspc/personal-solo-dump.json diff --git a/desktop-environment/bspwm/tests/fixtures/bspc/personal-solo-eDP-1-desktops.txt b/apps/monitor-manager/tests/fixtures/bspc/personal-solo-eDP-1-desktops.txt similarity index 100% rename from desktop-environment/bspwm/tests/fixtures/bspc/personal-solo-eDP-1-desktops.txt rename to apps/monitor-manager/tests/fixtures/bspc/personal-solo-eDP-1-desktops.txt diff --git a/desktop-environment/bspwm/tests/fixtures/bspc/personal-solo-monitors.txt b/apps/monitor-manager/tests/fixtures/bspc/personal-solo-monitors.txt similarity index 100% rename from desktop-environment/bspwm/tests/fixtures/bspc/personal-solo-monitors.txt rename to apps/monitor-manager/tests/fixtures/bspc/personal-solo-monitors.txt diff --git a/desktop-environment/bspwm/tests/fixtures/profiles/defaults.yaml b/apps/monitor-manager/tests/fixtures/profiles/defaults.yaml similarity index 100% rename from desktop-environment/bspwm/tests/fixtures/profiles/defaults.yaml rename to apps/monitor-manager/tests/fixtures/profiles/defaults.yaml diff --git a/desktop-environment/bspwm/tests/fixtures/profiles/personal-home.yaml b/apps/monitor-manager/tests/fixtures/profiles/personal-home.yaml similarity index 100% rename from desktop-environment/bspwm/tests/fixtures/profiles/personal-home.yaml rename to apps/monitor-manager/tests/fixtures/profiles/personal-home.yaml diff --git a/desktop-environment/bspwm/tests/fixtures/profiles/personal-solo.yaml b/apps/monitor-manager/tests/fixtures/profiles/personal-solo.yaml similarity index 100% rename from desktop-environment/bspwm/tests/fixtures/profiles/personal-solo.yaml rename to apps/monitor-manager/tests/fixtures/profiles/personal-solo.yaml diff --git a/desktop-environment/bspwm/tests/fixtures/xrandr/personal-solo-props.txt b/apps/monitor-manager/tests/fixtures/xrandr/personal-solo-props.txt similarity index 98% rename from desktop-environment/bspwm/tests/fixtures/xrandr/personal-solo-props.txt rename to apps/monitor-manager/tests/fixtures/xrandr/personal-solo-props.txt index f14c17f..bc2e79e 100644 --- a/desktop-environment/bspwm/tests/fixtures/xrandr/personal-solo-props.txt +++ b/apps/monitor-manager/tests/fixtures/xrandr/personal-solo-props.txt @@ -1,5 +1,5 @@ # NOTE: the eDP-1 EDID below is synthetic, not real hardware (hashes to -# 7f43020e9adaddcd via bspwm/lib/edid.py). The fixture profiles in +# 7f43020e9adaddcd via monitor_manager/edid.py). The fixture profiles in # tests/fixtures/profiles pin their laptop edid to exactly that hash; # test_coordinator's EDID-resolution test asserts the two stay in sync. # Don't run redact-edid.py over this file: it would replace the block diff --git a/desktop-environment/bspwm/tests/fixtures/xrandr/personal-solo.txt b/apps/monitor-manager/tests/fixtures/xrandr/personal-solo.txt similarity index 100% rename from desktop-environment/bspwm/tests/fixtures/xrandr/personal-solo.txt rename to apps/monitor-manager/tests/fixtures/xrandr/personal-solo.txt diff --git a/desktop-environment/bspwm/tests/test_cli.py b/apps/monitor-manager/tests/test_cli.py similarity index 97% rename from desktop-environment/bspwm/tests/test_cli.py rename to apps/monitor-manager/tests/test_cli.py index dea82ea..e2f3107 100644 --- a/desktop-environment/bspwm/tests/test_cli.py +++ b/apps/monitor-manager/tests/test_cli.py @@ -8,8 +8,8 @@ # Add parent directory to path for imports sys.path.insert(0, str(Path(__file__).parent.parent)) -from lib.display import DisplayService, Monitor -from lib.profile import ProfileService +from monitor_manager.display import DisplayService, Monitor +from monitor_manager.profile import ProfileService class TestPlanCommand(unittest.TestCase): diff --git a/desktop-environment/bspwm/tests/test_coordinator.py b/apps/monitor-manager/tests/test_coordinator.py similarity index 94% rename from desktop-environment/bspwm/tests/test_coordinator.py rename to apps/monitor-manager/tests/test_coordinator.py index 0ececf7..076d644 100644 --- a/desktop-environment/bspwm/tests/test_coordinator.py +++ b/apps/monitor-manager/tests/test_coordinator.py @@ -3,10 +3,10 @@ import unittest from pathlib import Path -from lib.coordinator import MonitorManagerCoordinator, ResolvedProfile, PlanResult -from lib.display import DisplayService, Monitor -from lib.profile import ProfileService -from lib.exceptions import ProfileNotFoundError, ProfileValidationError +from monitor_manager.coordinator import MonitorManagerCoordinator, ResolvedProfile, PlanResult +from monitor_manager.display import DisplayService, Monitor +from monitor_manager.profile import ProfileService +from monitor_manager.exceptions import ProfileNotFoundError, ProfileValidationError class MockXrandrExecutor: diff --git a/desktop-environment/bspwm/tests/test_display.py b/apps/monitor-manager/tests/test_display.py similarity index 98% rename from desktop-environment/bspwm/tests/test_display.py rename to apps/monitor-manager/tests/test_display.py index 15caa96..b73cd1c 100644 --- a/desktop-environment/bspwm/tests/test_display.py +++ b/apps/monitor-manager/tests/test_display.py @@ -4,7 +4,7 @@ from unittest.mock import patch, Mock from pathlib import Path -from lib.display import DisplayService, Monitor, XrandrExecutor +from monitor_manager.display import DisplayService, Monitor, XrandrExecutor class MockXrandrExecutor: diff --git a/desktop-environment/bspwm/tests/test_executor.py b/apps/monitor-manager/tests/test_executor.py similarity index 97% rename from desktop-environment/bspwm/tests/test_executor.py rename to apps/monitor-manager/tests/test_executor.py index 5fc4c55..ccdcba5 100644 --- a/desktop-environment/bspwm/tests/test_executor.py +++ b/apps/monitor-manager/tests/test_executor.py @@ -3,8 +3,8 @@ import unittest from typing import List, Tuple -from lib.executor import Executor -from lib.ops import ( +from monitor_manager.executor import Executor +from monitor_manager.ops import ( BspcConfig, BspcDesktopAdd, BspcDesktopRemove, @@ -275,11 +275,11 @@ class TestPlannerExecutorRoundTrip(unittest.TestCase): def test_unplug_regression_through_executor(self): from pathlib import Path - from lib.plan import PreserveByName - from lib.profile import ProfileService - from lib.reconciler import Reconciler - from lib.state.desired import compile_desired - from lib.state.hardware import ( + from monitor_manager.plan import PreserveByName + from monitor_manager.profile import ProfileService + from monitor_manager.reconciler import Reconciler + from monitor_manager.state.desired import compile_desired + from monitor_manager.state.hardware import ( BspwmDesktop, BspwmMonitor, HardwareState, diff --git a/desktop-environment/bspwm/tests/test_integration.py b/apps/monitor-manager/tests/test_integration.py similarity index 89% rename from desktop-environment/bspwm/tests/test_integration.py rename to apps/monitor-manager/tests/test_integration.py index 69807a8..635fc57 100644 --- a/desktop-environment/bspwm/tests/test_integration.py +++ b/apps/monitor-manager/tests/test_integration.py @@ -1,21 +1,23 @@ """Integration tests for full monitor manager workflow.""" +import os +import shutil import unittest from pathlib import Path -from lib.display import DisplayService -from lib.profile import ProfileService -from lib.coordinator import MonitorManagerCoordinator -from lib.reconciler import Reconciler -from lib.simulate import default_mint, simulate -from lib.state.desired import compile_desired -from lib.state.hardware import ( +from monitor_manager.display import DisplayService +from monitor_manager.profile import ProfileService +from monitor_manager.coordinator import MonitorManagerCoordinator +from monitor_manager.reconciler import Reconciler +from monitor_manager.simulate import default_mint, simulate +from monitor_manager.state.desired import compile_desired +from monitor_manager.state.hardware import ( BspwmDesktop, BspwmMonitor, HardwareState, XrandrOutput, ) -from lib.safety import SafetyService +from monitor_manager.safety import SafetyService class MockXrandrExecutor: @@ -95,6 +97,10 @@ def test_full_profile_application_workflow(self): self.assertEqual(desired.bspwm_settings.border_width, 7) self.assertEqual(desired.bspwm_settings.window_gap, 15) + @unittest.skipUnless( + os.environ.get("DISPLAY") and shutil.which("xrandr"), + "needs a live X display and xrandr (the one test here that shells out)", + ) def test_safety_snapshot_captures_full_state(self): """Test that safety snapshot captures both display and WM state.""" import tempfile @@ -135,14 +141,14 @@ def test_safety_snapshot_captures_full_state(self): def test_error_handling_invalid_profile(self): """Test error handling when profile doesn't exist.""" - from lib.exceptions import ProfileNotFoundError + from monitor_manager.exceptions import ProfileNotFoundError with self.assertRaises(ProfileNotFoundError): self.coordinator.plan("nonexistent-profile") def test_error_handling_missing_monitor(self): """Test error handling when required monitor is missing.""" - from lib.exceptions import ProfileValidationError + from monitor_manager.exceptions import ProfileValidationError # Create mock with no connected monitors mock_xrandr_empty = MockXrandrExecutor("Screen 0: minimum 320 x 200\n") diff --git a/desktop-environment/bspwm/tests/test_interactive.py b/apps/monitor-manager/tests/test_interactive.py similarity index 91% rename from desktop-environment/bspwm/tests/test_interactive.py rename to apps/monitor-manager/tests/test_interactive.py index 3f99846..3d1e2fd 100644 --- a/desktop-environment/bspwm/tests/test_interactive.py +++ b/apps/monitor-manager/tests/test_interactive.py @@ -4,9 +4,9 @@ from pathlib import Path from unittest.mock import MagicMock, patch -from lib.interactive import InteractiveMenu, MenuChoice, run_interactive_menu -from lib.display import Monitor -from lib.profile import Profile, DisplayConfig, WindowManagerConfig, UIConfig +from monitor_manager.interactive import InteractiveMenu, MenuChoice, run_interactive_menu +from monitor_manager.display import Monitor +from monitor_manager.profile import Profile, DisplayConfig, WindowManagerConfig, UIConfig class TestInteractiveMenu(unittest.TestCase): @@ -38,7 +38,7 @@ def setUp(self): self.sample_profile.window_manager = MagicMock() self.sample_profile.ui = MagicMock() - @patch("lib.interactive.TerminalMenu") + @patch("monitor_manager.interactive.TerminalMenu") def test_menu_initialization_requires_simple_term_menu(self, mock_term_menu): """Test that InteractiveMenu requires simple-term-menu.""" # This should work when TerminalMenu is available @@ -49,7 +49,7 @@ def test_menu_initialization_requires_simple_term_menu(self, mock_term_menu): ) self.assertIsNotNone(menu) - @patch("lib.interactive.TerminalMenu", None) + @patch("monitor_manager.interactive.TerminalMenu", None) def test_menu_initialization_fails_without_simple_term_menu(self): """Test that InteractiveMenu raises ImportError without simple-term-menu.""" with self.assertRaises(ImportError) as ctx: @@ -61,7 +61,7 @@ def test_menu_initialization_fails_without_simple_term_menu(self): self.assertIn("simple-term-menu", str(ctx.exception)) - @patch("lib.interactive.TerminalMenu") + @patch("monitor_manager.interactive.TerminalMenu") def test_show_detected_monitors_with_monitors(self, mock_term_menu): """Test showing detected monitors when monitors are present.""" self.mock_display_service.detect_monitors.return_value = [self.sample_monitor] @@ -78,7 +78,7 @@ def test_show_detected_monitors_with_monitors(self, mock_term_menu): self.assertEqual(monitors[0].output, "eDP-1") self.mock_display_service.detect_monitors.assert_called_once() - @patch("lib.interactive.TerminalMenu") + @patch("monitor_manager.interactive.TerminalMenu") def test_show_detected_monitors_without_monitors(self, mock_term_menu): """Test showing detected monitors when no monitors are present.""" self.mock_display_service.detect_monitors.return_value = [] @@ -93,7 +93,7 @@ def test_show_detected_monitors_without_monitors(self, mock_term_menu): self.assertEqual(len(monitors), 0) - @patch("lib.interactive.TerminalMenu") + @patch("monitor_manager.interactive.TerminalMenu") def test_show_matched_profiles_with_matches(self, mock_term_menu): """Test showing matched profiles when matches exist.""" matches = [("personal-solo", 100, self.sample_profile)] @@ -112,7 +112,7 @@ def test_show_matched_profiles_with_matches(self, mock_term_menu): self.assertEqual(result[0][1], 100) # score self.assertEqual(result[0][2].name, "personal-solo") # profile - @patch("lib.interactive.TerminalMenu") + @patch("monitor_manager.interactive.TerminalMenu") def test_show_matched_profiles_without_matches(self, mock_term_menu): """Test showing matched profiles when no matches exist.""" self.mock_profile_service.match_profiles.return_value = [] @@ -127,7 +127,7 @@ def test_show_matched_profiles_without_matches(self, mock_term_menu): self.assertEqual(len(result), 0) - @patch("lib.interactive.TerminalMenu") + @patch("monitor_manager.interactive.TerminalMenu") def test_select_profile_returns_name(self, mock_term_menu_class): """Test selecting a profile returns profile name.""" # Mock TerminalMenu instance @@ -148,7 +148,7 @@ def test_select_profile_returns_name(self, mock_term_menu_class): self.assertEqual(result, "personal-solo") mock_menu_instance.show.assert_called_once() - @patch("lib.interactive.TerminalMenu") + @patch("monitor_manager.interactive.TerminalMenu") def test_select_profile_cancel(self, mock_term_menu_class): """Test canceling profile selection.""" # Mock TerminalMenu instance @@ -168,7 +168,7 @@ def test_select_profile_cancel(self, mock_term_menu_class): self.assertIsNone(result) - @patch("lib.interactive.TerminalMenu") + @patch("monitor_manager.interactive.TerminalMenu") def test_select_action_plan(self, mock_term_menu_class): """Test selecting plan action.""" # Mock TerminalMenu instance @@ -186,7 +186,7 @@ def test_select_action_plan(self, mock_term_menu_class): self.assertEqual(result, "plan") - @patch("lib.interactive.TerminalMenu") + @patch("monitor_manager.interactive.TerminalMenu") def test_select_action_apply(self, mock_term_menu_class): """Test selecting apply action.""" # Mock TerminalMenu instance @@ -204,7 +204,7 @@ def test_select_action_apply(self, mock_term_menu_class): self.assertEqual(result, "apply") - @patch("lib.interactive.TerminalMenu") + @patch("monitor_manager.interactive.TerminalMenu") def test_select_action_cancel(self, mock_term_menu_class): """Test canceling action selection.""" # Mock TerminalMenu instance @@ -222,7 +222,7 @@ def test_select_action_cancel(self, mock_term_menu_class): self.assertEqual(result, "cancel") - @patch("lib.interactive.TerminalMenu") + @patch("monitor_manager.interactive.TerminalMenu") def test_confirm_application_yes(self, mock_term_menu_class): """Test confirming application.""" # Mock TerminalMenu instance @@ -240,7 +240,7 @@ def test_confirm_application_yes(self, mock_term_menu_class): self.assertTrue(result) - @patch("lib.interactive.TerminalMenu") + @patch("monitor_manager.interactive.TerminalMenu") def test_confirm_application_no(self, mock_term_menu_class): """Test declining application.""" # Mock TerminalMenu instance @@ -258,7 +258,7 @@ def test_confirm_application_no(self, mock_term_menu_class): self.assertFalse(result) - @patch("lib.interactive.TerminalMenu") + @patch("monitor_manager.interactive.TerminalMenu") @patch("builtins.input", return_value="") # Mock input() for "Press Enter" def test_run_workflow_cancel_at_profile_selection(self, mock_input, mock_term_menu_class): """Test full workflow with cancellation at profile selection.""" @@ -285,7 +285,7 @@ def test_run_workflow_cancel_at_profile_selection(self, mock_input, mock_term_me self.assertIsNone(result.profile_name) self.assertEqual(result.action, "cancel") - @patch("lib.interactive.TerminalMenu") + @patch("monitor_manager.interactive.TerminalMenu") @patch("builtins.input", return_value="") # Mock input() for "Press Enter" def test_run_workflow_plan_action(self, mock_input, mock_term_menu_class): """Test full workflow with plan action.""" @@ -315,7 +315,7 @@ def test_run_workflow_plan_action(self, mock_input, mock_term_menu_class): self.assertEqual(result.profile_name, "personal-solo") self.assertEqual(result.action, "plan") - @patch("lib.interactive.TerminalMenu") + @patch("monitor_manager.interactive.TerminalMenu") @patch("builtins.input", return_value="") # Mock input() for "Press Enter" def test_run_workflow_apply_confirmed(self, mock_input, mock_term_menu_class): """Test full workflow with apply action confirmed.""" @@ -346,7 +346,7 @@ def test_run_workflow_apply_confirmed(self, mock_input, mock_term_menu_class): self.assertEqual(result.profile_name, "personal-solo") self.assertEqual(result.action, "apply") - @patch("lib.interactive.TerminalMenu") + @patch("monitor_manager.interactive.TerminalMenu") def test_run_interactive_menu_convenience_function(self, mock_term_menu): """Test run_interactive_menu convenience function.""" # This is a simple wrapper, just verify it creates menu and calls run() diff --git a/desktop-environment/bspwm/tests/test_ops.py b/apps/monitor-manager/tests/test_ops.py similarity index 98% rename from desktop-environment/bspwm/tests/test_ops.py rename to apps/monitor-manager/tests/test_ops.py index 636e323..5115db5 100644 --- a/desktop-environment/bspwm/tests/test_ops.py +++ b/apps/monitor-manager/tests/test_ops.py @@ -3,7 +3,7 @@ import unittest from dataclasses import FrozenInstanceError -from lib.ops import ( +from monitor_manager.ops import ( BspcConfig, BspcDesktopAdd, BspcDesktopRemove, diff --git a/desktop-environment/bspwm/tests/test_plan_policies.py b/apps/monitor-manager/tests/test_plan_policies.py similarity index 96% rename from desktop-environment/bspwm/tests/test_plan_policies.py rename to apps/monitor-manager/tests/test_plan_policies.py index c425b8d..c3e84f1 100644 --- a/desktop-environment/bspwm/tests/test_plan_policies.py +++ b/apps/monitor-manager/tests/test_plan_policies.py @@ -2,7 +2,7 @@ import unittest -from lib.plan import PreserveByName, SpilloverModulo, SquashToFirst +from monitor_manager.plan import PreserveByName, SpilloverModulo, SquashToFirst class TestPreserveByName(unittest.TestCase): diff --git a/apps/monitor-manager/tests/test_polybar_contract.py b/apps/monitor-manager/tests/test_polybar_contract.py new file mode 100644 index 0000000..6dd6399 --- /dev/null +++ b/apps/monitor-manager/tests/test_polybar_contract.py @@ -0,0 +1,59 @@ +"""The env-var contract between the reconciler and the polybar config. + +The reconciler launches each bar with MONITOR, FONT_0/FONT_1, and +MODULES_LEFT/CENTER/RIGHT in its environment, and the tracked polybar +config reads them back with ${env:NAME:fallback}. Neither side used to +name the other; this test does, so renaming a variable on one side fails +here instead of producing a bar with the wrong font at the next login. +""" + +import re +from pathlib import Path + +from monitor_manager.reconciler import POLYBAR_ENV_VARS, bar_env +from monitor_manager.state.desired import DesiredBar + +# apps/monitor-manager/tests -> repo root -> the x11 module's polybar config +POLYBAR_CONFIG = Path(__file__).resolve().parents[3] / "modules" / "x11" / "polybar" / "shades" + +# What the reconciler exports; the polybar config may not read anything +# outside this set (plus the host values below). +EMITTED = set(POLYBAR_ENV_VARS) + +# Machine values bspwmrc exports from ~/.config/dotfiles/host.env +# (hosts/defaults.env in the repo); these are allowed too. +HOST_ENV = {"BACKLIGHT_CARD", "BATTERY", "ADAPTER"} + + +def _env_lookups() -> set: + names = set() + for ini in POLYBAR_CONFIG.glob("*.ini"): + names.update(re.findall(r"\$\{env:([A-Z0-9_]+)", ini.read_text())) + return names + + +def test_polybar_config_reads_only_variables_something_exports(): + assert POLYBAR_CONFIG.is_dir(), POLYBAR_CONFIG + unknown = _env_lookups() - EMITTED - HOST_ENV + assert not unknown, f"polybar config reads env vars nothing sets: {sorted(unknown)}" + + +def test_polybar_config_reads_every_variable_the_reconciler_emits(): + missing = EMITTED - _env_lookups() + assert not missing, f"reconciler exports vars the polybar config ignores: {sorted(missing)}" + + +def test_reconciler_emits_exactly_the_documented_set(): + bar = DesiredBar( + output="DP-1", + orientation="horizontal", + font_size=16, + modules_left="launcher workspaces", + modules_center="date time", + modules_right="network tray", + ) + env = dict(bar_env(bar, owns_tray=True)) + assert set(env) == EMITTED + assert env["MONITOR"] == "DP-1" + assert env["MODULES_RIGHT"] == "network tray" + assert dict(bar_env(bar, owns_tray=False))["MODULES_RIGHT"] == "network" diff --git a/desktop-environment/bspwm/tests/test_probe_bspwm.py b/apps/monitor-manager/tests/test_probe_bspwm.py similarity index 99% rename from desktop-environment/bspwm/tests/test_probe_bspwm.py rename to apps/monitor-manager/tests/test_probe_bspwm.py index 56eba10..71c0347 100644 --- a/desktop-environment/bspwm/tests/test_probe_bspwm.py +++ b/apps/monitor-manager/tests/test_probe_bspwm.py @@ -4,7 +4,7 @@ import unittest from pathlib import Path -from lib.probe.bspwm import BspwmProbe, _id_to_hex +from monitor_manager.probe.bspwm import BspwmProbe, _id_to_hex FIXTURES = Path(__file__).parent / "fixtures" / "bspc" diff --git a/desktop-environment/bspwm/tests/test_probe_composite.py b/apps/monitor-manager/tests/test_probe_composite.py similarity index 87% rename from desktop-environment/bspwm/tests/test_probe_composite.py rename to apps/monitor-manager/tests/test_probe_composite.py index f32951d..acaf9a3 100644 --- a/desktop-environment/bspwm/tests/test_probe_composite.py +++ b/apps/monitor-manager/tests/test_probe_composite.py @@ -2,11 +2,11 @@ import unittest -from lib.probe.bspwm import BspwmProbe -from lib.probe.composite import CompositeStateProbe -from lib.probe.polybar import PolybarProbe -from lib.probe.xrandr import XrandrProbe -from lib.state.hardware import BspwmSettings +from monitor_manager.probe.bspwm import BspwmProbe +from monitor_manager.probe.composite import CompositeStateProbe +from monitor_manager.probe.polybar import PolybarProbe +from monitor_manager.probe.xrandr import XrandrProbe +from monitor_manager.state.hardware import BspwmSettings class _FakeXrandrRunner: diff --git a/desktop-environment/bspwm/tests/test_probe_polybar.py b/apps/monitor-manager/tests/test_probe_polybar.py similarity index 92% rename from desktop-environment/bspwm/tests/test_probe_polybar.py rename to apps/monitor-manager/tests/test_probe_polybar.py index 7b7ff79..887214d 100644 --- a/desktop-environment/bspwm/tests/test_probe_polybar.py +++ b/apps/monitor-manager/tests/test_probe_polybar.py @@ -2,7 +2,7 @@ import unittest -from lib.probe.polybar import PolybarProbe +from monitor_manager.probe.polybar import PolybarProbe class _FakeRunner: diff --git a/desktop-environment/bspwm/tests/test_probe_xrandr.py b/apps/monitor-manager/tests/test_probe_xrandr.py similarity index 97% rename from desktop-environment/bspwm/tests/test_probe_xrandr.py rename to apps/monitor-manager/tests/test_probe_xrandr.py index 7a55745..bc12821 100644 --- a/desktop-environment/bspwm/tests/test_probe_xrandr.py +++ b/apps/monitor-manager/tests/test_probe_xrandr.py @@ -3,8 +3,8 @@ import unittest from pathlib import Path -from lib.edid import hash_edid -from lib.probe.xrandr import XrandrProbe +from monitor_manager.edid import hash_edid +from monitor_manager.probe.xrandr import XrandrProbe FIXTURES = Path(__file__).parent / "fixtures" / "xrandr" diff --git a/desktop-environment/bspwm/tests/test_profile.py b/apps/monitor-manager/tests/test_profile.py similarity index 99% rename from desktop-environment/bspwm/tests/test_profile.py rename to apps/monitor-manager/tests/test_profile.py index 738d9b6..7f8b28b 100644 --- a/desktop-environment/bspwm/tests/test_profile.py +++ b/apps/monitor-manager/tests/test_profile.py @@ -5,7 +5,7 @@ import tempfile import yaml -from lib.profile import ( +from monitor_manager.profile import ( ProfileService, Profile, MonitorDetection, @@ -16,8 +16,8 @@ PolybarConfig, ValidationResult, ) -from lib.display import Monitor -from lib.exceptions import ProfileNotFoundError, ProfileValidationError +from monitor_manager.display import Monitor +from monitor_manager.exceptions import ProfileNotFoundError, ProfileValidationError class TestProfileService(unittest.TestCase): diff --git a/desktop-environment/bspwm/tests/test_profile_defaults.py b/apps/monitor-manager/tests/test_profile_defaults.py similarity index 97% rename from desktop-environment/bspwm/tests/test_profile_defaults.py rename to apps/monitor-manager/tests/test_profile_defaults.py index 2a4d5f2..bd67004 100644 --- a/desktop-environment/bspwm/tests/test_profile_defaults.py +++ b/apps/monitor-manager/tests/test_profile_defaults.py @@ -5,7 +5,7 @@ import pytest -from lib.profile import ProfileService, _deep_merge +from monitor_manager.profile import ProfileService, _deep_merge MINIMAL_PROFILE = textwrap.dedent( """ diff --git a/apps/monitor-manager/tests/test_profile_search_path.py b/apps/monitor-manager/tests/test_profile_search_path.py new file mode 100644 index 0000000..8cd5464 --- /dev/null +++ b/apps/monitor-manager/tests/test_profile_search_path.py @@ -0,0 +1,101 @@ +"""The profile search path: tracked profiles plus an overlay directory. + +ProfileService reads from a list of directories. A later directory shadows +an earlier one on a name clash and contributes new names, and defaults.yaml +comes from the first directory that has one - so a private overlay can add +machine-specific profiles beside the public ones without editing the repo. +""" + +from pathlib import Path + +import pytest + +from monitor_manager.profile import ProfileService + +MINIMAL = """\ +name: {name} +description: {desc} +detection: + laptop: + output: eDP-1 + alias: laptop + monitors: [] +display: + laptop: + enabled: true + resolution: "1920x1080" + position: "0x0" + primary: true +window_manager: + monitor_order: [laptop] + workspaces: + laptop: [1, 2, 3] +ui: + bars: + - monitor: laptop + orientation: landscape + modules: + left: workspaces +""" + +DEFAULTS = """\ +window_manager: + settings: + border_width: 3 +ui: + font_size: 12 +""" + + +@pytest.fixture +def dirs(tmp_path: Path): + tracked = tmp_path / "profiles" + overlay = tmp_path / "profiles.d" + tracked.mkdir() + overlay.mkdir() + (tracked / "defaults.yaml").write_text(DEFAULTS) + (tracked / "shared.yaml").write_text(MINIMAL.format(name="shared", desc="tracked copy")) + (tracked / "public-only.yaml").write_text(MINIMAL.format(name="public-only", desc="tracked")) + (overlay / "shared.yaml").write_text(MINIMAL.format(name="shared", desc="overlay copy")) + (overlay / "work-only.yaml").write_text(MINIMAL.format(name="work-only", desc="overlay")) + return tracked, overlay + + +def test_list_is_the_union(dirs): + svc = ProfileService(list(dirs)) + assert svc.list_profiles() == ["public-only", "shared", "work-only"] + + +def test_later_directory_shadows_earlier(dirs): + svc = ProfileService(list(dirs)) + assert svc.load_profile("shared").description == "overlay copy" + assert svc.load_profile("public-only").description == "tracked" + assert svc.load_profile("work-only").description == "overlay" + + +def test_defaults_come_from_first_directory_that_has_them(dirs): + svc = ProfileService(list(dirs)) + # work-only lives in the overlay, which has no defaults.yaml; it still + # inherits the tracked defaults + assert svc.load_profile("work-only").window_manager.settings["border_width"] == 3 + + +def test_missing_overlay_directory_is_fine(dirs): + tracked, _ = dirs + svc = ProfileService([tracked, tracked.parent / "does-not-exist"]) + assert svc.list_profiles() == ["public-only", "shared"] + + +def test_single_path_still_accepted(dirs): + tracked, _ = dirs + svc = ProfileService(tracked) + assert svc.profiles_dir == tracked + assert svc.list_profiles() == ["public-only", "shared"] + + +def test_default_search_path_follows_xdg(monkeypatch, tmp_path): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + assert ProfileService.default_profiles_dirs() == [ + tmp_path / "bspwm" / "profiles", + tmp_path / "bspwm" / "profiles.d", + ] diff --git a/desktop-environment/bspwm/tests/test_reconciler.py b/apps/monitor-manager/tests/test_reconciler.py similarity index 97% rename from desktop-environment/bspwm/tests/test_reconciler.py rename to apps/monitor-manager/tests/test_reconciler.py index 690da05..3a4a331 100644 --- a/desktop-environment/bspwm/tests/test_reconciler.py +++ b/apps/monitor-manager/tests/test_reconciler.py @@ -8,7 +8,7 @@ import unittest from pathlib import Path -from lib.ops import ( +from monitor_manager.ops import ( BspcMonitorRemove, BspcWindowMoveToDesktop, PolybarLaunch, @@ -28,12 +28,12 @@ def _applies(plan): def _offs(plan): return [o for op in _layouts(plan) for o in op.offs] -from lib.plan import SquashToFirst -from lib.profile import ProfileService -from lib.reconciler import Reconciler -from lib.simulate import default_mint, simulate -from lib.state.desired import compile_desired -from lib.state.hardware import ( +from monitor_manager.plan import SquashToFirst +from monitor_manager.profile import ProfileService +from monitor_manager.reconciler import Reconciler +from monitor_manager.simulate import default_mint, simulate +from monitor_manager.state.desired import compile_desired +from monitor_manager.state.hardware import ( BspwmDesktop, BspwmMonitor, BspwmSettings, @@ -161,7 +161,7 @@ def test_xrandr_enables_edp_first(self): def test_no_reset_desktops_op_exists_in_vocabulary(self): # Sanity: the destructive op type isn't in our Op union by construction. # If someone adds it later, this test fails before the bug returns. - from lib import ops as ops_module + from monitor_manager import ops as ops_module self.assertFalse(hasattr(ops_module, "BspcDesktopReset")) diff --git a/desktop-environment/bspwm/tests/test_renderer.py b/apps/monitor-manager/tests/test_renderer.py similarity index 97% rename from desktop-environment/bspwm/tests/test_renderer.py rename to apps/monitor-manager/tests/test_renderer.py index b0de2c3..2327e20 100644 --- a/desktop-environment/bspwm/tests/test_renderer.py +++ b/apps/monitor-manager/tests/test_renderer.py @@ -2,7 +2,7 @@ import unittest -from lib.ops import ( +from monitor_manager.ops import ( BspcConfig, BspcDesktopAdd, BspcDesktopRemove, @@ -16,7 +16,7 @@ XrandrApply, XrandrOff, ) -from lib.renderer import Renderer +from monitor_manager.renderer import Renderer class TestRenderer(unittest.TestCase): diff --git a/desktop-environment/bspwm/tests/test_safety.py b/apps/monitor-manager/tests/test_safety.py similarity index 98% rename from desktop-environment/bspwm/tests/test_safety.py rename to apps/monitor-manager/tests/test_safety.py index 1d86c19..917fa59 100644 --- a/desktop-environment/bspwm/tests/test_safety.py +++ b/apps/monitor-manager/tests/test_safety.py @@ -5,7 +5,7 @@ from pathlib import Path from datetime import datetime -from lib.safety import SafetyService, StateSnapshot +from monitor_manager.safety import SafetyService, StateSnapshot class TestStateSnapshot(unittest.TestCase): diff --git a/desktop-environment/bspwm/tests/test_simulate.py b/apps/monitor-manager/tests/test_simulate.py similarity index 98% rename from desktop-environment/bspwm/tests/test_simulate.py rename to apps/monitor-manager/tests/test_simulate.py index 6fe453d..fd17951 100644 --- a/desktop-environment/bspwm/tests/test_simulate.py +++ b/apps/monitor-manager/tests/test_simulate.py @@ -2,7 +2,7 @@ import unittest -from lib.ops import ( +from monitor_manager.ops import ( BspcConfig, BspcDesktopAdd, BspcDesktopRemove, @@ -15,8 +15,8 @@ XrandrApply, XrandrOff, ) -from lib.simulate import default_mint, simulate -from lib.state.hardware import ( +from monitor_manager.simulate import default_mint, simulate +from monitor_manager.state.hardware import ( BspwmDesktop, BspwmMonitor, BspwmSettings, diff --git a/desktop-environment/bspwm/tests/test_state_desired.py b/apps/monitor-manager/tests/test_state_desired.py similarity index 98% rename from desktop-environment/bspwm/tests/test_state_desired.py rename to apps/monitor-manager/tests/test_state_desired.py index 14a1b43..baac29e 100644 --- a/desktop-environment/bspwm/tests/test_state_desired.py +++ b/apps/monitor-manager/tests/test_state_desired.py @@ -3,8 +3,8 @@ import unittest from pathlib import Path -from lib.profile import ProfileService -from lib.state.desired import compile_desired +from monitor_manager.profile import ProfileService +from monitor_manager.state.desired import compile_desired class TestCompilePersonalSolo(unittest.TestCase): diff --git a/desktop-environment/bspwm/tests/test_state_hardware.py b/apps/monitor-manager/tests/test_state_hardware.py similarity index 98% rename from desktop-environment/bspwm/tests/test_state_hardware.py rename to apps/monitor-manager/tests/test_state_hardware.py index 54d74ba..7bb1f22 100644 --- a/desktop-environment/bspwm/tests/test_state_hardware.py +++ b/apps/monitor-manager/tests/test_state_hardware.py @@ -3,7 +3,7 @@ import unittest from dataclasses import FrozenInstanceError -from lib.state.hardware import ( +from monitor_manager.state.hardware import ( BspwmDesktop, BspwmMonitor, BspwmSettings, diff --git a/desktop-environment/bspwm/tests/test_tracked_profiles.py b/apps/monitor-manager/tests/test_tracked_profiles.py similarity index 84% rename from desktop-environment/bspwm/tests/test_tracked_profiles.py rename to apps/monitor-manager/tests/test_tracked_profiles.py index 2f7a833..5bebba5 100644 --- a/desktop-environment/bspwm/tests/test_tracked_profiles.py +++ b/apps/monitor-manager/tests/test_tracked_profiles.py @@ -11,9 +11,10 @@ import unittest from pathlib import Path -from lib.profile import ProfileService +from monitor_manager.profile import ProfileService -TRACKED_PROFILES = Path(__file__).parent.parent / "profiles" +# apps/monitor-manager/tests -> repo root -> the x11 module's tracked profiles +TRACKED_PROFILES = Path(__file__).resolve().parents[3] / "modules" / "x11" / "bspwm" / "profiles" class TestTrackedProfiles(unittest.TestCase): @@ -31,7 +32,7 @@ def test_every_tracked_profile_loads_and_validates(self): ) def test_every_edid_pin_is_a_truncated_hash(self): - """EDID pins must be 16 lowercase hex chars (lib/edid.py hash keys); + """EDID pins must be 16 lowercase hex chars (monitor_manager/edid.py hash keys); anything else means a raw EDID or serial leaked into a public file.""" service = ProfileService(TRACKED_PROFILES) for name in service.list_profiles(): diff --git a/desktop-environment/bspwm/uv.lock b/apps/monitor-manager/uv.lock similarity index 99% rename from desktop-environment/bspwm/uv.lock rename to apps/monitor-manager/uv.lock index a9efcf4..7a42169 100644 --- a/desktop-environment/bspwm/uv.lock +++ b/apps/monitor-manager/uv.lock @@ -2,48 +2,6 @@ version = 1 revision = 3 requires-python = ">=3.10" -[[package]] -name = "bspwm-monitor-manager" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "pyyaml" }, - { name = "rich" }, - { name = "simple-term-menu" }, -] - -[package.optional-dependencies] -dev = [ - { name = "pytest" }, - { name = "pytest-cov" }, - { name = "ruff" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pytest" }, - { name = "pytest-cov" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" }, - { name = "pyyaml", specifier = ">=6.0" }, - { name = "rich", specifier = ">=13.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, - { name = "simple-term-menu", specifier = ">=1.6.6" }, -] -provides-extras = ["dev"] - -[package.metadata.requires-dev] -dev = [ - { name = "pytest", specifier = ">=8.4.2" }, - { name = "pytest-cov", specifier = ">=7.0.0" }, - { name = "ruff", specifier = ">=0.13.3" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -198,6 +156,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "monitor-manager" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pyyaml" }, + { name = "rich" }, + { name = "simple-term-menu" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "rich", specifier = ">=13.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, + { name = "simple-term-menu", specifier = ">=1.6.6" }, +] +provides-extras = ["dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "ruff", specifier = ">=0.13.3" }, +] + [[package]] name = "packaging" version = "26.2" diff --git a/bin/.gitkeep b/bin/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/desktop-environment/bspwm/CLAUDE.md b/desktop-environment/bspwm/CLAUDE.md deleted file mode 100644 index 29cd30a..0000000 --- a/desktop-environment/bspwm/CLAUDE.md +++ /dev/null @@ -1,154 +0,0 @@ -# CLAUDE.md - BSPWM + Monitor Manager - -## Commands - -**Python: ALWAYS use `uv run`** - -```bash -uv run python monitor-manager.py # CLI -uv run pytest # all tests -uv run pytest tests/test_display.py -v # specific test -``` - -**bspwmrc uses `.venv/bin/python` directly** (intentional — avoids `uv` startup cost at boot). If `.venv` doesn't exist, `uv sync` first. - -## Architecture - -### Boot Sequence (bspwmrc) - -Ordered startup — each step depends on the previous: - -1. **Wait for X** — polls `xrandr --query` for connected monitors (max 6s) -2. **Apply monitor profile** — `match --best` (stderr → log, exit code checked), then `apply-all --force`. Fallback: `xrandr --auto` + notify-send -3. **Configure bspwm** — borders, gaps, window rules (defaults; profiles override these) -4. **Launch background apps** — picom, nitrogen, redshift, nm-applet, blueman, protonvpn (all pgrep-guarded) -5. **Start sxhkd LAST** — `pkill` + 200ms wait + launch. Must be last so X key grabs succeed after monitor setup - -Logs: `$XDG_STATE_HOME/bspwm/bspwm.log`, `$XDG_STATE_HOME/sxhkd/sxhkd.log`, -and `$XDG_STATE_HOME/desktop-session/boot-.log`. The latter captures -filtered GNOME/Mutter, bspwm, Xorg, GDM, NVIDIA, monitor, and failed-unit -events for both GNOME autostart and bspwm startup. - -### Monitor Manager (Python) - -Service-oriented architecture with Protocol-based dependency injection for testability. - -**Services:** -- `DisplayService` (lib/display.py) — xrandr parsing and EDID extraction (read-only; application happens in the executor) -- `ProfileService` (lib/profile.py) — YAML loading with profiles/defaults.yaml merge, validation, EDID-based matching (+100/monitor match, -10/extra monitor) -- `MonitorManagerCoordinator` (lib/coordinator.py) — resolves profile aliases to actual hardware outputs via EDID→output mapping -- `SafetyService` (lib/safety.py) — pre-apply state snapshots (per-monitor desktop lists) to `$XDG_STATE_HOME/bspwm-monitor-manager/snapshots/`, pruned to the newest 20 per kind -- Default-profile preference (lib/preferences.py) — `$XDG_STATE_HOME/bspwm-monitor-manager/state.json` -- `InteractiveMenu` (lib/interactive.py) — simple-term-menu TUI for profile selection - -**Reconciliation pipeline** (the current apply path, used by `plan` and `apply-all`): -- `lib/probe/` — read-only probes (xrandr, bspc, polybar) assembling a `HardwareState` -- `lib/state/` — frozen dataclasses for hardware state, plus `compile_desired(profile, alias_to_output)` → `DesiredState` (pure) -- `lib/reconciler.py` — diffs current vs desired into a typed `Plan` of ops, simulating each op against shadow state -- `lib/executor.py` — runs the Plan, minting symbolic refs ($M_n) into real ids at runtime -- `lib/renderer.py` — renders the same Plan for preview and execution -- `lib/simulate.py`, `lib/ops.py`, `lib/plan.py` — pure transition function, op types, merge policies - -**CLI entry point:** `monitor-manager.py` (~950 lines). Commands: -- Read-only: `detect`, `validate`, `list`, `match` (with `--best` for scripting) -- State: `set-default`, `clear-default` -- Planning: `plan` (dry-run; renders the same Plan apply-all would run) -- Apply: `apply-all` — the single apply path, via the reconciliation pipeline -- Interactive: `interactive` (TUI; plan preview and apply both use the reconciliation pipeline via `apply_profile()`) - -**apply-all sequencing:** After xrandr ops, `WaitForBspwmMonitor` polls for up to 7.5s for expected outputs to appear in bspwm before bspc desktop ops run. - -### Profile System - -YAML files in `profiles/`. Four sections each: - -```yaml -detection: # EDID fingerprints for hardware matching -display: # xrandr config (resolution, position, rotation, scale, primary) -window_manager: # workspace distribution per monitor + bspwm settings -ui: # polybar bars per monitor (orientation, font_size, modules) -``` - -Profiles use logical aliases (laptop, main, vertical) resolved to actual outputs at runtime. - -**Current profiles:** personal-solo (eDP-1), personal-home, -work-laptop-home, work-laptop-woodinville (laptop fallback enabled), and -work-laptop-woodinville-clamshell (external monitors only). Profile matching -uses the ACPI lid state when available and skips an enabled-laptop profile -while the lid is closed. It also falls back to requiring an active Xrandr mode -when the lid state is unavailable. - -### Shell Scripts (scripts/) - -- `apply-auto.sh` — re-detect topology, apply best profile via reconciliation (super+alt+r, super+alt+shift+p) -- `smart_focus.sh` — focus node in direction, fall through to monitor at edge -- `smart_send.sh` — swap with neighbor node, or move to adjacent monitor -- `smart_resize.sh` — expand toward direction if neighbor exists, else contract -- `toggle_polybar.sh` — show/hide polybar; saves/restores per-monitor top_padding in `$XDG_STATE_HOME/bspwm/` -- `monocle-border.sh` — event-driven border width (thick in monocle, thin otherwise) -- `monitor-switch.sh` — launches interactive TUI in kitty terminal -- `capture-fixture.sh` — captures xrandr/bspc state as test fixtures - -### Polybar - -adi1090x "shades" theme. Single `[bar/main]` definition, customized per-monitor via env vars from UIService. `pin-workspaces = true` — each bar shows only its monitor's desktops. - -The tray is the `internal/tray` module, which needs polybar >= 3.7.0 — 22.04 -packages 3.5.7, where it silently does nothing, so `desktop-environment/ -provision.sh` builds a pinned 3.7.2 from source into `~/.local`. Profiles do -not name `tray` in their modules: two bars listing it race for tray clients, -so `_reconcile_polybar` appends it to the right block of the bar on the -`primary` output and strips it from every other bar. A profile with no -`primary: true` display therefore gets no tray at all. The tray is destroyed -and rebuilt on every polybar restart, and blueman-applet only registers its -icon at startup, which is why apply-auto.sh restarts it afterwards (nm-applet -re-registers on its own). - -`scripts/network-env.sh`, sourced by bspwmrc and apply-auto.sh, exports -`NETWORK_INTERFACE` (whichever interface holds the default route), and on a -wired link `NETWORK_LABEL` (its NetworkManager connection name). Both are -read by the network module's `../polybar/shades/scripts/network-label.sh`, -a custom/script module rather than polybar's `internal/network`. Wi-Fi leaves -`NETWORK_LABEL` unset: the script resolves the live SSID and signal strength -itself, so roaming needs no bar restart. - -### sxhkd (../sxhkd/sxhkdrc) - -Key bindings reference `~/.config/bspwm/scripts/` (symlinked by Dotbot). Notable: -- `super+shift+x` — interactive monitor manager -- `super+alt+shift+p` — re-apply current profile (restarts polybar) via apply-auto.sh -- `super+alt+p` — toggle polybar visibility via toggle_polybar.sh -- `super+shift+F1` — emergency laptop display recovery (enables any connected eDP-* output) - -## Testing - -200 tests across 21 files. Unit tests mock hardware via Protocol implementations (MockXrandrExecutor, MockBspcExecutor, MockPolybarExecutor). Integration tests use xrandr fixture files in `tests/fixtures/xrandr/`; one safety-snapshot test shells out to real `xrandr` and needs a display (use `xvfb-run` headless). - -Tests never read the tracked `profiles/` directory (production config with real-hardware EDID pins); they use the synthetic profiles in `tests/fixtures/profiles/` instead. The one exception is `test_tracked_profiles.py`, which exists to validate that the real profiles parse, merge with defaults.yaml, and pass validation. The fixture profiles' laptop edid must stay equal to the hash of the synthetic EDID in `tests/fixtures/xrandr/personal-solo-props.txt`; test_coordinator asserts this. - -```bash -uv run pytest # all -uv run pytest tests/test_profile.py -v # specific -uv run pytest --cov=lib --cov-report=term-missing # coverage -``` - -## Known Issues - -- Rollback files are still text instructions; a serialised inverse Plan - (true plan-based rollback) remains future work - -## xrandr Constraints (learned the hard way, 2026-06-11) - -- All xrandr changes go out as ONE invocation (`XrandrApplyLayout` op) with - an explicit `--fb`. Sequential per-output calls wedge the modesetting - driver: once any CRTC has a scale transform active, every subsequent - screen resize fails with RRSetScreenSize BadMatch — even a bare - `xrandr --fb`. Recovery from that state requires resetting the scaled - output to `--scale 1x1` first. -- The probe reads xrandr's summary line, which reports post-transform - geometry and no scale; the reconciler projects desired (mode, scale, - rotation) into that space (`_effective_mode`) before diffing, otherwise - scaled outputs re-modeset on every apply. -- bspwm refuses to remove a monitor's last desktop, but `bspc monitor -r` - takes the monitor down together with one remaining empty desktop, so the - cleanup phase leaves one behind for it. diff --git a/desktop-environment/bspwm/scripts/monitor-switch.sh b/desktop-environment/bspwm/scripts/monitor-switch.sh deleted file mode 100755 index dc45721..0000000 --- a/desktop-environment/bspwm/scripts/monitor-switch.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash -# Bash wrapper for monitor-manager.py interactive mode -# Designed to be called from sxhkd or other hotkey daemons - -set -euo pipefail - -# Get the directory where this script is located -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -# Use kitty launch script (handles font size based on resolution) -# Run monitor-manager in interactive mode with --hold to keep terminal open -~/.config/kitty/launch.sh --title "Monitor Manager" --hold bash -c "cd '$PROJECT_ROOT' && uv run python monitor-manager.py interactive" diff --git a/desktop-environment/polybar/shades/user_modules.ini b/desktop-environment/polybar/shades/user_modules.ini deleted file mode 100644 index 79330e5..0000000 --- a/desktop-environment/polybar/shades/user_modules.ini +++ /dev/null @@ -1,143 +0,0 @@ -;; ┌──────────────────────────────────────────────────────────────────────────────-----┐ -;; │░█▀█░█▀█░█░░░█░█░█▀▄░█▀█░█▀▄░░░░░░░░░█░█░█▀▀░█▀▀░█▀▄░░░█▄█░█▀█░█▀▄░█░█░█░░░█▀▀░█▀▀ │ -;; │░█▀▀░█░█░█░░░░█░░█▀▄░█▀█░█▀▄░░░░▀░░░░█░█░▀▀█░█▀▀░█▀▄░░░█░█░█░█░█░█░█░█░█░░░█▀▀░▀▀█ │ -;; │░▀░░░▀▀▀░▀▀▀░░▀░░▀▀░░▀░▀░▀░▀░░░░▀░░░░▀▀▀░▀▀▀░▀▀▀░▀░▀░░░▀░▀░▀▀▀░▀▀░░▀▀▀░▀▀▀░▀▀▀░▀▀▀ │ -;; │░Created░By░Aditya░Shakya░@adi1090x░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ -;; └──────────────────────────────────────────────────────────────────────────────-----┘ - -;; _-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_ - -[module/sysmenu] -type = custom/text -content = 襤 - -content-background = ${color.shade2} -content-foreground = ${color.foreground} -content-padding = 2 - -click-left = ~/.config/polybar/shades/scripts/powermenu.sh & - -;; _-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_ - -[module/links] -type = custom/text -content-foreground = ${color.foreground-alt} -content-padding = 2 - -[module/google] -inherit = module/links -content =  -click-left = exo-open https://www.google.com/ & - -[module/bitbucket] -inherit = module/links -content =  -click-left = xdg-open https://www.bitbucket.com/ & - -[module/gmail] -inherit = module/links -content =  -click-left = exo-open https://mail.google.com/ & - -[module/twitter] -inherit = module/links -content =  -click-left = exo-open https://www.twitter.com/ & - -;; _-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_ - -[module/powermenu] -type = custom/menu - -; If true, will be to the left of the menu items (default). -; If false, it will be on the right of all the items. -expand-right = true - -; "menu-LEVEL-N" has the same properties as "label-NAME" with -; the additional "exec" property -; -; Available exec commands: -; menu-open-LEVEL -; menu-close -; Other commands will be executed using "/usr/bin/env sh -c $COMMAND" -menu-0-0 = reboot -menu-0-0-exec = menu-open-1 -menu-0-1 = shutdown -menu-0-1-exec = menu-open-2 - -menu-1-0 = back -menu-1-0-exec = menu-open-0 -menu-1-1 = reboot -menu-1-1-exec = systemctl reboot - -menu-2-0 = shutdown -menu-2-0-exec = systemctl poweroff -menu-2-1 = back -menu-2-1-exec = menu-open-0 - -; Available tags: -; (default) - gets replaced with -; (default) -; Note that if you use you must also include -; the definition for - -format = -format-background = ${color.shade1} -format-foreground = ${color.foreground} -format-padding = 2 - -label-open =  -label-close =  - -; Optional item separator -; Default: none -label-separator = " | " - -;;label-open-foreground = ${color.foreground} -;;label-close-foreground = ${color.background} -;;label-separator-foreground = ${color.background} - -;; _-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_ - -[module/menu] -type = custom/menu - -; If true, will be to the left of the menu items (default). -; If false, it will be on the right of all the items. -expand-right = true - -; "menu-LEVEL-N" has the same properties as "label-NAME" with -; the additional "exec" property -; -; Available exec commands: -; menu-open-LEVEL -; menu-close -; Other commands will be executed using "/usr/bin/env sh -c $COMMAND" -menu-0-0 = Menu -menu-0-0-exec = ~/.config/polybar/shades/scripts/launcher.sh & -menu-0-1 = Files -menu-0-1-exec = thunar & -menu-0-2 = Terminal -menu-0-2-exec = termite & -menu-0-3 = Browser -menu-0-3-exec = firefox & - -; Available tags: -; (default) - gets replaced with -; (default) -; Note that if you use you must also include -; the definition for - -format = -format-background = ${color.shade1} -format-foreground = ${color.foreground} -format-padding = 2 - -label-open =  -label-close =  - -; Optional item separator -; Default: none -label-separator = " | " - -;; _-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_ diff --git a/desktop-environment/provision.sh b/desktop-environment/provision.sh deleted file mode 100755 index bafb8bb..0000000 --- a/desktop-environment/provision.sh +++ /dev/null @@ -1,164 +0,0 @@ -#!/usr/bin/env bash -# -# provision.sh - idempotent provisioning for the X11/WM stack in this repo. -# -# Installs the system packages this repo's configs and scripts depend on, -# plus uv (exact pin + sha256, via ../provision-lib.sh) to build the -# monitor-manager's venv. Steps already satisfied are skipped, so re-running -# is safe; any failure aborts loudly with a nonzero exit. Run ./install -# afterwards to symlink the configs themselves. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=../provision-lib.sh -. "$SCRIPT_DIR/../provision-lib.sh" - -require_not_root -init_provision_log "$SCRIPT_DIR/provision.log" - -mkdir -p "$HOME/.local/bin" - -log "Provisioning started" - -# Picom is built from a pinned upstream commit below because Ubuntu 22.04's -# package is v9, whose rounded-corner antialiasing is visibly uneven. -PICOM_VERSION="13" -PICOM_COMMIT="d87a5ba3af7a9ee3c4e040ee29b2dea7e9e46317" -# Polybar likewise: 22.04 packages 3.5.7, but modules.ini's [module/tray] needs -# the internal/tray module added in 3.7.0. Unlike picom's, this artifact is an -# asset upstream uploads rather than one GitHub generates on demand, so its -# bytes are fixed for good - and it is the only source that ships polybar's -# vendored submodules, which a git-tag archive omits. The sha256 is the one -# Debian records for polybar_3.7.2.orig.tar.gz. -POLYBAR_VERSION="3.7.2" -POLYBAR_SHA256="e2feacbd02e7c94baed7f50b13bcbf307d95df0325c3ecae443289ba5b56af29" - -# ---------------------------------------------------------------------------- -# WM/X11 stack (apt). Everything bspwmrc, sxhkd, dunst, and the systemd user -# units invoke: -# nitrogen - wallpaper restore at bspwm startup -# network-manager-gnome, blueman - nm-applet / blueman-applet tray apps -# x11-xkb-utils - setxkbmap (swapescape.service ExecStart) -# xdg-utils - xdg-open (dunstrc browser) -# i3lock, libnotify-bin - bin/lockscreen and startup-failure notifications -# protonvpn-app is deliberately NOT here: it comes from Proton's own repo -# and bspwmrc pgrep-guards it, so its absence is harmless. -# kitty (sxhkd's terminal, monitor-switch.sh) is provisioned by -# provision-shell.sh as a pinned upstream bundle. -# ---------------------------------------------------------------------------- -log "WM/X11 packages (apt)" -sudo apt-get update -qq -sudo apt-get install -y -qq \ - bspwm sxhkd rofi dunst nitrogen \ - redshift brightnessctl pulseaudio-utils scrot xclip simplescreenrecorder \ - x11-xserver-utils x11-xkb-utils xserver-xorg-input-wacom \ - network-manager-gnome blueman xdg-utils \ - i3lock libnotify-bin fontconfig xz-utils \ - build-essential cmake curl git meson ninja-build pkg-config \ - libconfig-dev libdbus-1-dev libegl-dev libev-dev libgl-dev libepoxy-dev \ - libpcre2-dev libpixman-1-dev libx11-xcb-dev libxcb1-dev \ - libxcb-composite0-dev libxcb-damage0-dev libxcb-glx0-dev \ - libxcb-image0-dev libxcb-present-dev libxcb-randr0-dev \ - libxcb-render0-dev libxcb-render-util0-dev libxcb-shape0-dev \ - libxcb-util-dev libxcb-xfixes0-dev uthash-dev \ - libcairo2-dev libuv1-dev libnl-genl-3-dev libpulse-dev \ - libxcb-cursor-dev libxcb-ewmh-dev libxcb-icccm4-dev libxcb-sync-dev \ - libxcb-xkb-dev libxcb-xrm-dev python3-xcbgen xcb-proto - -# ---------------------------------------------------------------------------- -# Picom (pinned source build). Ubuntu 22.04 only packages v9. Upstream uploads -# no release assets, and the alternative - a sha256 over GitHub's tag archive - -# pins bytes GitHub generates on demand and has changed before, which fails the -# hash through no fault of the tag. So this pins the commit instead: git's own -# object hashing makes it the content hash, and a re-pointed tag is caught by -# the check below rather than silently building something else. Meson's bundled -# libconfig fallback is itself pinned and handles 22.04's pre-1.7 libconfig. -# ---------------------------------------------------------------------------- -log "picom v$PICOM_VERSION (source build)" -PICOM_BIN="$HOME/.local/bin/picom" -if [ -x "$PICOM_BIN" ] && [ "$("$PICOM_BIN" --version 2>/dev/null)" = "v$PICOM_VERSION" ]; then - skip "picom $("$PICOM_BIN" --version) already at pin" -else - tmp="$(mktemp -d)" - git -c advice.detachedHead=false clone --quiet --depth 1 \ - --branch "v$PICOM_VERSION" https://github.com/yshui/picom "$tmp/src" || - die "clone failed: picom v$PICOM_VERSION" - picom_head="$(git -C "$tmp/src" rev-parse HEAD)" - [ "$picom_head" = "$PICOM_COMMIT" ] || - die "picom tag v$PICOM_VERSION is $picom_head, expected $PICOM_COMMIT - refusing to build" - # picom's meson.build stamps `git rev-parse` output into the version string - # when it builds inside a repository, so the binary would report "v13 - # (revision d87a5ba)" and never match the pin check above - rebuilding on - # every run. Drop the metadata now that the commit is verified. - rm -rf "$tmp/src/.git" - meson setup --buildtype=release "$tmp/src/build" "$tmp/src" || - die "picom v$PICOM_VERSION configure failed" - ninja -C "$tmp/src/build" src/picom || - die "picom v$PICOM_VERSION build failed" - install -m 0755 "$tmp/src/build/src/picom" "$PICOM_BIN" || - die "picom v$PICOM_VERSION install failed" - rm -rf "$tmp" -fi - -# ---------------------------------------------------------------------------- -# Polybar (pinned source build). 22.04 packages 3.5.7, which predates the -# internal/tray module modules.ini uses, so on that release the tray silently -# never appears. Built with the feature set this config actually references: -# internal/pulseaudio ([module/alsa]) and internal/network need libpulse and -# libnl, while i3/mpd/curl/alsa modules are unused and their backends are -# switched off rather than pulled in. Docs need sphinx and are skipped. -# Installs under ~/.local, ahead of any apt polybar on PATH. -# ---------------------------------------------------------------------------- -log "polybar $POLYBAR_VERSION (source build)" -if at_pinned_version polybar "$POLYBAR_VERSION"; then - skip "polybar $(installed_version polybar) already at pin" -else - tmp="$(mktemp -d)" - fetch_url \ - "https://github.com/polybar/polybar/releases/download/$POLYBAR_VERSION/polybar-$POLYBAR_VERSION.tar.gz" \ - "$tmp/polybar.tar.gz" || - die "download failed: polybar $POLYBAR_VERSION" - verify_sha256 "$tmp/polybar.tar.gz" "$POLYBAR_SHA256" - mkdir -p "$tmp/src" - tar -xzf "$tmp/polybar.tar.gz" -C "$tmp/src" --strip-components=1 || - die "extract failed: polybar $POLYBAR_VERSION" - cmake -S "$tmp/src" -B "$tmp/src/build" \ - -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_INSTALL_PREFIX="$HOME/.local" \ - -DBUILD_DOC=OFF \ - -DENABLE_I3=OFF -DENABLE_MPD=OFF -DENABLE_CURL=OFF -DENABLE_ALSA=OFF || - die "polybar $POLYBAR_VERSION configure failed" - cmake --build "$tmp/src/build" || die "polybar $POLYBAR_VERSION build failed" - cmake --install "$tmp/src/build" || die "polybar $POLYBAR_VERSION install failed" - rm -rf "$tmp" -fi - -# ---------------------------------------------------------------------------- -# Nerd Fonts (pinned via provision-lib.sh): polybar's bars use -# FantasqueSansM/Iosevka, dunst and the rofi themes use JetBrainsMono. -# ---------------------------------------------------------------------------- -install_nerd_font JetBrainsMono "$NERD_FONT_JETBRAINSMONO_SHA256" -install_nerd_font Iosevka "$NERD_FONT_IOSEVKA_SHA256" -install_nerd_font FantasqueSansMono "$NERD_FONT_FANTASQUESANSMONO_SHA256" - -# ---------------------------------------------------------------------------- -# uv (python package/venv manager) - needed for `uv sync` below; pin and -# installer are shared with provision-shell.sh via provision-lib.sh -# ---------------------------------------------------------------------------- -install_uv - -# ---------------------------------------------------------------------------- -# bspwm monitor-manager venv (bspwmrc runs .venv/bin/python directly at -# login, so the venv must exist before the first graphical session). -# --locked: install exactly what uv.lock records, and fail loudly if -# pyproject.toml and uv.lock have drifted apart. -# ---------------------------------------------------------------------------- -log "bspwm monitor-manager venv" -UV_BIN="$(command -v uv || echo "$HOME/.local/bin/uv")" -[ -x "$UV_BIN" ] || die "uv not found; cannot create bspwm .venv" -(cd "$SCRIPT_DIR/bspwm" && "$UV_BIN" sync --locked -q) || - die "uv sync --locked failed in bspwm/" -skip "bspwm .venv in sync" - -log "Provisioning complete. Run ./install to symlink configs." diff --git a/docs/desktop-environment-prd.md b/docs/desktop-environment-prd.md index 8f55df3..a81b28b 100644 --- a/docs/desktop-environment-prd.md +++ b/docs/desktop-environment-prd.md @@ -1,6 +1,11 @@ # PRD: Desktop Environment Consolidation (bspwm / polybar / sxhkd / rofi) -Status: draft for review +Status: draft for review. Written 2026-06 against the pre-roles layout +(`desktop-environment/`, `.venv`, `monitor-manager.py`); the file +references below predate the move to `modules/x11/` and +`apps/monitor-manager/`, and §1's "exactly one user" predates the +multi-machine roles and overlay seams. WS1, WS3, and WS5 (minus rollback) +have landed; WS4's single palette/font source has not. Scope: bspwm, monitor-manager, polybar, rofi, sxhkd, picom, dunst, redshift, and provisioning of all of the above Out of scope: kitty internals, tmux, nvim (separate repo), shell config diff --git a/hosts/defaults.env b/hosts/defaults.env new file mode 100644 index 0000000..85e2209 --- /dev/null +++ b/hosts/defaults.env @@ -0,0 +1,27 @@ +# Per-machine values every host starts from. Overridden, key by key, by +# hosts/.env (tracked; for machines it's fine to describe here) and +# then by $XDG_CONFIG_HOME/dotfiles/local.env (untracked; the private overlay's +# seam). ./install merges the three into $XDG_CONFIG_HOME/dotfiles/host.env, +# which templates (lib/dotbot-plugins/render.py) substitute ${NAME} from and +# bspwmrc exports into the desktop session. +# +# Plain KEY=value lines only: this file is both sourced by bash and parsed by +# Python, so no quoting tricks, no expansions. + +# Xft.dpi for ~/.Xresources. 124 is what every machine got before this was +# per-host; a machine with a different panel sets its own in +# hosts/.env or dotfiles/local.env. +DPI=124 + +# redshift: where the machine lives, and the day/night colour temperatures. +REDSHIFT_LAT=47.6 +REDSHIFT_LON=-122.3 +REDSHIFT_TEMP_DAY=5500 +REDSHIFT_TEMP_NIGHT=4000 + +# polybar hardware ids: the interface to watch when no default route is up +# yet, the backlight sysfs card, and the battery/adapter names. +NETWORK_FALLBACK_IFACE=wlp9s0 +BACKLIGHT_CARD=intel_backlight +BATTERY=BAT0 +ADAPTER=AC diff --git a/install b/install index bb851a0..772bbb8 100755 --- a/install +++ b/install @@ -1,33 +1,76 @@ #!/usr/bin/env bash # -# install - dotbot wrapper. Links the shell/terminal/dev configs -# (install.conf.yaml), then the desktop-environment configs -# (install-desktop.conf.yaml) unless --no-desktop is given. Any other -# arguments are forwarded to dotbot itself (e.g. -v, -Q). - -set -e - -CONFIG="./install.conf.yaml" -DESKTOP_CONFIG="./install-desktop.conf.yaml" -DOTBOT_DIR="./dotbot" - -DOTBOT_BIN="bin/dotbot" -BASEDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -DESKTOP=true -DOTBOT_ARGS=() -for arg in "$@"; do - case "$arg" in - --no-desktop) DESKTOP=false ;; - *) DOTBOT_ARGS+=("$arg") ;; - esac +# install - links this repo's configs into $HOME, one dotbot pass per +# selected module. +# +# ./install --roles desktop # first run on a machine of that kind +# ./install --roles desktop,wacom # a role plus an extra module +# ./install # re-apply the saved selection +# +# Roles live in roles/, modules in modules/ (see lib/roles.sh). The +# selection is saved to $XDG_CONFIG_HOME/dotfiles/roles so later runs need +# no flags. Any other argument is forwarded to dotbot itself (e.g. -v, -Q). +# Safe to re-run: links are relinked and pre-existing real files backed up. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +. "$REPO_ROOT/lib/common.sh" +# shellcheck source=lib/roles.sh +. "$REPO_ROOT/lib/roles.sh" + +DOTBOT="$REPO_ROOT/dotbot/bin/dotbot" +PLUGIN_DIR="$REPO_ROOT/lib/dotbot-plugins" +CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +HOST_ENV="$CONFIG_HOME/dotfiles/host.env" + +parse_targets "$@" +mapfile -t MODULES < <(resolve_modules "${TARGETS[@]}") +check_requires "${MODULES[@]}" + +cd "$REPO_ROOT" +git submodule sync --quiet --recursive +git submodule update --init --recursive + +# Per-machine values (DPI, redshift location, network interface, ...) are +# merged from three layers into one generated file that templates and +# scripts read; later layers win. hosts/defaults.env is tracked and complete, +# hosts/.env is tracked for machines it's fine to describe in +# public, and dotfiles/local.env is the untracked seam for a private overlay. +write_host_env() { + local host layer + host="$(hostname -s)" + mkdir -p "$(dirname "$HOST_ENV")" + { + printf '# Generated by ./install on %s - edit the layers, not this file:\n' "$(date '+%F %T')" + printf '# %s/hosts/defaults.env\n# %s/hosts/%s.env\n# %s/dotfiles/local.env\n' \ + "$REPO_ROOT" "$REPO_ROOT" "$host" "$CONFIG_HOME" + for layer in "hosts/defaults.env" "hosts/$host.env" "$CONFIG_HOME/dotfiles/local.env"; do + [ -f "$layer" ] || continue + printf '\n# --- %s\n' "$layer" + cat "$layer" + done + } >"$HOST_ENV.tmp" + mv "$HOST_ENV.tmp" "$HOST_ENV" +} +write_host_env +export DOTFILES_HOST_ENV="$HOST_ENV" + +log "Modules: ${MODULES[*]}" +for m in "${MODULES[@]}"; do + conf="modules/$m/install.conf.yaml" + [ -f "$conf" ] || continue + log "module $m" + # -d: sources in the manifest are relative to the module directory + "$DOTBOT" -d "$REPO_ROOT/modules/$m" -c "$conf" --plugin-dir "$PLUGIN_DIR" \ + ${PASSTHROUGH[@]+"${PASSTHROUGH[@]}"} done -cd "$BASEDIR" -git -C "$DOTBOT_DIR" submodule sync --quiet --recursive -git submodule update --init --recursive "$DOTBOT_DIR" +# Repo-local only: points this clone's hooks at the tracked identity guard +# (a relative hooksPath resolves against the repo root). Other repos on the +# machine are untouched. +git config core.hooksPath modules/git/hooks -"${BASEDIR}/${DOTBOT_DIR}/${DOTBOT_BIN}" -d "$BASEDIR" -c "$CONFIG" ${DOTBOT_ARGS[@]+"${DOTBOT_ARGS[@]}"} -if "$DESKTOP"; then - "${BASEDIR}/${DOTBOT_DIR}/${DOTBOT_BIN}" -d "$BASEDIR" -c "$DESKTOP_CONFIG" ${DOTBOT_ARGS[@]+"${DOTBOT_ARGS[@]}"} -fi +save_targets "${TARGETS[@]}" +log "Done. Selection saved to $ROLES_FILE." diff --git a/install-desktop.conf.yaml b/install-desktop.conf.yaml deleted file mode 100644 index 8304b20..0000000 --- a/install-desktop.conf.yaml +++ /dev/null @@ -1,42 +0,0 @@ -# Desktop-environment half of the install: the bspwm/sxhkd/polybar/picom/ -# dunst stack plus the X11-only leaf configs and scripts (Xresources, -# swapescape, wacominit, lockscreen, clipimg). `./install` runs this config -# after install.conf.yaml unless invoked with --no-desktop, so shell-only -# machines (e.g. remote boxes) skip everything in this file. -# -# Links are relinked and existing regular paths are backed up so install -# converges without deleting a pre-existing file or directory. -- defaults: - link: - relink: true - backup: true - create: true - -- link: - ~/.config/bspwm: desktop-environment/bspwm - ~/.config/sxhkd: desktop-environment/sxhkd - ~/.config/polybar: desktop-environment/polybar - ~/.config/picom/picom.conf: desktop-environment/picom.conf - ~/.config/dunst/dunstrc: desktop-environment/dunstrc - ~/.Xresources: Xresources - ~/.config/systemd/user/swapescape.service: systemd/swapescape.service - ~/.local/bin/wacominit: bin/wacominit - ~/.local/bin/lockscreen: bin/lockscreen - ~/.local/bin/clipimg: bin/clipimg - ~/.local/bin/desktop-session-log: bin/desktop-session-log - ~/.config/autostart/dotfiles-session-log.desktop: - create: true - path: desktop-environment/session/dotfiles-session-log.desktop - -- shell: - # redshift.conf is seeded by copy, never symlinked: each machine tunes - # its own lat/lon and temperatures afterwards, so repo updates to this - # file intentionally do not propagate to already-seeded machines. - - command: "[ ! -f ~/.config/redshift.conf ] && mkdir -p ~/.config && cp redshift.conf ~/.config/redshift.conf || true" - description: Copying redshift.conf if needed - quiet: true - # --locked: install exactly what uv.lock records; fails loudly if - # pyproject.toml and uv.lock have drifted apart. - - command: "cd desktop-environment/bspwm && uv sync --locked" - description: Building monitor-manager venv - quiet: false diff --git a/install.conf.yaml b/install.conf.yaml deleted file mode 100644 index 8ee4b0e..0000000 --- a/install.conf.yaml +++ /dev/null @@ -1,35 +0,0 @@ -# Shell/terminal/dev half of the install; `./install` always runs this -# config, and additionally install-desktop.conf.yaml (the bspwm/X11 stack) -# unless invoked with --no-desktop. -# -# Links are relinked and existing regular paths are backed up so install -# converges without deleting a pre-existing file or directory. -- defaults: - link: - relink: true - backup: true - create: true - -- link: - ~/.bash_aliases: bash/bash_aliases - ~/.bashrc: bash/bashrc - ~/.profile: bash/profile - ~/.inputrc: bash/inputrc - ~/.bazel_completions.bash: bash/bazel_completions.bash - ~/.gitconfig: git/gitconfig - ~/.gitmessage: git/gitmessage - ~/.git-prompt.sh: git/git-prompt.sh - ~/.config/lazygit/config.yml: git/lazygit.yml - ~/.config/starship.toml: starship/starship.toml - ~/.tmux: tmux - ~/.tmux.conf: tmux/tmux.conf - ~/.config/kitty: kitty - ~/.claude/CLAUDE.md: agents-config/user/AGENTS.md - ~/.claude/scripts/status-line.sh: claude/user/scripts/status-line.sh - -- shell: - - [git submodule update --init --recursive, Installing/updating submodules] - # Repo-local only: points this clone's hooks at the tracked git/hooks - # (relative paths resolve against the repo root). Other repos on the - # machine are untouched. - - [git config core.hooksPath git/hooks, Enabling identity-guard hooks for this repo] diff --git a/lib/common.sh b/lib/common.sh new file mode 100644 index 0000000..4a8d8bd --- /dev/null +++ b/lib/common.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# +# common.sh - logging and failure helpers shared by ./install, ./provision.sh, +# and everything under lib/. Source it; it is not executable on its own. +# +# Philosophy: fail early and loudly. Any failure is fatal, never +# warn-and-continue. + +log() { printf '\n==> [%s] %s\n' "$(date '+%F %T')" "$*"; } +skip() { printf ' [skip] %s\n' "$*"; } +note() { printf ' [note] %s\n' "$*"; } +die() { + printf ' [FATAL] %s\n' "$*" >&2 + exit 1 +} +have() { command -v "$1" >/dev/null 2>&1; } diff --git a/lib/dotbot-plugins/render.py b/lib/dotbot-plugins/render.py new file mode 100644 index 0000000..e35d1ab --- /dev/null +++ b/lib/dotbot-plugins/render.py @@ -0,0 +1,113 @@ +"""render - a dotbot directive for the few files that differ per machine. + +Symlinks carry everything in this repo except a handful of values that are +genuinely per-host (a panel's DPI, redshift's location, a wifi interface +name). Those files are templates: ``${NAME}`` placeholders substituted from +the generated ``$XDG_CONFIG_HOME/dotfiles/host.env`` (written by ./install +from hosts/defaults.env, hosts/.env, and the untracked +dotfiles/local.env), and the result is *copied* to its destination. + + - render: + ~/.Xresources: Xresources.tmpl + ~/.config/redshift.conf: + path: redshift.conf.tmpl + mode: "0644" + +Sources are relative to dotbot's base directory (the module), like ``link``. +A placeholder with no value is an error, not a blank - a silently empty DPI +is worse than a failed install. Rendering is idempotent: an unchanged +destination is left untouched and reported as such. +""" + +import os +import string +from typing import Any + +import dotbot + +HOST_ENV_VAR = "DOTFILES_HOST_ENV" + + +def _load_env(path: str) -> dict: + values: dict = {} + with open(path, encoding="utf-8") as fh: + for raw in fh: + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + values[key.strip()] = value.strip() + return values + + +class Render(dotbot.Plugin): + _directive = "render" + + def can_handle(self, directive: str) -> bool: + return directive == self._directive + + def handle(self, directive: str, data: Any) -> bool: + if directive != self._directive: + raise ValueError(f"Render cannot handle directive {directive}") + env_path = os.environ.get(HOST_ENV_VAR) or os.path.join( + os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"), + "dotfiles", + "host.env", + ) + if not os.path.isfile(env_path): + self._log.error(f"render: host env file not found: {env_path} (run ./install)") + return False + env = _load_env(env_path) + + base = self._context.base_directory() + ok = True + for dest, spec in data.items(): + if isinstance(spec, dict): + source = spec["path"] + mode = int(spec.get("mode", "0644"), 8) + else: + source = spec + mode = 0o644 + ok = self._render_one(base, source, dest, mode, env) and ok + if ok: + self._log.info("All templates rendered") + else: + self._log.error("Some templates could not be rendered") + return ok + + def _render_one(self, base: str, source: str, dest: str, mode: int, env: dict) -> bool: + src_path = os.path.join(base, source) + dest_path = os.path.abspath(os.path.expanduser(dest)) + try: + with open(src_path, encoding="utf-8") as fh: + template = fh.read() + except OSError as exc: + self._log.error(f"render: cannot read {src_path}: {exc}") + return False + try: + rendered = string.Template(template).substitute(env) + except KeyError as exc: + self._log.error(f"render: {source} needs {exc.args[0]}, which host.env does not define") + return False + except ValueError as exc: + self._log.error(f"render: {source}: {exc}") + return False + + # A destination that is a symlink (an earlier install linked the + # untemplated file) must be replaced, never written through. + if os.path.islink(dest_path): + os.unlink(dest_path) + elif os.path.isfile(dest_path): + with open(dest_path, encoding="utf-8") as fh: + if fh.read() == rendered and (os.stat(dest_path).st_mode & 0o777) == mode: + self._log.lowinfo(f"Up to date {dest}") + return True + + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + tmp_path = dest_path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as fh: + fh.write(rendered) + os.chmod(tmp_path, mode) + os.replace(tmp_path, dest_path) + self._log.lowinfo(f"Rendered {dest} <- {source}") + return True diff --git a/provision-lib.sh b/lib/provision-lib.sh similarity index 63% rename from provision-lib.sh rename to lib/provision-lib.sh index b6e54b7..c0f475d 100644 --- a/provision-lib.sh +++ b/lib/provision-lib.sh @@ -1,20 +1,15 @@ #!/usr/bin/env bash # -# provision-lib.sh - helpers shared by provision-shell.sh and -# desktop-environment/provision.sh. Source this from a script running +# provision-lib.sh - helpers shared by ./provision.sh and every +# modules/*/provision.sh. Source this from a script running # `set -euo pipefail`; it is not executable on its own. # # Philosophy: fail early and loudly. Every download is pinned to an exact # version and verified against a recorded sha256 before anything is # installed; any failure is fatal, never warn-and-continue. -log() { printf '\n==> [%s] %s\n' "$(date '+%F %T')" "$*"; } -skip() { printf ' [skip] %s\n' "$*"; } -die() { - printf ' [FATAL] %s\n' "$*" >&2 - exit 1 -} -have() { command -v "$1" >/dev/null 2>&1; } +# shellcheck source=common.sh +. "$(dirname "${BASH_SOURCE[0]}")/common.sh" # User-level installs go to ~/.local, ~/.nvm, etc.; sudo is used where needed. require_not_root() { @@ -31,6 +26,85 @@ init_provision_log() { exec > >(tee -a "$log_file") 2>&1 } +# ---------------------------------------------------------------------------- +# Platform. The release-tarball pins below are x86_64 builds and the package +# names are apt's, so that is what is supported; say so up front rather than +# failing partway through on a download that does not exist. +# ---------------------------------------------------------------------------- +os_id() { (. /etc/os-release && printf '%s' "${ID:-unknown}"); } +os_version_id() { (. /etc/os-release && printf '%s' "${VERSION_ID:-0}"); } +# Family, not distro: what decides the package manager and package names. +os_family() { + ( + . /etc/os-release + case " ${ID:-} ${ID_LIKE:-} " in + *" debian "* | *" ubuntu "*) printf 'debian' ;; + *" fedora "* | *" rhel "*) printf 'fedora' ;; + *" arch "*) printf 'arch' ;; + *) printf 'unknown' ;; + esac + ) +} +require_supported_platform() { + local arch family + arch="$(uname -m)" + [ "$arch" = "x86_64" ] || + die "unsupported architecture $arch: every release pin in modules/*/provision.sh is an x86_64 build" + family="$(os_family)" + [ "$family" = "debian" ] || + die "unsupported distro family '$family' ($(os_id)): package names here are apt's" +} + +# ---------------------------------------------------------------------------- +# Distro packages. pkg_ensure is the one to call: it installs only what is +# missing, so a converged machine never touches apt (or sudo) at all, and +# refreshes the package index once per provisioning run, on first need. +# ---------------------------------------------------------------------------- +pkg_installed() { + case "$(os_family)" in + debian) dpkg-query -W -f='${Status}' "$1" 2>/dev/null | grep -q 'install ok installed' ;; + *) return 1 ;; + esac +} +pkg_refresh() { + [ "${DOTFILES_PKG_REFRESHED:-0}" = "1" ] && return 0 + case "$(os_family)" in + debian) sudo apt-get update -qq ;; + *) die "pkg_refresh: unsupported distro family $(os_family)" ;; + esac + export DOTFILES_PKG_REFRESHED=1 +} +pkg_install() { + pkg_refresh + case "$(os_family)" in + debian) sudo apt-get install -y -qq "$@" ;; + *) die "pkg_install: unsupported distro family $(os_family)" ;; + esac +} +# usage: pkg_ensure ... +pkg_ensure() { + local p missing=() + for p in "$@"; do pkg_installed "$p" || missing+=("$p"); done + if [ "${#missing[@]}" -eq 0 ]; then + skip "packages present: $*" + return 0 + fi + pkg_install "${missing[@]}" +} +# usage: pkg_candidate_version -> the version apt would install, or +# nothing when the package is unknown. Lets a module take the distro package +# when it is new enough and source-build only where it is not. +pkg_candidate_version() { + case "$(os_family)" in + debian) apt-cache policy "$1" 2>/dev/null | awk '/Candidate:/ && $2 != "(none)" {print $2}' ;; + *) return 0 ;; + esac +} +# usage: version_ge +version_ge() { + [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -n1)" = "$2" ] +} + # usage: installed_version [version-arg] # Print a tool's installed version as x.y.z (PATH first, then ~/.local/bin, # which may not be on PATH yet during a fresh provision). Prints nothing when @@ -81,10 +155,8 @@ pin_satisfied() { skip "$cmd $cur already at pin" return 0 fi - # sort -V orders by version, so the pin sorting first means cur is newer - if [ "$(printf '%s\n%s\n' "$pin" "$cur" | sort -V | head -n1)" = "$pin" ]; then - printf ' [note] %s %s is newer than the pin (%s); keeping it\n' \ - "$cmd" "$cur" "$2" + if version_ge "$cur" "$pin"; then + note "$cmd $cur is newer than the pin ($2); keeping it" return 0 fi return 1 @@ -178,11 +250,45 @@ run_verified_installer() { rm -rf "$tmp" } +# usage: report_stale_copies +# A tool this repo manages can also exist elsewhere on PATH (old manual +# installs in /usr/local/bin, cargo, apt, or an earlier layout of these +# scripts). Flag every duplicate and say how to remove it. A duplicate that +# comes first on PATH is worse than clutter: it silently wins over the +# pinned copy, so `tool --version` reports the stale one and a re-install of +# the real thing looks like it did nothing. Returns 1 if anything was found. +report_stale_copies() { + local tool="$1" pinned="$2" pinned_real first_real path pkg rm_hint found=0 + [ -x "$pinned" ] || return 0 + # On a merged-usr system /bin and /sbin are symlinks into /usr, so one + # binary is reachable under two paths and would otherwise be reported as + # a duplicate of itself. Compare the resolved file, not the spelling. + pinned_real="$(readlink -f "$pinned")" + first_real="$(readlink -f "$(command -v "$tool")" 2>/dev/null)" + while IFS= read -r path; do + [ "$(readlink -f "$path")" = "$pinned_real" ] && continue + found=1 + # dpkg-owned copies (e.g. an old apt lsd/kitty) must go through apt, + # since deleting the file by hand leaves dpkg in an inconsistent state + rm_hint="sudo rm $path" + case "$path" in "$HOME"/*) rm_hint="rm $path" ;; esac + if pkg="$(dpkg -S "$path" 2>/dev/null | head -n1 | cut -d: -f1)" && [ -n "$pkg" ]; then + rm_hint="sudo apt-get remove $pkg" + fi + if [ "$first_real" = "$pinned_real" ]; then + note "stale $tool at $path (shadowed by $pinned; clean up with: $rm_hint)" + else + printf ' [WARN] %s on PATH resolves to %s, which shadows the pinned %s; remove it with: %s\n' \ + "$tool" "$path" "$pinned" "$rm_hint" + fi + done < <(type -aP "$tool" 2>/dev/null | awk '!seen[$0]++') + return "$found" +} + # ---------------------------------------------------------------------------- # Nerd Fonts (one release pins all per-font archives; sha256s from the -# release's published SHA-256.txt). Both halves install from this set: -# kitty uses JetBrainsMono (shell), polybar/dunst/rofi use all three -# (desktop), so the pins live here. +# release's published SHA-256.txt). kitty uses JetBrainsMono; the X11 module +# adds Iosevka and FantasqueSansMono for rofi, so the pins live here. # ---------------------------------------------------------------------------- NERD_FONTS_VERSION="v3.4.0" # shellcheck disable=SC2034 # consumed by the scripts that source this lib @@ -226,8 +332,9 @@ install_nerd_font() { } # ---------------------------------------------------------------------------- -# uv is needed by both halves (shell dev tooling; building the bspwm -# monitor-manager venv), so its pin lives here as the single source of truth. +# uv is needed by more than one module (cli-tools; the X11 module installs +# the monitor-manager with it), so its pin lives here as the single source +# of truth. # ---------------------------------------------------------------------------- UV_VERSION="0.11.20" UV_SHA256="5de211d9278af365497d387e25316907b3b4a9f25b4476dd6dbf238d6f85cff3" diff --git a/lib/roles.sh b/lib/roles.sh new file mode 100644 index 0000000..acad33a --- /dev/null +++ b/lib/roles.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# +# roles.sh - turn a machine's role selection into an ordered module list. +# Sourced by ./install and ./provision.sh after lib/common.sh; REPO_ROOT +# must point at the repo. +# +# A module is a directory under modules/ holding one tool's config +# (install.conf.yaml, applied by dotbot) and/or the provisioning that makes +# that config work (provision.sh). A role is a file under roles/ naming the +# modules a kind of machine gets, one per line; a line "@other" pulls in +# another role's modules first, so roles nest (desktop = workstation + X11). +# +# The selection a machine was last installed with is saved to +# $XDG_CONFIG_HOME/dotfiles/roles, so a bare ./install or ./provision.sh +# re-applies the same thing - the flag is only needed the first time, or to +# change a machine's kind. + +ROLES_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles/roles" + +# Emit every module a role or module name expands to, in order, with +# repeats (a module reached through two roles) kept for the caller to drop. +_expand_target() { + local name="$1" line + if [ -f "$REPO_ROOT/roles/$name" ]; then + while IFS= read -r line || [ -n "$line" ]; do + line="${line%%#*}" + line="${line//[[:space:]]/}" + [ -n "$line" ] || continue + case "$line" in + @*) _expand_target "${line#@}" ;; + *) + [ -d "$REPO_ROOT/modules/$line" ] || + die "roles/$name names module '$line', but modules/$line does not exist" + printf '%s\n' "$line" + ;; + esac + done <"$REPO_ROOT/roles/$name" + elif [ -d "$REPO_ROOT/modules/$name" ]; then + printf '%s\n' "$name" + else + die "unknown role or module: '$name' (roles: $(list_roles | tr '\n' ' '))" + fi +} + +# usage: resolve_modules ... -> module names, one per line, +# first occurrence wins so every module is applied exactly once. +resolve_modules() { + local t + for t in "$@"; do _expand_target "$t"; done | awk '!seen[$0]++' +} + +list_roles() { + local f + for f in "$REPO_ROOT"/roles/*; do + [ -f "$f" ] && basename "$f" + done +} + +# usage: check_requires ... +# A module may ship a `requires` file naming modules it cannot work without +# (sxhkd's bindings launch kitty; desktop-session-log greps with rg). Refuse a +# selection that leaves one out, rather than discovering it from a hotkey +# that does nothing. +check_requires() { + local m r + for m in "$@"; do + [ -f "$REPO_ROOT/modules/$m/requires" ] || continue + while IFS= read -r r || [ -n "$r" ]; do + r="${r%%#*}" + r="${r//[[:space:]]/}" + [ -n "$r" ] || continue + printf '%s\n' "$@" | grep -qx "$r" || + die "module '$m' requires '$r', which the selected roles do not include" + done <"$REPO_ROOT/modules/$m/requires" + done +} + +# usage: parse_targets "$@" +# Fills TARGETS (roles/modules to apply) and PASSTHROUGH (unrecognised +# arguments, for the caller to forward or reject). --roles and --modules +# take comma-separated lists; --no-desktop is the pre-roles spelling of +# --roles workstation and still works. With no selection on the command +# line, the saved one is used. +parse_targets() { + TARGETS=() + PASSTHROUGH=() + local arg + while [ $# -gt 0 ]; do + arg="$1" + case "$arg" in + --roles=* | --modules=*) + IFS=',' read -ra _parts <<<"${arg#*=}" + TARGETS+=("${_parts[@]}") + ;; + --roles | --modules) + [ $# -ge 2 ] || die "$arg needs a value" + IFS=',' read -ra _parts <<<"$2" + TARGETS+=("${_parts[@]}") + shift + ;; + --no-desktop) + note "--no-desktop is the old name for --roles workstation" + TARGETS+=(workstation) + ;; + *) PASSTHROUGH+=("$arg") ;; + esac + shift + done + if [ "${#TARGETS[@]}" -eq 0 ] && [ -f "$ROLES_FILE" ]; then + mapfile -t TARGETS < <(grep -v '^\s*#' "$ROLES_FILE" | tr -s ' \t' '\n' | grep -v '^$') + [ "${#TARGETS[@]}" -gt 0 ] && note "using saved selection from $ROLES_FILE: ${TARGETS[*]}" + fi + [ "${#TARGETS[@]}" -gt 0 ] || + die "no roles selected: pass --roles [,] (available: $(list_roles | tr '\n' ' ')) or --modules [,...]" +} + +save_targets() { + mkdir -p "$(dirname "$ROLES_FILE")" + printf '%s\n' "$@" >"$ROLES_FILE" +} diff --git a/agents-config/user/AGENTS.md b/modules/agents/AGENTS.md similarity index 100% rename from agents-config/user/AGENTS.md rename to modules/agents/AGENTS.md diff --git a/modules/agents/install.conf.yaml b/modules/agents/install.conf.yaml new file mode 100644 index 0000000..fe7ac45 --- /dev/null +++ b/modules/agents/install.conf.yaml @@ -0,0 +1,13 @@ +# agents - harness-agnostic instructions for coding agents, linked to each +# harness's user-level path, plus the Claude Code status line. Nothing here +# installs an agent; that is done per machine. +- defaults: + link: + relink: true + backup: true + create: true + +- link: + ~/.claude/CLAUDE.md: AGENTS.md + ~/.codex/AGENTS.md: AGENTS.md + ~/.claude/scripts/status-line.sh: status-line.sh diff --git a/claude/user/scripts/status-line.sh b/modules/agents/status-line.sh similarity index 100% rename from claude/user/scripts/status-line.sh rename to modules/agents/status-line.sh diff --git a/bash/bash_aliases b/modules/bash/bash_aliases similarity index 100% rename from bash/bash_aliases rename to modules/bash/bash_aliases diff --git a/bash/bashrc b/modules/bash/bashrc similarity index 81% rename from bash/bashrc rename to modules/bash/bashrc index da6dad2..e03edd3 100644 --- a/bash/bashrc +++ b/modules/bash/bashrc @@ -10,6 +10,14 @@ # Ensure ~/.local/bin is on PATH for non-interactive shells too (dedup-guarded) [[ ":$PATH:" != *":$HOME/.local/bin:"* ]] && export PATH="$HOME/.local/bin:$PATH" +# Machine-local environment for every shell, interactive or not - work +# proxies, PATH additions, a different EDITOR. Untracked; the private +# overlay's seam for anything `ssh host cmd`, cron, or a systemd user unit +# must also see. Keep it idempotent: it runs once per shell, but a login +# shell reaches here via ~/.profile as well. +[ -f "${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles/env.sh" ] && + . "${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles/env.sh" + # If not running interactively, don't do anything [ "$PS1" = "" ] && return @@ -51,11 +59,6 @@ fi # sleep 10; alert alias alert='notify-send --urgency=low -i "$([ $? = 0 ] && echo terminal || echo error)" "$(history|tail -n1|sed -e '\''s/^\s*[0-9]\+\s*//;s/[;&|]\s*alert$//'\'')"' -# Alias definitions. -if [ -f ~/.bash_aliases ]; then - . ~/.bash_aliases -fi - # enable programmable completion features (you don't need to enable # this, if it's already enabled in /etc/bash.bashrc and /etc/profile # sources /etc/bash.bashrc). @@ -126,11 +129,19 @@ export EDITOR=nvim [ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env" # =================================================== -# lsd replaces coreutils ls everywhere (installed by provision-shell.sh) -alias ls='lsd --color=auto' -alias ll='lsd -alF' -alias la='lsd -A' -alias l='lsd -CF' +# lsd replaces coreutils ls (modules/cli-tools/provision.sh installs it); +# guarded so a shell on a machine that hasn't been provisioned keeps a +# working ls +if command -v lsd >/dev/null 2>&1; then + alias ls='lsd --color=auto' + alias ll='lsd -alF' + alias la='lsd -A' + alias l='lsd -CF' +else + alias ll='ls -alF' + alias la='ls -A' + alias l='ls -CF' +fi # fzf integration setup if command -v fzf >/dev/null 2>&1; then @@ -156,11 +167,13 @@ if [[ -t 1 && "$TERM" != "dumb" ]] && command -v starship &>/dev/null; then echo "" fi } - PROMPT_COMMAND="show_newline" + # Prepend rather than assign, so a PROMPT_COMMAND set earlier (env.sh, a + # terminal's shell integration) keeps running + PROMPT_COMMAND="show_newline${PROMPT_COMMAND:+;$PROMPT_COMMAND}" eval "$(starship init bash)" fi -# go: provision-shell.sh installs the pinned toolchain to ~/.local/go and +# go: modules/go/provision.sh installs the pinned toolchain to ~/.local/go and # symlinks go/gofmt into ~/.local/bin (already on PATH above), so only the # default GOPATH bin dir - where `go install` drops binaries - needs adding [[ -d "$HOME/go/bin" ]] && export PATH="$PATH:$HOME/go/bin" @@ -179,5 +192,13 @@ if command -v uv &>/dev/null; then fi # uv end -# Machine-local shell env (untracked; e.g. work-specific vars). No-op if absent. +# Aliases and functions, last so they can override anything the tool blocks +# above defined (ls, EDITOR, fzf bindings). ~/.bash_aliases ends by sourcing +# the untracked ~/.bash_aliases.local, the overlay's seam for the same. +if [ -f ~/.bash_aliases ]; then + . ~/.bash_aliases +fi + +# Machine-local interactive shell config (untracked; e.g. work-specific +# prompt tweaks). Runs after everything, so it wins. No-op if absent. [ -f "$HOME/.bashrc.local" ] && . "$HOME/.bashrc.local" diff --git a/bash/inputrc b/modules/bash/inputrc similarity index 100% rename from bash/inputrc rename to modules/bash/inputrc diff --git a/modules/bash/install.conf.yaml b/modules/bash/install.conf.yaml new file mode 100644 index 0000000..6fdaf45 --- /dev/null +++ b/modules/bash/install.conf.yaml @@ -0,0 +1,12 @@ +# bash - the shell itself. Sources are relative to this directory. +- defaults: + link: + relink: true + backup: true + create: true + +- link: + ~/.bashrc: bashrc + ~/.bash_aliases: bash_aliases + ~/.profile: profile + ~/.inputrc: inputrc diff --git a/bash/profile b/modules/bash/profile similarity index 100% rename from bash/profile rename to modules/bash/profile diff --git a/modules/bash/provision.sh b/modules/bash/provision.sh new file mode 100755 index 0000000..62b11d8 --- /dev/null +++ b/modules/bash/provision.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# bash/provision.sh - programmable completion, which bashrc loads when present. + +set -euo pipefail + +MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../../lib/provision-lib.sh +. "$MODULE_DIR/../../lib/provision-lib.sh" + +require_not_root + +log "bash-completion" +pkg_ensure bash-completion diff --git a/bash/bazel_completions.bash b/modules/bazel/bazel_completions.bash similarity index 100% rename from bash/bazel_completions.bash rename to modules/bazel/bazel_completions.bash diff --git a/modules/bazel/install.conf.yaml b/modules/bazel/install.conf.yaml new file mode 100644 index 0000000..d1f3928 --- /dev/null +++ b/modules/bazel/install.conf.yaml @@ -0,0 +1,9 @@ +# bazel - shell completion for bazel, sourced by bashrc when the file exists. +- defaults: + link: + relink: true + backup: true + create: true + +- link: + ~/.bazel_completions.bash: bazel_completions.bash diff --git a/bin/clipimg b/modules/cli-tools/bin/clipimg similarity index 100% rename from bin/clipimg rename to modules/cli-tools/bin/clipimg diff --git a/modules/cli-tools/install.conf.yaml b/modules/cli-tools/install.conf.yaml new file mode 100644 index 0000000..4770be4 --- /dev/null +++ b/modules/cli-tools/install.conf.yaml @@ -0,0 +1,12 @@ +# cli-tools - the shell's everyday tools are provisioned, not linked; the +# one file here is clipimg, the remote-side half of kitty's clipboard +# kitten (it runs on the machine you ssh'd into, so it belongs to every +# role, not just desktops). +- defaults: + link: + relink: true + backup: true + create: true + +- link: + ~/.local/bin/clipimg: bin/clipimg diff --git a/modules/cli-tools/provision.sh b/modules/cli-tools/provision.sh new file mode 100755 index 0000000..7f447af --- /dev/null +++ b/modules/cli-tools/provision.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# +# cli-tools/provision.sh - the CLI tools bashrc and bash_aliases assume: +# fzf, ripgrep, fd, lsd, lazygit, jq, uv, glab. Release-tarball tools are +# pinned (floor) + sha256; fd and ripgrep come from apt. + +set -euo pipefail + +MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../../lib/provision-lib.sh +. "$MODULE_DIR/../../lib/provision-lib.sh" + +require_not_root +mkdir -p "$HOME/.local/bin" + +# Pinned versions + sha256s of the exact artifacts downloaded below. Each pin +# is the minimum this repo's configs are known to work with, and what a fresh +# machine gets; running something newer is fine and provisioning says so +# instead of rolling it back. Raising a pin is a deliberate, reviewed change: +# update the version AND its sha256 (from the upstream release's published +# checksums), review the upstream diff, then re-run. Nothing here ever +# resolves "latest" at runtime (see CLAUDE.md). UV_VERSION lives in +# lib/provision-lib.sh, shared with the X11 module. +GLAB_VERSION="v1.112.0" +GLAB_SHA256="71eb77a13dd57f3add103e979b20dbd9f4730bcaf9501ae2e8ac14cb4585c707" +LAZYGIT_VERSION="v0.62.2" +LAZYGIT_SHA256="8b9a4c2d0969cbea92b45c956dd2a44e1ba76900c9df49f1c60984045ce77984" +FZF_VERSION="v0.73.1" # bashrc's `fzf --bash` integration needs >= 0.48.0 +FZF_SHA256="f3252c2c366bc1700d3c85781ec8c9695998927ac127870eb049ceea2d540f8a" +LSD_VERSION="v1.2.0" # not in 22.04's apt, so pinned like the other release tools +LSD_SHA256="57d3b5859254adcfb8374ce98159cca97a14959997d2ae1176d2cff59556d829" + +# Download and archive tooling the helpers in provision-lib.sh rely on, plus +# jq (agents/status-line.sh) and xz for .tar.xz release archives. +log "Base packages" +pkg_ensure curl wget unzip xz-utils jq + +# ---------------------------------------------------------------------------- +# apt-managed CLI tools: fd, ripgrep (distro versions, presence-checked) +# ---------------------------------------------------------------------------- +log "fd" +if have fd; then + skip "fd already installed" +else + pkg_ensure fd-find + ln -sf "$(command -v fdfind)" "$HOME/.local/bin/fd" +fi + +log "ripgrep" +if have rg; then skip "ripgrep already installed"; else + pkg_ensure ripgrep +fi + +# ---------------------------------------------------------------------------- +# uv (python package/venv manager; pin shared with the X11 module) +# ---------------------------------------------------------------------------- +install_uv + +# ---------------------------------------------------------------------------- +# glab (GitLab CLI). Installed from upstream's .deb rather than the tarball so +# it lands in /usr/bin - the same place a hand-run `dpkg -i` from the releases +# page puts it. With one copy on PATH, a manual update actually replaces the +# provisioned one instead of being silently shadowed by ~/.local/bin/glab, and +# because the pin is a floor, the newer copy then survives re-provisioning. +# ---------------------------------------------------------------------------- +log "glab $GLAB_VERSION" +# Migration off the old tarball layout: ~/.local/bin precedes /usr/bin on PATH, +# so a leftover copy there would shadow the .deb and make the pin check read +# the wrong binary. Remove it before checking the version. +if [ -e "$HOME/.local/bin/glab" ]; then + rm -f "$HOME/.local/bin/glab" + note "removed ~/.local/bin/glab left by the old tarball install" +fi +if ! pin_satisfied glab "$GLAB_VERSION"; then + install_release_deb \ + "https://gitlab.com/gitlab-org/cli/-/releases/$GLAB_VERSION/downloads/glab_${GLAB_VERSION#v}_linux_amd64.deb" \ + "$GLAB_SHA256" glab +fi + +# ---------------------------------------------------------------------------- +# Release-tarball tools: lsd, lazygit, fzf (pinned floor + sha256) +# ---------------------------------------------------------------------------- +log "lsd $LSD_VERSION" +if ! pin_satisfied lsd "$LSD_VERSION"; then + install_release_binary \ + "https://github.com/lsd-rs/lsd/releases/download/$LSD_VERSION/lsd-$LSD_VERSION-x86_64-unknown-linux-gnu.tar.gz" \ + "$LSD_SHA256" "lsd-$LSD_VERSION-x86_64-unknown-linux-gnu/lsd" lsd 1 +fi + +log "lazygit $LAZYGIT_VERSION" +if ! pin_satisfied lazygit "$LAZYGIT_VERSION"; then + install_release_binary \ + "https://github.com/jesseduffield/lazygit/releases/download/$LAZYGIT_VERSION/lazygit_${LAZYGIT_VERSION#v}_Linux_x86_64.tar.gz" \ + "$LAZYGIT_SHA256" "lazygit" lazygit +fi + +log "fzf $FZF_VERSION" +if ! pin_satisfied fzf "$FZF_VERSION"; then + install_release_binary \ + "https://github.com/junegunn/fzf/releases/download/$FZF_VERSION/fzf-${FZF_VERSION#v}-linux_amd64.tar.gz" \ + "$FZF_SHA256" "fzf" fzf +fi + +log "Checking for stale duplicate binaries" +stale=0 +for entry in \ + "uv:$HOME/.local/bin/uv" \ + "lazygit:$HOME/.local/bin/lazygit" \ + "fzf:$HOME/.local/bin/fzf" \ + "lsd:$HOME/.local/bin/lsd" \ + "glab:/usr/bin/glab"; do + report_stale_copies "${entry%%:*}" "${entry#*:}" || stale=1 +done +[ "$stale" -eq 1 ] || skip "no stale copies found" diff --git a/git/git-prompt.sh b/modules/git/git-prompt.sh similarity index 100% rename from git/git-prompt.sh rename to modules/git/git-prompt.sh diff --git a/git/gitconfig b/modules/git/gitconfig similarity index 63% rename from git/gitconfig rename to modules/git/gitconfig index 23b57be..300a757 100644 --- a/git/gitconfig +++ b/modules/git/gitconfig @@ -1,14 +1,6 @@ [core] editor = nvim -[include] - ; Machine-specific values live in the untracked ~/.gitconfig.local: - ; [user] name and email, and any credential helper, e.g. - ; [credential "https://"] - ; helper = - ; helper = !$HOME/.local/bin/glab auth git-credential - path = ~/.gitconfig.local - [log] abbrevCommit = true @@ -46,3 +38,12 @@ updateRefs = true [feature] manyFiles = true + +; Machine-specific values live in the untracked ~/.gitconfig.local: [user] +; name and email, credential helpers, and any override of the keys above - +; git takes the last value it reads, so this include stays at the bottom. +; [credential "https://"] +; helper = +; helper = !glab auth git-credential +[include] + path = ~/.gitconfig.local diff --git a/git/gitmessage b/modules/git/gitmessage similarity index 100% rename from git/gitmessage rename to modules/git/gitmessage diff --git a/git/hooks/identity-guard.sh b/modules/git/hooks/identity-guard.sh similarity index 100% rename from git/hooks/identity-guard.sh rename to modules/git/hooks/identity-guard.sh diff --git a/git/hooks/pre-commit b/modules/git/hooks/pre-commit similarity index 100% rename from git/hooks/pre-commit rename to modules/git/hooks/pre-commit diff --git a/git/hooks/pre-merge-commit b/modules/git/hooks/pre-merge-commit similarity index 100% rename from git/hooks/pre-merge-commit rename to modules/git/hooks/pre-merge-commit diff --git a/git/hooks/pre-push b/modules/git/hooks/pre-push similarity index 100% rename from git/hooks/pre-push rename to modules/git/hooks/pre-push diff --git a/modules/git/install.conf.yaml b/modules/git/install.conf.yaml new file mode 100644 index 0000000..3f604cc --- /dev/null +++ b/modules/git/install.conf.yaml @@ -0,0 +1,14 @@ +# git - config, commit template, the bash prompt helper, and lazygit. +# hooks/ is not linked anywhere: ./install points this clone's own +# core.hooksPath at it. +- defaults: + link: + relink: true + backup: true + create: true + +- link: + ~/.gitconfig: gitconfig + ~/.gitmessage: gitmessage + ~/.git-prompt.sh: git-prompt.sh + ~/.config/lazygit/config.yml: lazygit.yml diff --git a/git/lazygit.yml b/modules/git/lazygit.yml similarity index 100% rename from git/lazygit.yml rename to modules/git/lazygit.yml diff --git a/modules/git/provision.sh b/modules/git/provision.sh new file mode 100755 index 0000000..5348482 --- /dev/null +++ b/modules/git/provision.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# +# git/provision.sh - a current git. gitconfig enables feature.manyFiles, +# whose index format git older than 2.40 refuses to read, so on Ubuntu the +# git-core PPA is added ahead of the distro package. Other Debian-family +# distros take the distro package; check `git --version` there. + +set -euo pipefail + +MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../../lib/provision-lib.sh +. "$MODULE_DIR/../../lib/provision-lib.sh" + +require_not_root + +log "git" +if [ "$(os_id)" = "ubuntu" ]; then + if ! grep -rq "git-core/ppa" /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null; then + pkg_ensure software-properties-common + sudo add-apt-repository -y ppa:git-core/ppa + sudo apt-get update -qq + fi +fi +pkg_ensure git +git_version="$(installed_version git)" +version_ge "$git_version" 2.40 || + note "git $git_version is older than 2.40; gitconfig's feature.manyFiles index will not be readable by it" diff --git a/modules/go/provision.sh b/modules/go/provision.sh new file mode 100755 index 0000000..25d32f7 --- /dev/null +++ b/modules/go/provision.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# +# go/provision.sh - the go toolchain from a pinned upstream archive +# (~/.local/go, go/gofmt symlinked into ~/.local/bin). 22.04's golang-go is +# 1.18, far behind the toolchain any current module expects. Fetched from +# dl.google.com, which is where go.dev/dl's links resolve to, so this is +# upstream's own artifact. The archive is a self-contained GOROOT and the go +# command finds it by resolving its own symlink, so ~/.local/bin/go works +# without setting GOROOT. Binaries from `go install` land in ~/go/bin, which +# bashrc adds to PATH. + +set -euo pipefail + +MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../../lib/provision-lib.sh +. "$MODULE_DIR/../../lib/provision-lib.sh" + +require_not_root +mkdir -p "$HOME/.local/bin" + +# go sha256 is upstream's published checksum for this archive (go.dev/dl lists +# one per file). Upstream names .0 releases "go1.27", not "go1.27.0", so the +# pin is whatever `go version` prints minus the "go" prefix. +GO_VERSION="1.26.5" +GO_SHA256="5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053" + +log "go $GO_VERSION" +if ! pin_satisfied go "$GO_VERSION" version; then + install_release_bundle \ + "https://dl.google.com/go/go$GO_VERSION.linux-amd64.tar.gz" \ + "$GO_SHA256" go 1 go gofmt +fi +report_stale_copies go "$HOME/.local/bin/go" || true +report_stale_copies gofmt "$HOME/.local/bin/gofmt" || true diff --git a/kitty/current-theme.conf b/modules/kitty/config/current-theme.conf similarity index 100% rename from kitty/current-theme.conf rename to modules/kitty/config/current-theme.conf diff --git a/kitty/kitty.conf b/modules/kitty/config/kitty.conf similarity index 94% rename from kitty/kitty.conf rename to modules/kitty/config/kitty.conf index 1ffd893..9f60492 100644 --- a/kitty/kitty.conf +++ b/modules/kitty/config/kitty.conf @@ -95,3 +95,7 @@ cursor_trail_decay 0.1 0.4 cursor_trail_start_threshold 1 scrollbar scrolled window_drag_tolerance 2 + +# Machine-local overrides (untracked). Outside ~/.config/kitty because that +# directory is a symlink into the repo; globinclude tolerates its absence. +globinclude ~/.config/kitty.local.conf diff --git a/kitty/launch.sh b/modules/kitty/config/launch.sh similarity index 59% rename from kitty/launch.sh rename to modules/kitty/config/launch.sh index 2df0ae4..50f23e4 100755 --- a/kitty/launch.sh +++ b/modules/kitty/config/launch.sh @@ -1,8 +1,11 @@ #!/bin/bash -SOCKET_PATH="/tmp/kitty-$USER" -KITTY="$HOME/.local/bin/kitty" -font_size="12.0" +SOCKET_PATH="${XDG_RUNTIME_DIR:-/tmp}/kitty-$USER" +KITTY="$(command -v kitty || echo "$HOME/.local/bin/kitty")" +# The size kitty.conf sets; passed explicitly because --override below would +# otherwise reset it. Read from the config so there is one place to change it. +font_size="$(awk '$1 == "font_size" {print $2; exit}' "$HOME/.config/kitty/kitty.conf")" +font_size="${font_size:-12.0}" if [ ! -x "$KITTY" ]; then printf 'kitty executable not found: %s\n' "$KITTY" >&2 @@ -22,5 +25,6 @@ if [ -S "$SOCKET_PATH" ] && "$KITTY" @ --to "unix:$SOCKET_PATH" ls &>/dev/null; exec "$KITTY" @ --to "unix:$SOCKET_PATH" launch --type=os-window --cwd="$CWD" "$@" else rm -f "$SOCKET_PATH" - exec "$KITTY" --listen-on "unix:$SOCKET_PATH" --override "font_size=$font_size" --directory="$HOME" "$@" + [ "$CWD" = "current" ] && CWD="$PWD" + exec "$KITTY" --listen-on "unix:$SOCKET_PATH" --override "font_size=$font_size" --directory="$CWD" "$@" fi diff --git a/kitty/Aquarium Dark.conf b/modules/kitty/config/themes/aquarium_dark.conf similarity index 100% rename from kitty/Aquarium Dark.conf rename to modules/kitty/config/themes/aquarium_dark.conf diff --git a/kitty/themes/diff-frappe.conf b/modules/kitty/config/themes/diff-frappe.conf similarity index 100% rename from kitty/themes/diff-frappe.conf rename to modules/kitty/config/themes/diff-frappe.conf diff --git a/kitty/themes/diff-latte.conf b/modules/kitty/config/themes/diff-latte.conf similarity index 100% rename from kitty/themes/diff-latte.conf rename to modules/kitty/config/themes/diff-latte.conf diff --git a/kitty/themes/diff-macchiato.conf b/modules/kitty/config/themes/diff-macchiato.conf similarity index 100% rename from kitty/themes/diff-macchiato.conf rename to modules/kitty/config/themes/diff-macchiato.conf diff --git a/kitty/themes/diff-mocha.conf b/modules/kitty/config/themes/diff-mocha.conf similarity index 100% rename from kitty/themes/diff-mocha.conf rename to modules/kitty/config/themes/diff-mocha.conf diff --git a/kitty/themes/frappe.conf b/modules/kitty/config/themes/frappe.conf similarity index 100% rename from kitty/themes/frappe.conf rename to modules/kitty/config/themes/frappe.conf diff --git a/kitty/themes/latte.conf b/modules/kitty/config/themes/latte.conf similarity index 100% rename from kitty/themes/latte.conf rename to modules/kitty/config/themes/latte.conf diff --git a/kitty/themes/macchiato.conf b/modules/kitty/config/themes/macchiato.conf similarity index 100% rename from kitty/themes/macchiato.conf rename to modules/kitty/config/themes/macchiato.conf diff --git a/kitty/themes/mocha.conf b/modules/kitty/config/themes/mocha.conf similarity index 100% rename from kitty/themes/mocha.conf rename to modules/kitty/config/themes/mocha.conf diff --git a/kitty/themes/tokyonight_day.conf b/modules/kitty/config/themes/tokyonight_day.conf similarity index 100% rename from kitty/themes/tokyonight_day.conf rename to modules/kitty/config/themes/tokyonight_day.conf diff --git a/kitty/themes/tokyonight_moon.conf b/modules/kitty/config/themes/tokyonight_moon.conf similarity index 100% rename from kitty/themes/tokyonight_moon.conf rename to modules/kitty/config/themes/tokyonight_moon.conf diff --git a/kitty/themes/tokyonight_night.conf b/modules/kitty/config/themes/tokyonight_night.conf similarity index 100% rename from kitty/themes/tokyonight_night.conf rename to modules/kitty/config/themes/tokyonight_night.conf diff --git a/kitty/themes/tokyonight_storm.conf b/modules/kitty/config/themes/tokyonight_storm.conf similarity index 100% rename from kitty/themes/tokyonight_storm.conf rename to modules/kitty/config/themes/tokyonight_storm.conf diff --git a/kitty/zenmode.py b/modules/kitty/config/zenmode.py similarity index 100% rename from kitty/zenmode.py rename to modules/kitty/config/zenmode.py diff --git a/modules/kitty/install.conf.yaml b/modules/kitty/install.conf.yaml new file mode 100644 index 0000000..bc98555 --- /dev/null +++ b/modules/kitty/install.conf.yaml @@ -0,0 +1,12 @@ +# kitty - the whole config directory is linked, so kitty.conf's relative +# `include`s and `kitten zenmode.py` resolve inside it. A machine-local +# ~/.config/kitty/local.conf would land in the repo, so the overlay seam is +# ~/.config/kitty.local.conf instead (see kitty.conf). +- defaults: + link: + relink: true + backup: true + create: true + +- link: + ~/.config/kitty: config diff --git a/modules/kitty/provision.sh b/modules/kitty/provision.sh new file mode 100755 index 0000000..2a53461 --- /dev/null +++ b/modules/kitty/provision.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# +# kitty/provision.sh - the terminal emulator (upstream binary bundle -> +# ~/.local/kitty.app, kitty/kitten symlinked into ~/.local/bin) and the font +# kitty.conf names. Pinned rather than apt: 22.04's kitty (0.21) is too old +# for this repo's kitty.conf and kittens, and the X11 module's sxhkd +# bindings and monitor-switch.sh hard-depend on the binary. +# +# This is a client-side module: it belongs on machines someone sits at, not +# on servers reached over ssh (the ssh kitten ships its own remote helper). + +set -euo pipefail + +MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../../lib/provision-lib.sh +. "$MODULE_DIR/../../lib/provision-lib.sh" + +require_not_root +mkdir -p "$HOME/.local/bin" + +# kitty publishes no checksum file (it signs with GPG); this sha256 was +# computed from the downloaded release artifact when the pin was set. +KITTY_VERSION="0.47.4" +KITTY_SHA256="bc230142b2bd27f2a4bf1b1b67575f3d397a4ea2cc83f4ac2b912c306a939693" + +# fontconfig: fc-cache for the Nerd Font below. fonts-symbola: covers +# Miscellaneous Technical symbols that neither the Nerd Fonts nor Noto Color +# Emoji carry. U+23F5 in particular is excluded from the RGI emoji set, so +# nothing else on a clean install provides it and Claude Code's +# permission-mode indicators render as tofu in the terminal. +log "Font packages" +pkg_ensure fontconfig fonts-symbola + +log "kitty $KITTY_VERSION" +if ! pin_satisfied kitty "$KITTY_VERSION"; then + install_release_bundle \ + "https://github.com/kovidgoyal/kitty/releases/download/v$KITTY_VERSION/kitty-$KITTY_VERSION-x86_64.txz" \ + "$KITTY_SHA256" kitty.app 0 kitty kitten + # Desktop integration, per kitty's install docs: launcher entries with + # absolute Exec/Icon paths (the bundle's .desktop files assume kitty is on + # the system PATH), and xdg-terminal-exec registration. These embed $HOME, + # so they're generated here rather than symlinked by dotbot. + mkdir -p "$HOME/.local/share/applications" "$HOME/.config" + cp "$HOME/.local/kitty.app/share/applications/kitty.desktop" \ + "$HOME/.local/kitty.app/share/applications/kitty-open.desktop" \ + "$HOME/.local/share/applications/" + sed -i \ + -e "s|Icon=kitty|Icon=$HOME/.local/kitty.app/share/icons/hicolor/256x256/apps/kitty.png|g" \ + -e "s|Exec=kitty|Exec=$HOME/.local/kitty.app/bin/kitty|g" \ + "$HOME/.local/share/applications/kitty.desktop" \ + "$HOME/.local/share/applications/kitty-open.desktop" + echo 'kitty.desktop' >"$HOME/.config/xdg-terminals.list" +fi + +# kitty.conf's font_family is "JetBrainsMono Nerd Font Mono" +install_nerd_font JetBrainsMono "$NERD_FONT_JETBRAINSMONO_SHA256" + +report_stale_copies kitty "$HOME/.local/bin/kitty" || true diff --git a/modules/node/provision.sh b/modules/node/provision.sh new file mode 100755 index 0000000..621fa1b --- /dev/null +++ b/modules/node/provision.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# +# node/provision.sh - nvm (pinned installer + sha256) and the current node +# LTS. bashrc loads nvm from the same NVM_DIR this script installs to. + +set -euo pipefail + +MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../../lib/provision-lib.sh +. "$MODULE_DIR/../../lib/provision-lib.sh" + +require_not_root + +NVM_VERSION="v0.40.3" +NVM_INSTALL_SHA256="2d8359a64a3cb07c02389ad88ceecd43f2fa469c06104f92f98df5b6f315275f" + +# Must match bashrc's NVM_DIR logic exactly, or provisioning installs node +# somewhere the shell never looks. +if [[ -z "${XDG_CONFIG_HOME-}" ]]; then + export NVM_DIR="$HOME/.nvm" +else + export NVM_DIR="$XDG_CONFIG_HOME/nvm" +fi +log "nvm $NVM_VERSION + node LTS (NVM_DIR=$NVM_DIR)" +if [ ! -s "$NVM_DIR/nvm.sh" ]; then + mkdir -p "$NVM_DIR" + # PROFILE=/dev/null: bashrc already has its own nvm block; never let the + # installer append one to the repo-symlinked ~/.bashrc. + PROFILE=/dev/null run_verified_installer \ + "https://raw.githubusercontent.com/nvm-sh/nvm/$NVM_VERSION/install.sh" \ + "$NVM_INSTALL_SHA256" +else + skip "nvm already installed" +fi +# shellcheck disable=SC1091 +. "$NVM_DIR/nvm.sh" || die "failed to load $NVM_DIR/nvm.sh" +if ! nvm ls --no-colors lts/* >/dev/null 2>&1; then + NVM_SYMLINK_CURRENT=true nvm install --lts || die "nvm install --lts failed" +else + skip "node LTS already installed" +fi diff --git a/modules/nvim/provision.sh b/modules/nvim/provision.sh new file mode 100755 index 0000000..1d95d27 --- /dev/null +++ b/modules/nvim/provision.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# +# nvim/provision.sh - neovim and what its config (a separate repo) builds +# with: a pinned rust toolchain (mason builds native extensions with cargo) +# and the tree-sitter CLI, source-built at a pinned version. bashrc's +# EDITOR, gitconfig's difftool/mergetool, and lazygit's edit command all +# assume nvim. + +set -euo pipefail + +MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../../lib/provision-lib.sh +. "$MODULE_DIR/../../lib/provision-lib.sh" + +require_not_root +mkdir -p "$HOME/.local/bin" + +# neovim publishes no checksum file; this sha256 was computed from the +# downloaded release artifact when the pin was set, so every machine gets a +# byte-identical copy of what was reviewed. +NVIM_VERSION="v0.12.3" +NVIM_SHA256="c441b547142860bf01bcce39e36cbed185c41112813e15443b16e5237750724d" +# rustup-init sha256 is upstream's published checksum for this archive version +RUSTUP_VERSION="1.29.0" +RUSTUP_INIT_SHA256="4acc9acc76d5079515b46346a485974457b5a79893cfb01112423c89aeb5aa10" +RUST_TOOLCHAIN="1.96.1" +TREE_SITTER_VERSION="0.26.10" + +# libclang-dev: the tree-sitter CLI source build pulls in rquickjs-sys, whose +# bindgen build step needs libclang.so at compile time. +log "Build packages" +pkg_ensure build-essential pkg-config libclang-dev + +# ---------------------------------------------------------------------------- +# neovim (upstream bundle -> ~/.local/nvim.app). 22.04's apt neovim (0.6) is +# far too old for a current config. +# ---------------------------------------------------------------------------- +log "neovim $NVIM_VERSION" +if ! pin_satisfied nvim "$NVIM_VERSION"; then + install_release_bundle \ + "https://github.com/neovim/neovim/releases/download/$NVIM_VERSION/nvim-linux-x86_64.tar.gz" \ + "$NVIM_SHA256" nvim.app 1 nvim +fi + +# ---------------------------------------------------------------------------- +# rust toolchain (rustup, pinned) +# ---------------------------------------------------------------------------- +log "rust $RUST_TOOLCHAIN (rustup $RUSTUP_VERSION)" +export CARGO_HOME="${CARGO_HOME:-$HOME/.cargo}" +if [ -x "$CARGO_HOME/bin/rustup" ]; then + skip "rustup already installed" +else + tmp="$(mktemp -d)" + fetch_url \ + "https://static.rust-lang.org/rustup/archive/$RUSTUP_VERSION/x86_64-unknown-linux-gnu/rustup-init" \ + "$tmp/rustup-init" || + die "download failed: rustup-init $RUSTUP_VERSION" + verify_sha256 "$tmp/rustup-init" "$RUSTUP_INIT_SHA256" + chmod +x "$tmp/rustup-init" + # --no-modify-path: bashrc already sources ~/.cargo/env; never let the + # installer edit the repo-symlinked shell files. + "$tmp/rustup-init" -y -q --no-modify-path --profile minimal \ + --default-toolchain "$RUST_TOOLCHAIN" || die "rustup-init failed" + rm -rf "$tmp" +fi +# shellcheck disable=SC1091 +. "$CARGO_HOME/env" || die "failed to load $CARGO_HOME/env" +if rustup toolchain list | grep -q "^$RUST_TOOLCHAIN"; then + skip "rust $RUST_TOOLCHAIN toolchain present" +else + rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal || + die "rust $RUST_TOOLCHAIN toolchain install failed" +fi + +# ---------------------------------------------------------------------------- +# tree-sitter CLI (nvim-treesitter needs >= 0.26.1). Built from source at a +# pinned version: upstream prebuilts are compiled against glibc 2.39 (Ubuntu +# 24.04) and die with "GLIBC_2.39 not found" on 22.04. cargo verifies every +# crate against the crates.io registry checksums; --locked uses the crate's +# committed Cargo.lock. Installs to ~/.cargo/bin (on PATH via ~/.cargo/env). +# ---------------------------------------------------------------------------- +log "tree-sitter CLI $TREE_SITTER_VERSION (source build)" +if ! pin_satisfied tree-sitter "$TREE_SITTER_VERSION"; then + cargo "+$RUST_TOOLCHAIN" install tree-sitter-cli \ + --version "$TREE_SITTER_VERSION" --locked --force || + die "tree-sitter-cli $TREE_SITTER_VERSION build failed" +fi + +report_stale_copies nvim "$HOME/.local/bin/nvim" || true diff --git a/modules/starship/install.conf.yaml b/modules/starship/install.conf.yaml new file mode 100644 index 0000000..879fd30 --- /dev/null +++ b/modules/starship/install.conf.yaml @@ -0,0 +1,9 @@ +# starship - prompt theming; bashrc runs `starship init` when the binary exists. +- defaults: + link: + relink: true + backup: true + create: true + +- link: + ~/.config/starship.toml: starship.toml diff --git a/modules/starship/provision.sh b/modules/starship/provision.sh new file mode 100755 index 0000000..cc2a0e9 --- /dev/null +++ b/modules/starship/provision.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# +# starship/provision.sh - the prompt binary, pinned (floor) + sha256. + +set -euo pipefail + +MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../../lib/provision-lib.sh +. "$MODULE_DIR/../../lib/provision-lib.sh" + +require_not_root +mkdir -p "$HOME/.local/bin" + +STARSHIP_VERSION="v1.25.1" +STARSHIP_SHA256="4488c11ca632327d1f1f16fb2f102c0646094c35479cd5435991385da43c61ac" + +log "starship $STARSHIP_VERSION" +if ! pin_satisfied starship "$STARSHIP_VERSION"; then + install_release_binary \ + "https://github.com/starship/starship/releases/download/$STARSHIP_VERSION/starship-x86_64-unknown-linux-gnu.tar.gz" \ + "$STARSHIP_SHA256" "starship" starship +fi +report_stale_copies starship "$HOME/.local/bin/starship" || true diff --git a/starship/starship.toml b/modules/starship/starship.toml similarity index 96% rename from starship/starship.toml rename to modules/starship/starship.toml index 64773e5..9180df3 100644 --- a/starship/starship.toml +++ b/modules/starship/starship.toml @@ -193,8 +193,3 @@ symbol = " " [zig] symbol = " " - -[custom.foxdev] -command = "echo [foxdev]" -when = true -format = " $output " diff --git a/tmux/plugins/nord-tmux b/modules/tmux/config/plugins/nord-tmux similarity index 100% rename from tmux/plugins/nord-tmux rename to modules/tmux/config/plugins/nord-tmux diff --git a/tmux/plugins/tmux-sensible b/modules/tmux/config/plugins/tmux-sensible similarity index 100% rename from tmux/plugins/tmux-sensible rename to modules/tmux/config/plugins/tmux-sensible diff --git a/tmux/plugins/tpm b/modules/tmux/config/plugins/tpm similarity index 100% rename from tmux/plugins/tpm rename to modules/tmux/config/plugins/tpm diff --git a/tmux/settings.conf b/modules/tmux/config/settings.conf similarity index 100% rename from tmux/settings.conf rename to modules/tmux/config/settings.conf diff --git a/tmux/tmux.conf b/modules/tmux/config/tmux.conf similarity index 74% rename from tmux/tmux.conf rename to modules/tmux/config/tmux.conf index 520fcc1..bce6e08 100644 --- a/tmux/tmux.conf +++ b/modules/tmux/config/tmux.conf @@ -5,5 +5,8 @@ set -g @plugin 'tmux-plugins/tpm' set -g @plugin 'arcticicestudio/nord-tmux' set -g @plugin 'tmux-plugins/tmux-sensible' +# Machine-local overrides (untracked; may add @plugin lines). No-op if absent. +source -q ~/.tmux.conf.local + # Initialize TMUX plugin manager (keep this line at the very bottom) run '~/.tmux/plugins/tpm/tpm' diff --git a/modules/tmux/install.conf.yaml b/modules/tmux/install.conf.yaml new file mode 100644 index 0000000..ff17a69 --- /dev/null +++ b/modules/tmux/install.conf.yaml @@ -0,0 +1,11 @@ +# tmux - config plus the plugin checkouts (submodules under config/plugins), +# so ~/.tmux is the whole config directory. +- defaults: + link: + relink: true + backup: true + create: true + +- link: + ~/.tmux: config + ~/.tmux.conf: config/tmux.conf diff --git a/modules/tmux/provision.sh b/modules/tmux/provision.sh new file mode 100755 index 0000000..64be627 --- /dev/null +++ b/modules/tmux/provision.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# tmux/provision.sh - the distro tmux; tmux.conf needs nothing newer. + +set -euo pipefail + +MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../../lib/provision-lib.sh +. "$MODULE_DIR/../../lib/provision-lib.sh" + +require_not_root + +log "tmux" +pkg_ensure tmux diff --git a/modules/typst/provision.sh b/modules/typst/provision.sh new file mode 100755 index 0000000..a515df5 --- /dev/null +++ b/modules/typst/provision.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# +# typst/provision.sh - typst from a pinned upstream archive. Not packaged for +# 22.04 at all, and upstream's own instructions are "unpack the archive, put +# it on PATH" - the same thing, with the download pinned and verified. Only a +# musl build is published for linux x86_64; it is statically linked, so it +# runs whatever the host glibc is. `typst update` self-updates the binary in +# place, and because the pin is a floor, a copy updated that way is kept and +# noted rather than rolled back. + +set -euo pipefail + +MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../../lib/provision-lib.sh +. "$MODULE_DIR/../../lib/provision-lib.sh" + +require_not_root +mkdir -p "$HOME/.local/bin" + +# typst publishes no checksum file; this sha256 was computed from the reviewed +# download. `typst --version` prints the release tag's commit alongside the +# version (0.15.1 -> 9dfd3a08), which is a second check that the installed +# binary is built from the tag that was reviewed. +TYPST_VERSION="0.15.1" +TYPST_SHA256="a6d077d0a95eed5a2eba715b2dae06be954f624ccbf85758a03f389ded33118c" + +log "typst $TYPST_VERSION" +if ! pin_satisfied typst "$TYPST_VERSION"; then + install_release_binary \ + "https://github.com/typst/typst/releases/download/v$TYPST_VERSION/typst-x86_64-unknown-linux-musl.tar.xz" \ + "$TYPST_SHA256" "typst-x86_64-unknown-linux-musl/typst" typst 1 +fi +report_stale_copies typst "$HOME/.local/bin/typst" || true diff --git a/modules/wacom/install.conf.yaml b/modules/wacom/install.conf.yaml new file mode 100644 index 0000000..1db8623 --- /dev/null +++ b/modules/wacom/install.conf.yaml @@ -0,0 +1,10 @@ +# wacom - one tablet's xsetwacom mapping. Hardware-specific, so it is not in +# any role; hosts that have the tablet add it: ./install --roles desktop,wacom +- defaults: + link: + relink: true + backup: true + create: true + +- link: + ~/.local/bin/wacominit: wacominit diff --git a/modules/wacom/provision.sh b/modules/wacom/provision.sh new file mode 100755 index 0000000..282e089 --- /dev/null +++ b/modules/wacom/provision.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# wacom/provision.sh - the X input driver wacominit's xsetwacom talks to. + +set -euo pipefail + +MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../../lib/provision-lib.sh +. "$MODULE_DIR/../../lib/provision-lib.sh" + +require_not_root + +log "wacom driver" +pkg_ensure xserver-xorg-input-wacom diff --git a/modules/wacom/requires b/modules/wacom/requires new file mode 100644 index 0000000..e181da0 --- /dev/null +++ b/modules/wacom/requires @@ -0,0 +1 @@ +x11 diff --git a/bin/wacominit b/modules/wacom/wacominit similarity index 100% rename from bin/wacominit rename to modules/wacom/wacominit diff --git a/modules/x11/Xresources.tmpl b/modules/x11/Xresources.tmpl new file mode 100644 index 0000000..604d1f8 --- /dev/null +++ b/modules/x11/Xresources.tmpl @@ -0,0 +1,4 @@ +! Rendered by ./install from ~/.config/dotfiles/host.env (DPI); edit the +! host layer, not ~/.Xresources. +Xcursor.size: 24 +Xft.dpi: ${DPI} diff --git a/bin/desktop-session-log b/modules/x11/bin/desktop-session-log similarity index 100% rename from bin/desktop-session-log rename to modules/x11/bin/desktop-session-log diff --git a/bin/lockscreen b/modules/x11/bin/lockscreen similarity index 100% rename from bin/lockscreen rename to modules/x11/bin/lockscreen diff --git a/desktop-environment/bspwm/bspwmrc b/modules/x11/bspwm/bspwmrc similarity index 86% rename from desktop-environment/bspwm/bspwmrc rename to modules/x11/bspwm/bspwmrc index 815b278..01f661f 100755 --- a/desktop-environment/bspwm/bspwmrc +++ b/modules/x11/bspwm/bspwmrc @@ -9,15 +9,27 @@ # 5. Start sxhkd LAST (ensures all key grabs succeed) # Resolve the repo dir from this script's own (symlinked) location so a move -# of the repo can't leave a stale hard-coded path behind. ~/.config/bspwm is a -# Dotbot symlink into the repo; readlink -f follows it to the real bspwm/ dir. +# of the repo can't leave a stale hard-coded path behind. ~/.config/bspwm/bspwmrc +# is a Dotbot symlink into the repo; readlink -f follows it to the real bspwm/ dir. BSPWM_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}" LOG_FILE="$STATE_DIR/bspwm/bspwm.log" SXHKD_LOG="$STATE_DIR/sxhkd/sxhkd.log" SESSION_LOGGER="$HOME/.local/bin/desktop-session-log" +MONITOR_MANAGER="$(command -v monitor-manager || echo "$HOME/.local/bin/monitor-manager")" mkdir -p "$STATE_DIR/bspwm" "$STATE_DIR/sxhkd" +# Per-machine values (polybar's hardware ids, the wifi fallback interface), +# merged by ./install from hosts/*.env and the private overlay. Exported so +# polybar's ${env:...} lookups and the scripts sxhkd runs all see them. +HOST_ENV="${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles/host.env" +if [ -f "$HOST_ENV" ]; then + set -a + # shellcheck source=/dev/null + . "$HOST_ENV" + set +a +fi + log() { echo "[$(date +%s.%N)] $1" >>"$LOG_FILE" } @@ -94,11 +106,11 @@ xrandr --query | awk '/ disconnected [0-9]+x[0-9]+\+/ {print $1}' | while read - xrandr --output "$out" --off done -# 2b. Apply monitor profile -PYTHON="$BSPWM_DIR/.venv/bin/python" - +# 2b. Apply monitor profile (monitor-manager is installed by +# modules/x11/provision.sh as a uv tool; profiles come from +# ~/.config/bspwm/profiles plus the overlay's profiles.d) log "Getting best profile..." -profile=$("$PYTHON" monitor-manager.py match --best 2>>"$LOG_FILE") +profile=$("$MONITOR_MANAGER" match --best 2>>"$LOG_FILE") match_exit=$? log "Profile match returned (exit=$match_exit): $profile" @@ -108,7 +120,7 @@ if [ $match_exit -ne 0 ] || [ -z "$profile" ]; then notify-send -u critical "Monitor Manager" "No matching profile - fallback" else log "Applying profile: $profile" - "$PYTHON" monitor-manager.py apply-all --profile "$profile" --force >>"$LOG_FILE" 2>&1 + "$MONITOR_MANAGER" apply-all --profile "$profile" --force >>"$LOG_FILE" 2>&1 apply_exit=$? if [ $apply_exit -ne 0 ]; then log "ERROR: apply-all failed (exit=$apply_exit), using fallback" diff --git a/desktop-environment/bspwm/profiles/defaults.yaml b/modules/x11/bspwm/profiles/defaults.yaml similarity index 100% rename from desktop-environment/bspwm/profiles/defaults.yaml rename to modules/x11/bspwm/profiles/defaults.yaml diff --git a/desktop-environment/bspwm/profiles/personal-home.yaml b/modules/x11/bspwm/profiles/personal-home.yaml similarity index 97% rename from desktop-environment/bspwm/profiles/personal-home.yaml rename to modules/x11/bspwm/profiles/personal-home.yaml index aa3b2e4..1136f8a 100644 --- a/desktop-environment/bspwm/profiles/personal-home.yaml +++ b/modules/x11/bspwm/profiles/personal-home.yaml @@ -6,7 +6,7 @@ detection: # Laptop display (present but disabled) laptop: output: eDP-1 - edid: "fdd376fe3ab6edb7" # sha256(full EDID hex)[:16] -- see bspwm/lib/edid.py + edid: "fdd376fe3ab6edb7" # sha256(full EDID hex)[:16] -- see apps/monitor-manager/monitor_manager/edid.py alias: laptop # External monitors (output names are informational; matching is EDID-based) diff --git a/desktop-environment/bspwm/profiles/personal-solo.yaml b/modules/x11/bspwm/profiles/personal-solo.yaml similarity index 95% rename from desktop-environment/bspwm/profiles/personal-solo.yaml rename to modules/x11/bspwm/profiles/personal-solo.yaml index 2083465..2a40efd 100644 --- a/desktop-environment/bspwm/profiles/personal-solo.yaml +++ b/modules/x11/bspwm/profiles/personal-solo.yaml @@ -7,7 +7,7 @@ detection: laptop: output: eDP-1 alias: laptop - edid: "fdd376fe3ab6edb7" # sha256(full EDID hex)[:16] -- see bspwm/lib/edid.py + edid: "fdd376fe3ab6edb7" # sha256(full EDID hex)[:16] -- see apps/monitor-manager/monitor_manager/edid.py # No external monitors required for this profile monitors: [] diff --git a/desktop-environment/bspwm/profiles/work-laptop-home.yaml b/modules/x11/bspwm/profiles/work-laptop-home.yaml similarity index 100% rename from desktop-environment/bspwm/profiles/work-laptop-home.yaml rename to modules/x11/bspwm/profiles/work-laptop-home.yaml diff --git a/desktop-environment/bspwm/profiles/work-laptop-woodinville-clamshell.yaml b/modules/x11/bspwm/profiles/work-laptop-woodinville-clamshell.yaml similarity index 100% rename from desktop-environment/bspwm/profiles/work-laptop-woodinville-clamshell.yaml rename to modules/x11/bspwm/profiles/work-laptop-woodinville-clamshell.yaml diff --git a/desktop-environment/bspwm/profiles/work-laptop-woodinville.yaml b/modules/x11/bspwm/profiles/work-laptop-woodinville.yaml similarity index 100% rename from desktop-environment/bspwm/profiles/work-laptop-woodinville.yaml rename to modules/x11/bspwm/profiles/work-laptop-woodinville.yaml diff --git a/desktop-environment/bspwm/scripts/apply-auto.sh b/modules/x11/bspwm/scripts/apply-auto.sh similarity index 82% rename from desktop-environment/bspwm/scripts/apply-auto.sh rename to modules/x11/bspwm/scripts/apply-auto.sh index f8a7aee..9341da3 100755 --- a/desktop-environment/bspwm/scripts/apply-auto.sh +++ b/modules/x11/bspwm/scripts/apply-auto.sh @@ -16,12 +16,7 @@ if [ -f "$LOG" ]; then fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -cd "$PROJECT_ROOT" || { - notify-send -u critical "monitor-manager" "apply-auto: cannot cd to $PROJECT_ROOT" - exit 1 -} +MONITOR_MANAGER="$(command -v monitor-manager || echo "$HOME/.local/bin/monitor-manager")" # Keep polybar's network module on the interface carrying the default route; # docking can move it, and the bars are relaunched below either way. @@ -31,7 +26,7 @@ network_env { echo "=== apply-auto run at $(date) ===" - "$HOME/.local/bin/uv" run python monitor-manager.py apply-all --force + "$MONITOR_MANAGER" apply-all --force } >>"$LOG" 2>&1 if [ $? -ne 0 ]; then diff --git a/modules/x11/bspwm/scripts/monitor-switch.sh b/modules/x11/bspwm/scripts/monitor-switch.sh new file mode 100755 index 0000000..bd8f134 --- /dev/null +++ b/modules/x11/bspwm/scripts/monitor-switch.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Opens monitor-manager's interactive profile picker in a kitty window. +# Designed to be called from sxhkd or other hotkey daemons. + +set -euo pipefail + +MONITOR_MANAGER="$(command -v monitor-manager || echo "$HOME/.local/bin/monitor-manager")" + +# Use kitty launch script (handles font size based on resolution) +# --hold keeps the window open after the picker exits +~/.config/kitty/launch.sh --title "Monitor Manager" --hold "$MONITOR_MANAGER" interactive diff --git a/desktop-environment/bspwm/scripts/monocle-border.sh b/modules/x11/bspwm/scripts/monocle-border.sh similarity index 100% rename from desktop-environment/bspwm/scripts/monocle-border.sh rename to modules/x11/bspwm/scripts/monocle-border.sh diff --git a/desktop-environment/bspwm/scripts/network-env.sh b/modules/x11/bspwm/scripts/network-env.sh similarity index 87% rename from desktop-environment/bspwm/scripts/network-env.sh rename to modules/x11/bspwm/scripts/network-env.sh index 5fe283f..0a73bb3 100644 --- a/desktop-environment/bspwm/scripts/network-env.sh +++ b/modules/x11/bspwm/scripts/network-env.sh @@ -12,7 +12,9 @@ network_env() { local iface label iface="$(ip route show default 2>/dev/null | awk 'NR == 1 {print $5}')" - iface="${iface:-wlp9s0}" + # Fallback when no default route is up yet: the host's wifi interface + # from host.env, exported by bspwmrc. + iface="${iface:-${NETWORK_FALLBACK_IFACE:-wlan0}}" if [ -d "/sys/class/net/$iface/wireless" ]; then unset NETWORK_LABEL diff --git a/desktop-environment/bspwm/scripts/smart_focus.sh b/modules/x11/bspwm/scripts/smart_focus.sh similarity index 100% rename from desktop-environment/bspwm/scripts/smart_focus.sh rename to modules/x11/bspwm/scripts/smart_focus.sh diff --git a/desktop-environment/bspwm/scripts/smart_resize.sh b/modules/x11/bspwm/scripts/smart_resize.sh similarity index 100% rename from desktop-environment/bspwm/scripts/smart_resize.sh rename to modules/x11/bspwm/scripts/smart_resize.sh diff --git a/desktop-environment/bspwm/scripts/smart_send.sh b/modules/x11/bspwm/scripts/smart_send.sh similarity index 100% rename from desktop-environment/bspwm/scripts/smart_send.sh rename to modules/x11/bspwm/scripts/smart_send.sh diff --git a/desktop-environment/bspwm/scripts/toggle_polybar.sh b/modules/x11/bspwm/scripts/toggle_polybar.sh similarity index 100% rename from desktop-environment/bspwm/scripts/toggle_polybar.sh rename to modules/x11/bspwm/scripts/toggle_polybar.sh diff --git a/desktop-environment/dunstrc b/modules/x11/dunstrc similarity index 99% rename from desktop-environment/dunstrc rename to modules/x11/dunstrc index 1e13044..3410b6f 100644 --- a/desktop-environment/dunstrc +++ b/modules/x11/dunstrc @@ -254,10 +254,10 @@ # Context-menu picker. rofi's dmenu mode, since rofi is already part of # this stack and plain dmenu isn't provisioned. - dmenu = /usr/bin/rofi -dmenu -p dunst: + dmenu = rofi -dmenu -p dunst: # Browser for opening urls in context menu. - browser = /usr/bin/xdg-open + browser = xdg-open # Always run rule-defined scripts, even if the notification is suppressed always_run_script = true diff --git a/modules/x11/install.conf.yaml b/modules/x11/install.conf.yaml new file mode 100644 index 0000000..61e7033 --- /dev/null +++ b/modules/x11/install.conf.yaml @@ -0,0 +1,28 @@ +# x11 - the bspwm + sxhkd + polybar stack, picom, dunst, and the X11-only +# leaf configs and scripts. ~/.config/bspwm is a real directory with the +# tracked pieces linked into it, so a private overlay can drop extra monitor +# profiles into ~/.config/bspwm/profiles.d/ without touching the repo. +- defaults: + link: + relink: true + backup: true + create: true + +- link: + ~/.config/bspwm/bspwmrc: bspwm/bspwmrc + ~/.config/bspwm/scripts: bspwm/scripts + ~/.config/bspwm/profiles: bspwm/profiles + ~/.config/sxhkd: sxhkd + ~/.config/polybar: polybar + ~/.config/picom/picom.conf: picom.conf + ~/.config/dunst/dunstrc: dunstrc + ~/.config/systemd/user/swapescape.service: swapescape.service + ~/.local/bin/lockscreen: bin/lockscreen + ~/.local/bin/desktop-session-log: bin/desktop-session-log + ~/.config/autostart/dotfiles-session-log.desktop: session/dotfiles-session-log.desktop + +# Per-machine values (DPI, redshift location) come from host.env; these are +# rendered copies, refreshed on every install, not symlinks. +- render: + ~/.Xresources: Xresources.tmpl + ~/.config/redshift.conf: redshift.conf.tmpl diff --git a/desktop-environment/picom.conf b/modules/x11/picom.conf similarity index 100% rename from desktop-environment/picom.conf rename to modules/x11/picom.conf diff --git a/desktop-environment/polybar/shades/bars.ini b/modules/x11/polybar/shades/bars.ini similarity index 99% rename from desktop-environment/polybar/shades/bars.ini rename to modules/x11/polybar/shades/bars.ini index 01beb82..4767dd5 100644 --- a/desktop-environment/polybar/shades/bars.ini +++ b/modules/x11/polybar/shades/bars.ini @@ -94,7 +94,7 @@ type = internal/backlight ; Use the following command to list available cards: ; $ ls -1 /sys/class/backlight/ -card = intel_backlight +card = ${env:BACKLIGHT_CARD:intel_backlight} ;card = amdgpu_bl0 ; Available tags: @@ -141,8 +141,8 @@ full-at = 99 ; Use the following command to list batteries and adapters: ; $ ls -1 /sys/class/power_supply/ -battery = BAT0 -adapter = AC +battery = ${env:BATTERY:BAT0} +adapter = ${env:ADAPTER:AC} ; If an inotify event haven't been reported in this many ; seconds, manually poll for new values. diff --git a/desktop-environment/polybar/shades/colors.ini b/modules/x11/polybar/shades/colors.ini similarity index 100% rename from desktop-environment/polybar/shades/colors.ini rename to modules/x11/polybar/shades/colors.ini diff --git a/desktop-environment/polybar/shades/config.ini b/modules/x11/polybar/shades/config.ini similarity index 100% rename from desktop-environment/polybar/shades/config.ini rename to modules/x11/polybar/shades/config.ini diff --git a/desktop-environment/polybar/shades/modules.ini b/modules/x11/polybar/shades/modules.ini similarity index 97% rename from desktop-environment/polybar/shades/modules.ini rename to modules/x11/polybar/shades/modules.ini index 87bfe9f..ca9b73d 100644 --- a/desktop-environment/polybar/shades/modules.ini +++ b/modules/x11/polybar/shades/modules.ini @@ -329,8 +329,8 @@ full-at = 99 ; Use the following command to list batteries and adapters: ; $ ls -1 /sys/class/power_supply/ -battery = BAT0 -adapter = AC +battery = ${env:BATTERY:BAT0} +adapter = ${env:ADAPTER:AC} ; If an inotify event haven't been reported in this many ; seconds, manually poll for new values. @@ -430,22 +430,15 @@ animation-charging-framerate = 750 ;; _-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_ -; If you use both a wired and a wireless network, just add 2 module definitions. For example -[module/wired-network] -type = internal/network -interface = eth0 - -[module/wireless-network] -type = internal/network -interface = wlp3s0 - -; Normal Module +; custom/script rather than internal/network: scripts/network-label.sh +; follows the interface bspwmrc exports (NETWORK_INTERFACE) and resolves the +; live SSID itself, so roaming needs no bar restart. [module/network] type = custom/script exec = ~/.config/polybar/shades/scripts/network-label.sh format =