diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index a02907f..5faa340 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -72,28 +72,6 @@ buttons in the installer (VM gum screens under Boreal look legible — artifacts in `/tmp/nomarchy-v2-swap-93/`). Needs Bernardo's screenshot or an Acer repro to pin the actual widget before fixing. -### 113. Live session: offline theme switch rebuilds the world from source - -Surfaced by #99's evidence run (2026-07-13, `/tmp/nomarchy-v2-theme-99/`): -in the **live ISO** session, offline `nomarchy-theme-sync apply gruvbox` -fails — its `home-manager switch` tries to build **1176 derivations** from -the `stage0`/`hex0` bootstrap up, then dies on offline source fetches -(`vala`, `yasm`, `config.jsonc`, `ghostty-config`, `easyeffects.svg`, -`typogrify`…), ending `error: Build failed due to failed dependency`. This -contradicts docs/TESTING.md item 6 ("apply gruvbox → switch runs offline"). -The failing generation is `bvl30ggn…-home-manager-generation.drv`; it is -**not** the drv the ISO pins (the pin is the *default*-theme -`homeConfigurations.${username}.activationPackage`, i.e. Boreal — confirmed -drv-identical to the failing one only for gruvbox state, so a non-default -theme's generation is unpinned). NOT caused by #99's git-seed: the drv is -byte-identical path-seeded vs git-seeded. **Decide the contract:** either -pin every baked theme's live activationPackage into the ISO (size cost) or -scope the offline-switch promise to the shipped theme + correct -TESTING.md/MEMORY. Pass = a fresh live ISO either switches to any baked -theme offline, or fails with a clear "needs network for this theme" message -and accurate docs. **Not caused by anything in today's batch** — separate -pre-existing ISO-pinning gap. - ## NEXT ### 122. A crisper Nomarchy logo in fastfetch @@ -283,14 +261,6 @@ scoped implementation/verification tasks before work starts. Pass = every current route has one deliberate home, navigation remains shallow, and no root-level entry is added or lost. -### 106. Internal menu Back/Left navigation contract - -Reproduce the reported internal leaf with neither Back nor Left behavior, then -fix any breach of the already-intended navigation contract. Add a permanent -guard covering every internal leaf; external GUIs and free-text prompts retain -their explicit Esc exceptions. Pass = no internal leaf can strand the user and -the guard fails on a regression. - ### 108. Keybindings menu presentation and completeness audit Group the menu as Window, Workspace, Menu, and Media, then prove every live @@ -328,16 +298,6 @@ generation, System boot generation, and Files/root BTRFS scopes. Pass = labels state what is restored and from where before action, with destructive or reboot effects made clear and each existing recovery path represented once. -### 112. Installer disk-picker safety - -Exclude floppy, pseudo, tiny, and otherwise non-installable devices such as -`/dev/fd0`; never select them by default. **Reproduced in QEMU 2026-07-13** -(#93's V2 run): with OVMF the guest exposes `/dev/fd0` and the picker listed -it FIRST — a blind Enter selected it (artifacts: -`/tmp/nomarchy-v2-swap-93/20-review.png`, `Disk: /dev/fd0 (WILL BE ERASED)`). Pass = the real install disk is clear, -invalid devices cannot reach destructive setup, a permanent guard covers the -filter/default logic, and a pre-destructive KVM run proves the picker behavior. - ## LATER - **Wallpapers artifact split** (ROADMAP § Faster switches — decided, diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index 185beed..cb2142a 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -19,6 +19,19 @@ Template: --- +## 2026-07-15 — Fix batch #112 disk picker, #106 menu Back, #113 offline theme +- **Task:** Second fix-only batch from NEXT/NOW residuals. +- **Did:** (112) `list_installable_disks` excludes fd/loop/sr/zram/tiny + (<8 GiB), sorts largest-first; installer-safety markers. (106) + `tools/check-menu-back.py` + `checks.menu-back` (proved to fail when Back + stripped). (113) offline theme contract: TESTING item 6 scoped to default/ + current theme; `run_switch` prints clear offline-oriented message on + network/build failures. +- **Verified:** V0 flake check; installer-safety; menu-back prove-fail; + py_compile theme-sync. +- **Pending:** V2 unattended install with OVMF fd0 present (optional). +- **Next suggestion:** #108 keybinds audit, or #94 test-install chromium. + ## 2026-07-15 — Fix batch #123–#126 (install bake, flake ref, ISO name, display) - **Task:** Fix-only NOW batch: #123 dconf pre-activate, #124 main flake seed, #125 ISO filename, #126 display mode list. diff --git a/agent/MEMORY.md b/agent/MEMORY.md index 39502de..cbcd2ab 100644 --- a/agent/MEMORY.md +++ b/agent/MEMORY.md @@ -137,6 +137,11 @@ iteration would otherwise rediscover. without a hierarchy pass. ## Gotchas (cost a debugging session once) +- **OVMF exposes /dev/fd0 as TYPE=disk (#112):** disk picker must exclude + `/dev/fd*` and tiny sizes; sort largest-first or blind Enter picks floppy. +- **Live ISO offline theme switch (#113):** only the *default/pinned* HM + generation is offline-safe; other presets may try to build the world — + document the contract and fail with a network-oriented message. - **Installer HM pre-activate needs XDG_RUNTIME_DIR (+ session bus) (#123):** `runuser … activate` inside `nixos-enter` has no user session. Without `mkdir -p /run/user/$UID` owned by the install user and diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7a1de6d..3956652 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -404,6 +404,11 @@ Design/decision records and a running log of shipped work (items marked decision rather than a drive-by. Proved to fail: dropping snapshot makes it name the missing entry. **V3 pending** — that the apps *launch* needs real hardware (HARDWARE-QUEUE, Acer M5-481T). +- ✓ **Fix batch #112 / #106 / #113:** installer disk picker drops fd0/loop/ + optical/<8 GiB and lists largest-first (OVMF no longer defaults to floppy); + `checks.menu-back` guards every internal rofi list emits ↩ Back; offline + theme-switch promise scoped to the live-pinned (default) theme with a clear + error when a non-pinned preset needs network. - ✓ **Install fix batch (#123–#126):** (123) HM pre-activate creates `/run/user/$UID` + `dbus-run-session` so dconf no longer aborts the bake; (124) main-built ISOs seed `?ref=main` and installer actually rewrites diff --git a/docs/TESTING.md b/docs/TESTING.md index 279a7d7..6f04d43 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -56,8 +56,11 @@ session may not start. Hyprland via `initial_session`; logging out lands on tuigreet. - The flake is seeded writable at `~/.nomarchy` (from the read-only `/etc/nomarchy` copy) — `$NOMARCHY_PATH` already points there. -- Locked flake inputs are pinned into the ISO store, so - `home-manager switch` works **without a network**. +- Locked flake inputs are pinned into the ISO store, so a + `home-manager switch` for the **already-active / default theme** works + **without a network**. Switching to an arbitrary other preset can still + need downloads (that preset's HM generation is not in the pin) — the + tool should fail with an offline-oriented message (#113). ## 3. Verification checklist @@ -70,7 +73,7 @@ Work through these in order; each one exercises a different layer. | 3 | `SUPER+Return` opens Kitty with the default theme colors (Boreal) | terminal default, ANSI palette | | 4 | `btop` in the terminal is themed | per-theme asset baking | | 5 | `nomarchy-theme-sync list` prints 24 presets | package, baked themes dir | -| 6 | `nomarchy-theme-sync apply gruvbox` → state written, `home-manager switch` runs, desktop re-themes, wallpaper changes | the whole engine: state write, pure eval, HM rebuild, wallpaper hook | +| 6 | `nomarchy-theme-sync apply boreal` (or re-apply the **current** theme) offline → switch succeeds. Applying a *different* preset may need a network — the ISO pins the default theme's generation, not every preset (#113) | state write + offline rebuild contract | | 7 | `SUPER+SHIFT+T` cycles wallpapers instantly (try `tokyo-night` for 4, or `boreal` for its set) | the runtime wallpaper path | | 8 | `nomarchy-theme-sync apply summer-night` → after the switch the bar has its own identity (light bar, different styling) | whole-swap waybar.css assets | | 9 | Open a GTK app — dark theme matching the palette | Stylix layer | diff --git a/flake.nix b/flake.nix index 5c14a73..d640e4c 100644 --- a/flake.nix +++ b/flake.nix @@ -456,7 +456,8 @@ # Installer safety guards (item 54): offline compose-lock fail-closed # (no network flake lock), disk-signature warn regex, pre-activate - # recovery hint path, password floors. Pure source+regex contract. + # recovery hint path, password floors, disk-picker filter (#112). + # Pure source+regex contract. installer-safety = pkgs.runCommand "nomarchy-installer-safety" { nativeBuildInputs = [ pkgs.python3 ]; } '' @@ -467,6 +468,15 @@ touch $out ''; + # Menu Back/Left contract (#106): every internal rofi_menu list + # emits ↩ Back (external modi / free-text keep Esc exceptions). + menu-back = pkgs.runCommand "nomarchy-menu-back" + { nativeBuildInputs = [ pkgs.python3 ]; } + '' + python3 ${./tools/check-menu-back.py} ${./modules/home/rofi.nix} + touch $out + ''; + # Installer keyboard no-variant regression (item 91): gum's `(none)` # row is display-only. Exercise it through the real template # patcher, then build the exact us + empty-variant console keymap diff --git a/pkgs/nomarchy-install/nomarchy-install.sh b/pkgs/nomarchy-install/nomarchy-install.sh index cebadf4..27d3ed6 100644 --- a/pkgs/nomarchy-install/nomarchy-install.sh +++ b/pkgs/nomarchy-install/nomarchy-install.sh @@ -224,13 +224,43 @@ live_disk="" live_src=$(findmnt -no SOURCE /iso 2>/dev/null || true) [[ -n "$live_src" ]] && live_disk=$(lsblk -no PKNAME "$live_src" 2>/dev/null || true) -mapfile -t disks < <(lsblk -dpno NAME,SIZE,MODEL,TYPE \ - | awk -v skip="/dev/${live_disk:-NONE}" \ - '$NF == "disk" && $1 != skip && $1 !~ /loop|zram|sr[0-9]/ {NF--; print}') -[[ ${#disks[@]} -gt 0 ]] || fail "No installable disks found." +# Installable whole disks only (#112): drop floppy/pseudo/tiny devices that +# OVMF and some firmwares expose as TYPE=disk (e.g. /dev/fd0 first in the +# list — a blind Enter would erase nothing useful and break the install). +# Bytes via lsblk -b; 8 GiB floor catches fd0/USB crumbs; REQUIREMENTS still +# recommend ≥40 GiB for a real install. +# NOMARCHY_MIN_DISK_BYTES overrides the floor (tests / expert installs). +MIN_DISK_BYTES="${NOMARCHY_MIN_DISK_BYTES:-$((8 * 1024 * 1024 * 1024))}" +list_installable_disks() { + local skip="/dev/${live_disk:-NONE}" + local name size type model hsize + # NAME SIZE TYPE in bytes (-b); MODEL looked up separately (may contain spaces). + { + while read -r name size type; do + [[ "$type" == "disk" ]] || continue + [[ "$name" == "$skip" ]] && continue + case "$name" in + /dev/loop*|/dev/zram*|/dev/sr*|/dev/fd*|/dev/ram*|/dev/nbd*|/dev/md*) + continue ;; + esac + [[ "$size" =~ ^[0-9]+$ ]] || continue + (( size >= MIN_DISK_BYTES )) || continue + hsize=$(lsblk -dno SIZE "$name" 2>/dev/null || echo "?") + model=$(lsblk -dno MODEL "$name" 2>/dev/null | tr -s '[:space:]' ' ' | sed 's/^ //;s/ $//') + printf '%s %s %s\n' "$name" "$hsize" "${model:-}" + done < <(lsblk -dpbno NAME,SIZE,TYPE 2>/dev/null) + } | sort -k2 -h -r # largest first — never default to a tiny leftover +} + +mapfile -t disks < <(list_installable_disks) +[[ ${#disks[@]} -gt 0 ]] || fail "No installable disks found (need a whole disk ≥ $(( MIN_DISK_BYTES / 1024 / 1024 / 1024 )) GiB; floppy/loop/optical excluded)." if [[ "$UNATTENDED" == "1" ]]; then TARGET_DISK="${NOMARCHY_DISK:?NOMARCHY_DISK required in unattended mode}" + # Unattended still rejects non-installable targets (CI footgun). + if ! printf '%s\n' "${disks[@]}" | grep -q "^${TARGET_DISK} "; then + fail "$TARGET_DISK is not an installable disk (missing, live medium, or below size floor)." + fi else choice=$(printf '%s\n' "${disks[@]}" \ | gum choose --header "Install Nomarchy on which disk? (EVERYTHING on it will be erased)") diff --git a/pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py b/pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py index 7296279..c452ed8 100644 --- a/pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py +++ b/pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py @@ -370,9 +370,23 @@ def run_switch() -> None: "Rebuild FAILED — scroll up for last lines; run nomarchy-doctor", urgency="critical", ) + # Live ISO pins only the *default* theme's HM generation. Applying + # another preset offline tries to build the world from source (#113). + offline_hint = "" + try: + import socket + socket.create_connection(("1.1.1.1", 53), timeout=1.5).close() + except OSError: + offline_hint = ( + " Offline? Only the live ISO's already-pinned theme is " + "guaranteed without a network — other themes need builds " + "or a binary cache. Connect and retry, or apply the default " + "theme (boreal)." + ) die( "rebuild failed (state already written; fix and re-run). " - "Diagnose: nomarchy-doctor. Recovery: docs/RECOVERY.md" + "Diagnose: nomarchy-doctor. Recovery: docs/RECOVERY.md." + + offline_hint ) notify("Changes applied ✓") # Waybar runs from Hyprland's exec-once (not a systemd unit HM would diff --git a/tools/check-install-safety.py b/tools/check-install-safety.py index 1d5600e..b0b3031 100755 --- a/tools/check-install-safety.py +++ b/tools/check-install-safety.py @@ -72,6 +72,25 @@ check( "missing online-only compose-lock fallback warn", ) +# ── 1b. Disk picker: exclude floppy/pseudo/tiny (#112) ───────────────── +check( + "list_installable_disks" in script, + "missing list_installable_disks helper (#112)", +) +check( + "/dev/fd*" in script or r"/dev/fd*" in script, + "disk filter must exclude /dev/fd* (floppy — OVMF lists fd0 first)", +) +check( + "MIN_DISK_BYTES" in script, + "disk filter must enforce a minimum size floor", +) +check( + "8 * 1024 * 1024 * 1024" in script or "8 * 1024**3" in script + or "$((8 * 1024 * 1024 * 1024))" in script, + "default min disk size should be 8 GiB (catches fd0 and crumbs)", +) + # ── 2. Disk signature warn ────────────────────────────────────────────── sig_re = re.search( r"grep -qiE '([^']+)'\s*<<<\s*\"\$existing_sig\"", diff --git a/tools/check-menu-back.py b/tools/check-menu-back.py new file mode 100644 index 0000000..8f9dd87 --- /dev/null +++ b/tools/check-menu-back.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Guard the nomarchy-menu Back/Left contract (BACKLOG #106). + +Every *internal* list picker built with `rofi_menu` must emit the shared +`↩ Back` row (via `back`, `printf … "$BACK"`, or a literal). External +modi/tools and free-text prompts are listed as exceptions — Esc is their +back path. + +Usage: check-menu-back.py +Pure — no rofi runtime. Fails on a list menu that can strand the user. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +if len(sys.argv) != 2: + sys.exit(f"usage: {sys.argv[0]} ") + +text = Path(sys.argv[1]).read_text(encoding="utf-8") + +# Strip Nix multi-line comments lightly? Keep simple — work on the whole file. +# Find `| rofi_menu` call sites (possibly with line breaks before `|`). +# Look at a window of lines before each call for BACK emission. + +lines = text.splitlines() + +# Exception markers: if the window mentions these, allow no BACK row. +EXCEPTION_MARKERS = ( + "External tool:", + "external modi", + "Esc closes", + "Esc is their back", + "free-text", + "Free-text", + "no list to", + "rofi_menu -p search", # web: empty stdin free-text + "rofi_menu -p ask", + # calc/emoji/network are exec-only without rofi_menu list construction +) + +def window_has_back(chunk: str) -> bool: + # Shared helpers emit ↩ Back: bare `back`, printf of $BACK, or a literal. + if re.search(r"(?m)^\s*back\s*$", chunk): + return True + if re.search(r"\bback\b", chunk) and "back()" not in chunk.split("rofi_menu")[0][-20:]: + # `back` as a command (not only the word in comments). Prefer line-start. + if re.search(r"(?m)^\s*back\b", chunk): + return True + if "$BACK" in chunk or r"\$BACK" in chunk or "↩ Back" in chunk: + return True + if re.search(r'printf[^\n]*BACK', chunk): + return True + return False + + +def window_is_exception(chunk: str) -> bool: + return any(m in chunk for m in EXCEPTION_MARKERS) + + +# Match lines that invoke rofi_menu as a picker (not the function definition). +invoke_re = re.compile(r"\brofi_menu\b") +bad: list[str] = [] +for i, line in enumerate(lines): + if "rofi_menu()" in line or "rofi_menu ()" in line: + continue + if not invoke_re.search(line): + continue + # Skip comments and the definition of the helper's docs. + stripped = line.lstrip() + if stripped.startswith("#"): + continue + # Window: up to 40 lines before this call (menu construction). + start = max(0, i - 40) + chunk = "\n".join(lines[start : i + 1]) + if window_is_exception(chunk): + continue + # Free-text: empty stdin piped in + if re.search(r"rofi_menu[^\n]*<\s*/dev/null", chunk) or re.search( + r"rofi_menu[^\n]*\n[^\n]*<\s*/dev/null", chunk + ): + continue + if not window_has_back(chunk): + bad.append(f"line {i + 1}: rofi_menu without ↩ Back in preceding construction:\n {line.strip()}") + +if bad: + print("check-menu-back: FAIL — internal list menus must end with ↩ Back:\n", file=sys.stderr) + for b in bad: + print(b, file=sys.stderr) + sys.exit(1) + +print(f"check-menu-back: ok — {sum(1 for l in lines if invoke_re.search(l) and 'rofi_menu()' not in l)} rofi_menu sites scanned")