# SysDeck — Release Notes Author: **Jeremy Anderson** · · --- ## v0.2.0 — 2026-08-20 (Master Edition: one tarball, two editions, Fester pre-integrated) v0.2.0 turns SysDeck into a single distributable that ships **both** editions with **Fester vendored and wired in**. The release answers the operator's framing directly: *"fester exists as a separate repository — it deserves its own. generate a master tarball of sysdeck with fester pre-integrated."* ### What ships in sysdeck-0.2.0-master.tar.bz2 - `/` — the cockpit edition, unchanged upstream layout: 26 plugins, bridge/, shared/, packaging, tests, docs. - `/web` — the NEW **SysDeck Web Edition**: a standalone Next.js 16 console (28 bridge modules) with real `/proc` + `/sys` collectors where the host allows, and clearly badged demo datasets where backends are absent. New panels: Overview landing view, Hardware Alerts (the orphaned bridge that never had a UI), and fully-built Mesh / Vault / Hardware Auth / Firmware. - `/web/mini-services/fester` — **Fester, vendored + pre-integrated**. Fester stays an independent project with its own version line (0.2.1); the master tarball pins a snapshot so no separate checkout is needed. ### Fester pre-integration (the cockpit side is real now) - `bridge/fester.py` — the v0.0.31 systemd-listing stub is gone. 11 real subcommands against the fester REST API (stdlib urllib, 4s timeout, `FESTER_URL` override, default `http://127.0.0.1:3010`): `status`, `metrics`, `builds`, `build `, `nodes`, `targets`, `timeline `, `sessions`, `start-build --project --targets [--no-cache] [--retries] [--fail-action]`, `cancel `, `replay `. Every subcommand degrades to actionable JSON when the service is down ("start it with `make fester-start`…"). - `plugins/sysdeck-fester/fester.js` — a real panel: service status card, stat grid (builds total/running/succeeded/failed, cache-hit rate), cluster nodes table, live+history builds table with state chips and row actions (Cancel, Replay → inline session id, Timeline → expandable event log), a start-build form built from the target catalog, 5s auto-refresh that preserves form state. - `shared/bridge.js` — the fester surface grew from 1 method to 11; `check-bridge-subcommands` now verifies **206 calls across 27 bridge modules** (was 195/26). ### Also fixed in this release - **The shipped 0.1.3 Makefile was broken.** Its recipes were indented with 8 spaces instead of tabs — GNU make rejects the file outright (`missing separator (did you mean TAB instead of 8 spaces?)`), so `make install` / `make dist` could not run from the released tarball. v0.2.0 restores tab indentation (the fix the v0.0.28 guard itself recommended) and `make -n` passes for every target. - **New Makefile targets**: `fester-start` (vendored fester service on :3010), `web-install` (bun + prisma setup for the web edition), `web-dev` (fester in the background + web console on :3000), `master` (rebuild the master tarball from the tree). - Version surfaces bumped 0.1.3 → 0.2.0 across all release surfaces (Makefile, `bridge/__init__.py`, setup.py, PKGBUILD, spec, debian/changelog, compat-manifest, metainfo). ### Verification - Cockpit tree guards: `python3 -m py_compile bridge/fester.py` clean; `node --check` clean on `fester.js` and `bridge.js`; `tests/check_bridge_subcommands.py` → 206/206 across 27 modules; `check-makefile-recipes` green (tabs restored); `make -n` parses for install / plugins / dist / master / web-dev / fester-start. - Live integration smoke against the running fester service: `start-build --project gentoo-stage3 --targets mipsel` → build `gentoo-stage3-mtw730qo` (7/7 actions, 7 CAS cache hits, 1050 ms critical path); a second build listed `running` then `cancel` → `{"ok": true}` (final state cancelled, 11 timeline events); `replay` → session `sess-tl6ax5-uvo7vh`. - Web edition end-to-end: all 28 modules clicked through with zero page errors and zero console errors; the Fester sub-app through the gateway shows live WS events, cluster nodes, and mid-flight builds; the theme engine re-tints the whole suite. - Master tarball assembled by `scripts/make-master-tarball.sh`; verified by extraction, file count, and sha256. ### Notes - Fester's version (0.2.1) is intentionally independent from SysDeck's (0.2.0) — it mirrors the separate-repo reality and lets the vendored snapshot track upstream releases without forcing a SysDeck release. - The web edition's Prisma-backed modules (mesh, vault, containers, mining, …) are demo-badged where the host lacks the backend — the badge is honest, the data shapes are real. ## v0.1.3 — 2026-08-19 (critical: import fixed with shutil.which + mkosi reads profile via temp symlink + download/manage UI) ### Theme: the import was silently empty and the build still wasn't reading the config v0.1.3 fixes two more critical bugs that v0.1.2 missed, and adds the download/manage UI the operator asked for. The operator's report: > *profile workstation still doesnt import current system pkgs. it > trys to build only 2. which is repetitive at this point.* Two root causes. Both are embarrassingly simple. ### Import bug: `from __init__ import` failed in the cockpit context `_detect_host_packages()` started with: ```python try: sys.path.insert(0, str(Path(__file__).resolve().parent)) from __init__ import DISTRO, PKG_MANAGER except Exception: DISTRO = "unknown" PKG_MANAGER = "unknown" ``` The `except Exception: PKG_MANAGER = "unknown"` was the silent killer. When the cockpit superuser channel runs the bridge helper, the Python path context is different — `from __init__ import` fails, the except clause silently sets `PKG_MANAGER = "unknown"`, and the function returns `([], "unknown")`. No error, no warning, just an empty list. The import call in `profile_import_packages` then wrote nothing (the empty-list early return), and the build used the profile's original 4 template packages — of which mkosi installed 2 (the base `iana-etc` and `filesystem`). **Fix:** use `shutil.which()` to find the binary directly: ```python pacman_bin = shutil.which("pacman") if pacman_bin: cmd = [pacman_bin, "-Qqe"] marker = "arch" ``` No import dependency. Works in any execution context. If `pacman` isn't on PATH, it tries `apt-mark`, then `dnf`. If none are found, returns `([], "unknown")` — but now the "unknown" is real, not a silent import failure. ### Build bug: `--include` doesn't replace the base config v0.1.2 added `--include ` to the mkosi command. I thought `--include` told mkosi "load this config file." It doesn't. mkosi's `--include` flag includes a **drop-in fragment** ON TOP OF the base `mkosi.conf`. The base config must still exist as `mkosi.conf` in the cwd. If it doesn't, mkosi uses defaults and the `--include` file is silently ignored. So for the operator's profile at `/etc/mkosi/mkosi.conf.d/arch-workstation.conf`: - mkosi runs in `/etc/mkosi/mkosi.conf.d/` - looks for `mkosi.conf` there — doesn't find one - uses empty defaults (2 base packages) - the `--include arch-workstation.conf` file is layered on top of nothing, effectively ignored **Fix:** create a temp directory, symlink the profile file as `mkosi.conf` inside it, and run mkosi from there: ```python def _prepare_mkosi_work_dir(profile): tmpdir = Path(tempfile.mkdtemp(prefix="sysdeck-mkosi-")) link = tmpdir / "mkosi.conf" link.symlink_to(Path(profile["path"]).resolve()) return tmpdir ``` Then `build()` sets `work_dir = tmpdir`, so mkosi's cwd is the temp dir. mkosi finds `mkosi.conf` (the symlink), follows it, reads the actual profile file. Works for ANY profile path — v0.0.x drop-ins, v0.1.0+ per-profile dirs, even profiles in random locations. The temp dir is cleaned up after the build finishes. ### New: download artifacts directly from the panel Each artifact in the Artifacts panel now has a ⬇ Download button. The implementation uses `cockpit.spawn(["cat", path], { superuser: "try", binary: true })` to read the file as a binary stream, collects the chunks into a `Blob`, creates an object URL, and triggers a browser download via a synthetic `` click. Works for files of any size (streamed, not loaded into memory all at once by the bridge — the JS side does collect chunks, but cockpit handles the transport efficiently). ### New: manage artifacts — delete per-file, clear per-profile Each artifact has a 🗑 button that calls `artifact-delete `. The Python side resolves the path safely (refuses path traversal outside `BUILDER_ARTIFACTS_DIR`) and `unlink()`s the file. Each profile's artifacts card has a 🗑 Clear all button that calls `artifacts-clear `. Removes the entire `/var/lib/sysdeck/builder/artifacts//` directory. Returns file count + bytes freed for the log. The card header now shows total size: `myarch artifacts (3, 1.2 GB)`. ### New: manage builds — delete state + log (+ optionally artifacts) Each build in the Builds table has a 🗑 button. Two-step confirm: 1. "Delete build record?" — OK = delete state + log only 2. If Cancel: "Also delete ALL artifacts for profile?" — OK = delete state + log + the profile's entire artifacts dir The Python `build-delete [--artifacts]` reads the state file FIRST (to get the profile name for artifact cleanup) before deleting it. Then deletes state + log. If `--artifacts`, also `shutil.rmtree()` the artifacts dir. ### Regression tests 11 new unit tests across two new test classes: - `TestBuilderArtifactManagement` (7 tests): - `test_artifact_delete_removes_file` - `test_artifact_delete_refuses_path_traversal` - `test_artifacts_clear_removes_all` - `test_build_delete_removes_state_and_log` - `test_build_delete_with_artifacts_flag_clears_artifacts_dir` - `test_build_delete_nonexistent_returns_error` - `test_new_subcommands_registered_in_commands` - `TestBuilderMkosiTempWorkDir` (3 tests): - `test_prepare_creates_temp_dir_with_mkosi_conf_symlink` - `test_prepare_returns_none_for_missing_profile_path` - `test_prepare_returns_none_for_nonexistent_file` Existing `test_detect_host_packages_pacman` rewritten to mock `shutil.which` instead of `__import__`. `test_build_success_path` updated to no longer expect `--include` on the command line. Total: 254 tests (was 243 in v0.1.2; +11). All pass. ### The mkosi command now ``` $ mkosi build --output myarch.raw --output-dir /var/lib/sysdeck/builder/artifacts/myarch --force # work_dir: /tmp/sysdeck-mkosi-abc123 # backend: mkosi # profile: myarch # output_dir: /var/lib/sysdeck/builder/artifacts/myarch ``` `work_dir` is the temp dir containing `mkosi.conf` → symlink to the real profile. mkosi reads the symlink, gets the real config. No `--include` needed. --- ## v0.1.2 — 2026-08-19 (critical: mkosi never read the profile — --include + auto-migrate) ### Theme: the builder had zero package awareness because mkosi never saw the config v0.1.2 fixes the critical "zero packages" bug. The operator's report was unambiguous: > *the builder absolutely does not work yet. it has zero awareness of > packages we tell it to add.* Two compounding root causes were identified and both fixed. ### Root cause 1: mkosi never read the profile config file `_backend_build_command()` for mkosi was: ```python cmd = ["mkosi", "build", "--output", name, "--output-dir", dir, "--force"] ``` No flag tells mkosi WHERE the profile config file is. mkosi's default behavior: look for a file literally named `mkosi.conf` in the cwd. If it doesn't find one, it uses EMPTY defaults — no distribution override, no packages, no output settings, nothing. For the operator's profile at `/etc/mkosi/mkosi.conf.d/arch-workstation.conf`: - `work_dir` = `/etc/mkosi/mkosi.conf.d/` (parent of the profile file) - mkosi runs in that cwd - mkosi looks for `mkosi.conf` in `/etc/mkosi/mkosi.conf.d/` - The file is named `arch-workstation.conf`, NOT `mkosi.conf` - mkosi finds no config → uses empty defaults - Zero packages installed Even for v0.1.0+ profiles at `/etc/mkosi/profiles//mkosi.conf`, the file IS named `mkosi.conf` so mkosi would find it — but only because of the directory layout, not because sysdeck was explicit about it. That's fragile. ### Fix 1: `--include ` on every mkosi build ```python cmd = ["mkosi", "build"] if ppath: cmd += ["--include", ppath] cmd += ["--output", output_name, "--output-dir", artifacts_dir, "--force"] ``` `--include` tells mkosi to explicitly load the profile config by path, regardless of its filename or location. This is the fix for "zero awareness of packages" — mkosi now ALWAYS sees the profile config, whether it's at `/etc/mkosi/profiles/myarch/mkosi.conf` (v0.1.0 layout) or `/etc/mkosi/mkosi.conf.d/arch-workstation.conf` (v0.0.x layout). ### Root cause 2: legacy `Packages=` syntax silently parsed as garbage Even when mkosi DID read the profile file (e.g. v0.1.0+ profiles with correct location), profiles created by v0.0.x used the old indented `Packages=` syntax: ```ini [Packages] Packages= linux linux-firmware systemd openssh ``` mkosi v22+ (Arch ships 25.x) only understands single-line: ```ini [Packages] Packages=linux linux-firmware systemd openssh ``` The old indented form is silently parsed as a single package name with embedded newlines (`"linux\nlinux-firmware\nsystemd\nopenssh"`), which doesn't exist in any repo — so mkosi installs NOTHING. The build "succeeds" but the image has zero of the operator's requested packages. ### Fix 2: auto-migrate legacy `Packages=` syntax before every build New `_migrate_legacy_mkosi_packages(conf_path)` function: 1. Reads the profile file 2. Detects the old indented syntax via regex 3. Extracts package names from the indented block 4. Rewrites the `Packages=` line to single-line space-separated form 5. Writes the file back IN-PLACE `build()` calls this automatically on every mkosi build, BEFORE constructing the command. The migration is logged in: - **Build state JSON** (`warnings` array): `"packages_migrated: rewrote Packages= from old indented syntax to single-line (N packages: ...)"` - **Log file header**: `# MIGRATED: rewrote Packages= from old indented syntax to single-line (N packages: ...)` If the file already uses modern syntax, the migration is a no-op (returns `{"migrated": False, "reason": "already uses single-line syntax"}`). ### Regression tests 4 new unit tests in `TestBuilderBuildPath`: - `test_migrate_rewrites_old_indented_syntax` — verifies old `Packages=\n linux\n vim\n` is rewritten to `Packages=linux vim` - `test_migrate_noop_on_modern_syntax` — verifies already-modern files are left unchanged - `test_migrate_noop_on_no_packages_section` — verifies files without `[Packages]` are left unchanged - `test_migrate_runs_during_build` — end-to-end: `build()` with a profile containing old syntax auto-migrates before mkosi runs, and the migration is recorded in state + log The existing `test_build_success_path` was extended to verify `--include` is on the command line and points at the profile file. Total: 243 tests (was 239 in v0.1.1; +4). All build-time guards pass. ### The mkosi command line now ``` $ mkosi build --include /etc/mkosi/profiles/myarch/mkosi.conf --output myarch.raw --output-dir /var/lib/sysdeck/builder/artifacts/myarch --force # work_dir: /etc/mkosi/profiles/myarch # backend: mkosi # profile: myarch # output_dir: /var/lib/sysdeck/builder/artifacts/myarch ``` Every flag sysdeck needs is on the CLI. Nothing depends on the profile's `mkosi.conf` having the right settings — sysdeck forces the config path, output name, output dir, and overwrite. --- ## v0.1.1 — 2026-08-19 (output path safety fix: CLI flags force artifacts dir) ### Theme: trusting mkosi.conf was the bug v0.1.1 is the "I should have done this in v0.1.0" release. The v0.1.0 fix added `OutputDirectory=` to the scaffolded `mkosi.conf` template, trusting mkosi to honor it. Two problems with that trust: 1. **Old v0.0.x profiles have no `OutputDirectory=`.** The operator's `arch-workstation` profile was created by v0.0.50 — it lives at `/etc/mkosi/mkosi.conf.d/arch-workstation.conf` and has no output directory setting. mkosi defaulted to writing `image.raw` into the cwd (`/etc/mkosi/mkosi.conf.d/`), a system config directory owned by root. 2. **mkosi then refused to overwrite the existing `image.raw`.** The error message — "Output path /etc/mkosi/mkosi.conf.d/image.raw exists already. (Use --force to rebuild.)" — blocked every rebuild from the panel, which had no way to pass `--force`. The operator's response was unambiguous: > *this is NOT a safe output path. fix this now.* ### The fix: don't trust the profile, force the CLI `_backend_build_command()` for mkosi was just: ```python cmd = ["mkosi", "build"] ``` It is now: ```python artifacts_dir = options.get("output_dir") or str(BUILDER_ARTIFACTS_DIR / pname) output_name = options.get("output_name") or f"{pname}.raw" cmd = [ "mkosi", "build", "--output", output_name, "--output-dir", artifacts_dir, "--force", ] ``` CLI flags override `mkosi.conf` (mkosi's documented precedence: CLI > config file). So the output path is forced to `/var/lib/sysdeck/builder/artifacts//.raw` regardless of what the profile says — or doesn't say. `--force` overwrites any existing image so rebuilds don't fail. ### Belt-and-suspenders: refuse unsafe output paths Even with the CLI force, I added a safety check in `build()` that refuses to proceed if the resolved `output_dir` is not under `/var/lib/`, `/tmp/`, `/var/tmp/`, or the configured `BUILDER_ARTIFACTS_DIR`. This blocks `/etc/`, `/usr/`, `/boot/`, `/bin/`, `/sbin/`, `/lib/`, `/root/`, `/home/`, etc. — anywhere a stray `image.raw` would corrupt the system or pollute a user's home. If an operator somehow passes `options.output_dir=/etc/something` via the JS bridge, the build is refused before `subprocess.run` is called. The error message: ``` refusing to build: output directory '/etc/mkosi/evil' is not under /var/lib/, /tmp/, or /var/tmp/. Build outputs must go to /var/lib/sysdeck/builder/artifacts// to avoid corrupting system config directories. ``` ### Legacy profile warning The operator's build was running against a v0.0.x profile in `/etc/mkosi/mkosi.conf.d/`. v0.1.0 already fixed the scaffold location for NEW profiles (they go in `/etc/mkosi/profiles//mkosi.conf`), but the OLD profile is still there. v0.1.1 doesn't refuse to build it (the output-path safety is handled), but it records a warning in both the build state JSON and the log file: ``` # WARNING: profile is in /etc/mkosi/mkosi.conf.d/ (legacy v0.0.x layout). # mkosi may silently ignore this drop-in fragment. # Migrate to /etc/mkosi/profiles//mkosi.conf for a real profile. ``` The build state JSON gets a `warnings` array so the panel can surface it in the UI too. ### Log improvement The build log header now includes the resolved `output_dir` so the operator can see exactly where the image will land before mkosi starts: ``` $ mkosi build --output arch-workstation.raw --output-dir /var/lib/sysdeck/builder/artifacts/arch-workstation --force # work_dir: /etc/mkosi/mkosi.conf.d # backend: mkosi # profile: arch-workstation # output_dir: /var/lib/sysdeck/builder/artifacts/arch-workstation ``` ### Regression tests 2 new unit tests in `TestBuilderBuildPath`: - `test_build_refuses_output_dir_under_etc` — verifies the safety check rejects `output_dir=/etc/mkosi/evil`. - `test_build_legacy_v050_profile_records_warning` — verifies building a profile in `/etc/mkosi/mkosi.conf.d/` records the legacy warning in state + log. The existing `test_build_success_path` was extended to verify the mkosi command line includes `--output`, `--output-dir`, and `--force`, and that `--output-dir` points at the per-profile artifacts dir. Total: 239 tests (was 237 in v0.1.0; +2). All build-time guards pass. ### What the operator should do 1. `sudo make uninstall && sudo make install && sudo systemctl restart cockpit.socket` 2. Delete the old image.raw that mkosi created in the wrong place: `sudo rm /etc/mkosi/mkosi.conf.d/image.raw` 3. Migrate the old profile: `sudo mkdir -p /etc/mkosi/profiles/arch-workstation && sudo mv /etc/mkosi/mkosi.conf.d/arch-workstation.conf /etc/mkosi/profiles/arch-workstation/mkosi.conf` 4. Click Build on `arch-workstation` — output will land at `/var/lib/sysdeck/builder/artifacts/arch-workstation/arch-workstation.raw` --- ## v0.1.0 — 2026-08-19 (builder profile fixup + host pkg import) ### Theme: the empty-image bug that turned out to be three bugs in a trench coat v0.1.0 is the "fix what v0.0.50 should have caught" release. The v0.0.50 fix (adding the missing `import re` to `bridge/builder.py`) unblocked the `build()` code path, and the operator immediately ran into the next layer of problems. The build log went: ``` ‣ Installing Arch Linux Packages (2) iana-etc-20260530-1 filesystem-2025.10.12-1 ‣ Generating disk image ‣ /etc/mkosi/mkosi.conf.d/image.raw size is 33.0M, consumes 32.0M. ``` Then the operator wrote: *"nice try but i think our profile build didnt work. i dont see a final tarball or image file to look at."* Two red flags in that single last line: 1. The file is `image.raw`, not `myimage.raw` (the template said `Output=myimage.raw`) 2. It's in `/etc/mkosi/mkosi.conf.d/`, not sysdeck's artifacts dir. The four packages from the scaffold template (`linux`, `linux-firmware`, `systemd`, `openssh`) were silently dropped. Three compounding bugs were to blame, and only one of them was the "obvious" one. ### Bug 1: the scaffolded profile was never read `profile-create` (since v0.0.31) wrote `/etc/mkosi/mkosi.conf.d/.conf` — a drop-in *fragment*. mkosi's drop-in semantics: `mkosi.conf.d/*.conf` files are layered on top of a *parent* `mkosi.conf`. With no parent, mkosi runs as if the fragment didn't exist. It auto-detected the host distro, defaulted to `Format=disk` with `Output=image.raw`, and used an empty `Packages=` list. That's why the output filename was `image.raw` not `myimage.raw`, and why the only packages that got installed were `iana-etc` + `filesystem` — mkosi's hardcoded Arch base. **Fix:** each profile now lives in its own directory `/etc/mkosi/profiles//mkosi.conf`. `mkosi.conf` is the only filename mkosi reads automatically from the cwd. `MKOSI_DIRS` updated to scan `/etc/mkosi/profiles` first. This also makes per-profile `mkosi.extra/`, `mkosi.pkg/`, etc. work naturally — operators can drop in supplementary files alongside the config and mkosi picks them up. ### Bug 2: the `Packages=` syntax was from mkosi v15 The template was: ```ini [Packages] Packages= linux linux-firmware systemd openssh ``` That indented-continuation form was the **old systemd-mkosi (≤v15)** syntax. Modern mkosi (v22+, what Arch ships as `mkosi 25.x`) wants either `Packages=linux linux-firmware systemd openssh` on a single line, or a separate `mkosi.pkg` file referenced via `Packages=mkosi.pkg`. The v0.0.x form was silently parsed as a single package named `"linux\nlinux-firmware\nsystemd\nopenssh"` and failed to install. **Fix:** template + writer now emit the modern single-line form. The reader accepts both forms so v0.0.x profiles migrate cleanly on first append/replace. This bug was particularly nasty because: - The writer's tests (`test_bridge_parsers.py:2216-2252`) asserted `" vim\n"` was in the file — passing the test meant emitting the *wrong* syntax. - The reader only understood the indented form, so even if you fixed the template by hand, the next `--mode=append` write would silently re-break the syntax. ### Bug 3: the artifact never reached sysdeck's artifacts directory `build()` (line 672-681 of the old code) only scanned `/var/lib/sysdeck/builder/artifacts//` for artifacts. But mkosi writes its output to the cwd (`/etc/mkosi/mkosi.conf.d/image.raw`). No copy step. Hence "i dont see a final tarball or image file to look at" from sysdeck's point of view — even though mkosi technically did produce one. **Fix:** `_MKOSI_TEMPLATE` now sets `OutputDirectory=/var/lib/sysdeck/builder/artifacts/` so mkosi writes directly there. The artifacts panel's discovery code already scanned that directory, so once mkosi writes there the panel finds it automatically. ### New feature: `profile-import-packages` Per operator request: *"import current os pkg list to profile should be an option."* Queries the host's explicitly-installed packages: - **Arch:** `pacman -Qqe` (explicitly installed; excludes deps) - **Debian:** `apt-mark showmanual` (closest analog to `pacman -Qqe`) - **Fedora:** `dnf repoquery --userinstalled --queryformat '%{name}'` Then writes the result into the profile via the existing `_write_packages` dispatch. Defaults to **append** mode (the operator usually wants to layer host packages on top of the profile's existing baseline like `linux`/`systemd`/`openssh`). Three flags: - `--mode=replace` — wipe the baseline first - `--dry-run` — return what *would* be written, don't touch the file - `--packages=` — override the host query with a JSON-encoded multiline string (useful for importing a list captured on a different host) The panel exposes a "⇩ Import host pkgs" button on every profile row. Two-step UX: dry-run preview → `window.confirm` with package count, source distro, and first 200 packages → append write. Operator can cancel cleanly without any file changes. New polkit exec paths for `pacman`/`apt-mark`/`dnf` added to `org.sysdeck.builder.modify`. ### Regression tests 7 new unit tests in `TestBuilderImportHostPackages`: - `_detect_host_packages` dispatch (pacman path + dedup) - `--packages` override end-to-end (writes the file) - `--dry-run` doesn't write - unknown profile / no args / bad mode / COMMANDS-registration error paths (Actually 8 — one of the dispatch tests splits into two methods, one for the happy path and one for dedup. The class total is 8.) 4 existing tests in `TestBuilderPackagesField` updated for the new single-line `Packages=` syntax. 1 new test (`test_mkosi_modern_single_line_input_parsed`) guards against a regression where the writer emits the new form but the reader only understands the old one — that would silently break append mode on profiles created by v0.1.0 itself. Total: 237 tests (was 228 in v0.0.50; +9). All build-time guards pass. ### Why v0.1.0 (and not v0.0.51) The bug fixes are technically backwards-incompatible: profiles created by v0.0.x live in `/etc/mkosi/mkosi.conf.d/.conf` and use the indented `Packages=` syntax. v0.1.0's reader accepts both syntaxes (safe migration), but the scaffold location is different. Operators upgrading from v0.0.x to v0.1.0 should either: 1. Move their profiles from `/etc/mkosi/mkosi.conf.d/.conf` to `/etc/mkosi/profiles//mkosi.conf`, or 2. Create a parent `/etc/mkosi/mkosi.conf` (any non-empty `[Distribution]` section will do) so the existing drop-ins start being honored. The minor version bump makes the incompatibility visible. --- ## v0.0.50 — 2026-08-19 (build path NameError fix: `import re` added to bridge/builder.py) ### Theme: the one-line fix that took 18 releases to find v0.0.50 is a one-line bugfix release. An operator reported: > *NameError: name 're' is not defined. Did you forget to import > 're'? happens right away on build for a new profile i created.* The traceback pointed at `bridge/builder.py` line 492, inside `_new_build_id()`: ```python safe_profile = re.sub(r"[^A-Za-z0-9_-]", "_", profile) ``` `re` wasn't imported at module level. The module-level imports were: ```python import json import os import shutil import subprocess import sys from pathlib import Path from typing import Any ``` No `import re`. `_new_build_id` has used `re.sub` since v0.0.31 — when the build operations were first added. The bug went undetected for 18 releases (v0.0.31 through v0.0.49) until an operator actually clicked Build on a freshly-created profile. #### Why it went undetected Three layers of defense all missed it: 1. **`python3 -m py_compile`** (the `make check` syntax check) only catches *syntax* errors. A `NameError` at call time is not a syntax error — the code is syntactically valid Python, it just references a name that isn't in scope when the function runs. 2. **Unit tests.** The existing unit tests covered `profile_create`, `profile_copy`, `profile_delete`, and the v0.0.49 package-writing helpers. None of them call `build()`, and `build()` is the only caller of `_new_build_id`. So the function that held the bug was never exercised by any test. 3. **Manual testing.** The operator workflow up to v0.0.48 was "create/copy a profile, then build it from a shell." The v0.0.49 release added the inline package-list field, which made the create-then-build flow smooth enough that the operator clicked Build in the panel for the first time — and hit the bug. The lesson: functions that are reachable only through a specific code path (here: `build()` → `_new_build_id()`) need explicit tests that exercise that path, even if the function itself looks trivial. The `make check` syntax check is necessary but not sufficient. #### The fix One line added to the module-level imports: ```python import json import os import re # ← added import shutil import subprocess import sys from pathlib import Path from typing import Any ``` Also removed the now-redundant local `import re` inside `_write_packages_vmdb2` (it was a v0.0.49 workaround — that function uses `re.compile` for the YAML-include regex, and I added a local import there instead of checking whether `re` was already module-level. It wasn't. The local import masked the missing module-level import for `_write_packages_vmdb2`'s own tests, but did nothing for `_new_build_id`). #### Regression tests 9 new unit tests in `TestBuilderBuildPath`: - **`_new_build_id` format**: asserts the build_id matches `^-(\d{14})$`. - **`_new_build_id` sanitizes unsafe chars**: profile name `myarch.v2` → `myarch_v2-` (dot replaced with `_`). - **`_new_build_id` preserves safe chars**: profile name `my-arch_profile` → `my-arch_profile-` (hyphens + underscores kept). - **`_new_build_id_re_imported_at_module_level`**: explicit `assertIn("re", dir(builder))`. This is the regression guard — if anyone ever removes the `import re` line in a future refactor, this test fails before the tarball ships. The v0.0.31-v0.0.49 bug can't recur. - **`build()` success path**: end-to-end with mocked `subprocess.run` (rc=0). Patches `BUILDER_STATE_DIR` / `BUILDER_LOGS_DIR` / `BUILDER_ARTIFACTS_DIR` to tempdirs, patches `BACKENDS` to fake a mkosi install, patches `profiles()` to return a fake profile. Verifies response shape (`build_id` / `state` / `rc` / `success` / `duration_s` / `artifacts` / `log_path`), state file written, log file written, `subprocess.run` was actually called. - **`build()` unknown profile**: returns `{error: "profile 'nonexistent' not found"}`. - **`build()` no args**: returns `{error: "profile name required"}` (no crash). - **`build()` backend not installed**: returns `{error: "backend 'mkosi' is not installed", hint: ...}`. - **`build()` non-zero returncode**: mocked `subprocess.run` returns rc=1 → build state `"failed"`, `rc=1`, `success=False`. All tests mock `subprocess.run` and the module-level state dirs; none touch real `/var/lib/` or invoke real backends. The tests run in 9 milliseconds. #### AST audit To make sure there weren't *other* latent NameErrors lurking in `builder.py`, I wrote an AST-based audit that walks every function body, collects `Name` loads, and checks each against (module-level names + function locals + builtins). The audit flagged ~50 items, but every one was a false positive: - **Comprehension locals** (`b`, `v`, `s`, `p`, `logf`) — bound by the comprehension itself. - **Tuple-unpacking targets** (`cid`, `chint`, `k`, `v`, `backend_id`, `binary`, `vargs`, `kind`) — bound by `for ... in` loops. - **Except-clause targets** (`exc`) — bound by `except ... as exc:`. - **`__file__`** — provided by Python in every module. No real undefined names. The build path is now fully exercisable by tests. #### What this release is NOT - **No JS changes.** This is a Python-only fix. The bridge.js subcommand cross-check stays at 102 calls. - **No new bridge subcommands.** `build` is an existing command; it just works now. - **No new plugin / polkit / bridge helper file.** Counts unchanged. - **No changes to profile discovery, build invocation, or the package-writing helpers.** The fix is purely the missing import. #### Process improvement The v0.0.31-v0.0.49 bug existed because no test exercised the `build()` code path. v0.0.50 adds that test coverage — 9 tests that mock `subprocess.run` and the state dirs, so they run hermetically in the `make check` suite without needing a real backend installed. Any future regression in the build path (missing imports, broken state- file writing, wrong response shape, wrong subprocess invocation) will now be caught before the tarball ships. --- ## v0.0.49 — 2026-08-19 (builder inline package list: textarea + file upload + merge mode for all 4 backends) ### Theme: close the loop on profile creation — paste the baseline apps inline v0.0.49 is a feature release for the Image Builder panel. Per user directive: *"we should allow adding a pacman -Sy applist.txt with a literal list of baseline apps for the profile being generated."* The v0.0.48 release fixed the dead-end error that operators of archiso-only or live-build-only hosts were hitting, and added the "Copy shipped profile" form. But both forms still left the operator with a half-finished profile: they scaffolded/copied the config, then had to drop to a shell to edit the package list. v0.0.49 closes that loop — the package list is now part of the creation flow. #### 1. The field Both the Create Profile and Copy shipped profile forms now include a shared `renderPackagesField(prefix, defaultMode)` block with three parts: - A `