diff --git a/.gitignore b/.gitignore
index 54c865e011..cef909bf1c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ SAFETY_LOCK
**/**.egg*
**/**.sh
!archinstall/locales/locales_generator.sh
+!test_tooling/dev_vm/dev_vm.sh
**/**.egg-info/
**/**build/
**/**src/
@@ -39,6 +40,7 @@ requirements.txt
/.gitconfig
/actions-runner
/cmd_output.txt
+/.dev/
node_modules/
uv.lock
test_tooling/mkosi/mkosi.output/*image*
diff --git a/README.md b/README.md
index 4e6847f406..414cbab235 100644
--- a/README.md
+++ b/README.md
@@ -170,6 +170,24 @@ replace the archinstall version with a newer one and execute the subsequent step
rare case it will not work is if the source has introduced any new dependencies that are not installed yet
- Installing the branch version with `pip install --break-system-packages .` and `archinstall`
+## Developer VM (recommended for contributors)
+
+The fastest way to iterate on archinstall is the dev VM script in `test_tooling/dev_vm/`.
+It builds a minimal Arch ISO with runtime deps pre-installed, then launches QEMU with the
+project source mounted read-only via 9p - edit code on the host, run `archinstall` in the
+guest immediately, no rebuild needed.
+
+```shell
+./test_tooling/dev_vm/dev_vm.sh # first run builds ISO, then boots
+./test_tooling/dev_vm/dev_vm.sh rebuild # force rebuild ISO
+./test_tooling/dev_vm/dev_vm.sh boot # boot from installed disk (no ISO)
+./test_tooling/dev_vm/dev_vm.sh --bios # legacy BIOS mode
+./test_tooling/dev_vm/dev_vm.sh -h # all options
+```
+
+Requires an Arch-based host with `qemu-base` installed. See the script header for details
+on 9p shares, log streaming, and env overrides.
+
## Without a Live ISO Image
To test this without a live ISO, the simplest approach is to use a local image and create a loop device.
diff --git a/test_tooling/dev_vm/derive_packages.py b/test_tooling/dev_vm/derive_packages.py
new file mode 100755
index 0000000000..1f84774d2e
--- /dev/null
+++ b/test_tooling/dev_vm/derive_packages.py
@@ -0,0 +1,33 @@
+#!/usr/bin/env python3
+"""Parse pyproject.toml runtime dependencies and emit Arch package names.
+
+Assumes the python- convention, which holds for every archinstall
+dependency today. If a future dep breaks the convention, fix here.
+"""
+
+import re
+import sys
+import tomllib
+
+
+def main() -> int:
+ if len(sys.argv) != 2:
+ print(f'usage: {sys.argv[0]} ', file=sys.stderr)
+ return 1
+
+ with open(sys.argv[1], 'rb') as f:
+ data = tomllib.load(f)
+
+ for dep in data['project']['dependencies']:
+ name = re.split(r'[<>=!~;\[\s]', dep, maxsplit=1)[0].strip()
+ if name:
+ # PEP 503: lowercase, any run of [-_.] becomes '-'. Arch mirrors this
+ # naming, so normalize before prepending the python- prefix.
+ name = re.sub(r'[-_.]+', '-', name).lower()
+ print(f'python-{name}')
+
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/test_tooling/dev_vm/dev_vm.sh b/test_tooling/dev_vm/dev_vm.sh
new file mode 100755
index 0000000000..8d43bcbc18
--- /dev/null
+++ b/test_tooling/dev_vm/dev_vm.sh
@@ -0,0 +1,307 @@
+#!/bin/bash
+# Dev test VM for archinstall.
+# Builds a minimal dev ISO on first run (runtime deps pre-installed, 9p shares
+# auto-mounted, `archinstall` wrapped to clear logs and run `python -m archinstall`),
+# then launches QEMU. Source lives on the host, guest mounts it read-only - no rebuild loop.
+#
+# Host artifacts (all under .dev/ in repo root, git-ignored):
+# .dev/iso/ generated dev ISO (mkarchiso output)
+# .dev/disk.qcow2 VM disk image (qemu-img)
+# .dev/ovmf-vars.fd persistent UEFI NVRAM (copied from OVMF_VARS)
+# .dev/configs/ optional, user-created - shared rw to guest as /root/cfg
+# .dev/logs/ auto-created - shared rw to guest as /var/log/archinstall
+#
+# Inside the guest after boot:
+# /root/archinstall-dev project source (9p ro, host edits appear live)
+# /root/cfg .dev/configs share (9p rw, mounted only if folder exists)
+# /var/log/archinstall .dev/logs share (9p rw, host can tail install.log live)
+# archinstall wrapper around `python -m archinstall`; clears the
+# log directory on every invocation
+#
+# Run from the project root or from inside test_tooling/dev_vm/ - both work,
+# the script resolves the project root from its own location.
+#
+# Usage (from project root):
+# ./test_tooling/dev_vm/dev_vm.sh - build ISO if missing, fresh disk, boot
+# ./test_tooling/dev_vm/dev_vm.sh rebuild | r - force rebuild ISO, fresh disk, boot
+# ./test_tooling/dev_vm/dev_vm.sh keep | k - reuse disk, boot ISO
+# ./test_tooling/dev_vm/dev_vm.sh boot | b - boot from installed disk (no ISO)
+# ./test_tooling/dev_vm/dev_vm.sh clean | c - remove disk, NVRAM, ISO, logs
+# ./test_tooling/dev_vm/dev_vm.sh -h - show this help
+#
+# Firmware mode (default is UEFI 64-bit):
+# --bios - legacy BIOS boot (SeaBIOS, no OVMF)
+#
+# Flag can be combined with a command:
+# ./test_tooling/dev_vm/dev_vm.sh --bios rebuild
+#
+# Env overrides:
+# SCREEN_W, SCREEN_H - virtio-vga resolution (default 1280x720)
+#
+# Host FS note: 9p mapped-xattr mode needs xattr support on the filesystem
+# holding the repo (ext4/btrfs work out of the box; ZFS requires xattr=sa).
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
+DEV_DIR="$PROJECT_DIR/.dev"
+CONFIGS_DIR="$DEV_DIR/configs"
+LOGS_DIR="$DEV_DIR/logs"
+ISO_DIR="$DEV_DIR/iso"
+DISK="$DEV_DIR/disk.qcow2"
+OVMF_VARS="$DEV_DIR/ovmf-vars.fd"
+DISK_SIZE="30G"
+RAM="4G"
+CPUS="4"
+SCREEN_W="${SCREEN_W:-1280}"
+SCREEN_H="${SCREEN_H:-720}"
+
+err() { echo "ERROR: $*" >&2; exit 1; }
+
+# Parse flags and command from arguments
+FW_MODE="uefi64"
+ARG="default"
+for arg in "$@"; do
+ case "$arg" in
+ --bios)
+ FW_MODE="bios"
+ ;;
+ *)
+ ARG="$arg"
+ ;;
+ esac
+done
+
+# OVMF firmware - probed at runtime across common distro paths
+OVMF_CODE=""
+OVMF_VARS_ORIG=""
+
+probe_ovmf() {
+ if [ -f /usr/share/edk2/x64/OVMF_CODE.4m.fd ] && [ -f /usr/share/edk2/x64/OVMF_VARS.4m.fd ]; then
+ OVMF_CODE="/usr/share/edk2/x64/OVMF_CODE.4m.fd"
+ OVMF_VARS_ORIG="/usr/share/edk2/x64/OVMF_VARS.4m.fd"
+ return 0
+ fi
+ echo ">>> OVMF firmware not found. Installing edk2-ovmf (sudo needed for UEFI support)..."
+ sudo pacman --noconfirm -S edk2-ovmf
+ if [ -f /usr/share/edk2/x64/OVMF_CODE.4m.fd ] && [ -f /usr/share/edk2/x64/OVMF_VARS.4m.fd ]; then
+ OVMF_CODE="/usr/share/edk2/x64/OVMF_CODE.4m.fd"
+ OVMF_VARS_ORIG="/usr/share/edk2/x64/OVMF_VARS.4m.fd"
+ return 0
+ fi
+ err "x64 OVMF not found at /usr/share/edk2/x64/ even after installing edk2-ovmf."
+}
+
+check_host_deps() {
+ local missing=() cmd
+ for cmd in qemu-system-x86_64 qemu-img sudo; do
+ command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
+ done
+ if [ ${#missing[@]} -gt 0 ]; then
+ err "missing host commands: ${missing[*]} (install qemu-base and sudo)"
+ fi
+}
+
+# ISO build needs pacman + mkarchiso, which are Arch-only. Accept Arch and
+# Arch-based derivatives (Manjaro, EndeavourOS, ...) via ID / ID_LIKE.
+check_arch_host() {
+ local id="" id_like=""
+ if [ -r /etc/os-release ]; then
+ # shellcheck disable=SC1091
+ . /etc/os-release
+ id="${ID:-}"
+ id_like="${ID_LIKE:-}"
+ fi
+ if [ "$id" != "arch" ] && [[ "$id_like" != *arch* ]]; then
+ err "ISO build requires an Arch-based host (pacman + mkarchiso). Detected ID='${id:-unknown}', ID_LIKE='${id_like:-unknown}'."
+ fi
+}
+
+# Parse runtime deps from pyproject.toml and map to Arch package names.
+# Delegates to derive_packages.py so the Python logic lives in a real .py file.
+derive_packages() {
+ command -v python3 >/dev/null 2>&1 || err "python3 not found on host (needed to parse pyproject.toml)"
+ local result
+ result=$(python3 "$SCRIPT_DIR/derive_packages.py" "$PROJECT_DIR/pyproject.toml") \
+ || err "failed to parse $PROJECT_DIR/pyproject.toml"
+ [ -n "$result" ] || err "pyproject.toml has no [project.dependencies]"
+ printf '%s\n' "$result"
+}
+
+find_iso() {
+ ls -t "$ISO_DIR"/archlinux-*-x86_64.iso 2>/dev/null | head -n1
+}
+
+build_iso() {
+ check_arch_host
+ if ! command -v mkarchiso >/dev/null 2>&1; then
+ echo ">>> mkarchiso not found on host; archiso will be installed via sudo pacman."
+ fi
+ local runtime_deps
+ runtime_deps=$(derive_packages)
+ echo ">>> Runtime deps derived from pyproject.toml:"
+ echo "$runtime_deps" | sed 's/^/ /'
+ echo ">>> Building dev ISO (sudo needed for pacman/mkarchiso)..."
+ sudo env OUT_DIR="$ISO_DIR" HOST_USER="$(id -un)" RUNTIME_DEPS="$runtime_deps" bash <<'SUDO_SCRIPT'
+set -e
+BUILD_DIR=/tmp/archlive-dev
+
+# Runtime deps - python plus the list derived from pyproject.toml on the host.
+# Source comes via 9p, no wheel build needed.
+packages=(python git)
+while IFS= read -r p; do
+ [ -n "$p" ] && packages+=("$p")
+done <<< "$RUNTIME_DEPS"
+
+rm -rf "$BUILD_DIR"
+pacman --noconfirm --needed -S archiso
+cp -r /usr/share/archiso/configs/releng "$BUILD_DIR"
+
+# Drop preinstalled archinstall - we run from 9p-mounted source.
+# Anchored pattern so related packages (archinstall-*, python-archinstall-*)
+# would not be accidentally removed if releng ever grows them.
+sed -i '/^archinstall$/d' "$BUILD_DIR/packages.x86_64"
+for p in "${packages[@]}"; do
+ echo "$p" >> "$BUILD_DIR/packages.x86_64"
+done
+
+# Trust the 9p-mounted source for git: host UIDs differ from the guest's,
+# which would otherwise trip git's safe.directory dubious-ownership check.
+mkdir -p "$BUILD_DIR/airootfs/etc"
+cat > "$BUILD_DIR/airootfs/etc/gitconfig" <<'GIT'
+[safe]
+ directory = /root/archinstall-dev
+GIT
+
+# Auto-mount project, define archinstall wrapper, print info on login
+mkdir -p "$BUILD_DIR/airootfs/root"
+cat > "$BUILD_DIR/airootfs/root/.zprofile" <<'ZP'
+mkdir -p /root/archinstall-dev /root/cfg /var/log/archinstall
+if ! mount -t 9p -o trans=virtio,ro dev /root/archinstall-dev 2>/dev/null; then
+ echo "ERROR: failed to mount 9p 'dev' share. The archinstall wrapper will not work."
+ echo "Check host qemu virtfs support and that the guest kernel has the 9p module."
+fi
+mount -t 9p -o trans=virtio cfg /root/cfg 2>/dev/null || true
+mount -t 9p -o trans=virtio logs /var/log/archinstall 2>/dev/null || true
+cd /root/archinstall-dev
+export PYTHONDONTWRITEBYTECODE=1
+# Wrapper instead of a plain alias so each invocation starts with a clean log
+# directory. The directory is a 9p mount from the host, so the contents are
+# wiped (find -delete) instead of removing the mountpoint itself.
+archinstall() {
+ find /var/log/archinstall -mindepth 1 -delete 2>/dev/null
+ python -m archinstall "$@"
+}
+cat <>> ISO built."
+}
+
+case "$ARG" in
+ -h|--help|help)
+ sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'
+ exit 0
+ ;;
+ clean|c)
+ rm -fv "$DISK" "$OVMF_VARS"
+ rm -rf "$ISO_DIR" "$LOGS_DIR"
+ exit 0
+ ;;
+ boot|b)
+ check_host_deps
+ [ "$FW_MODE" != "bios" ] && probe_ovmf
+ [ -f "$DISK" ] || err "Disk missing, run without args first"
+ BOOT_ORDER="c"
+ ATTACH_ISO=false
+ ;;
+ rebuild|r)
+ check_host_deps
+ [ "$FW_MODE" != "bios" ] && probe_ovmf
+ rm -rf "$ISO_DIR"
+ build_iso
+ rm -f "$DISK"
+ qemu-img create -f qcow2 "$DISK" "$DISK_SIZE"
+ BOOT_ORDER="d"
+ ATTACH_ISO=true
+ ;;
+ keep|k)
+ check_host_deps
+ [ "$FW_MODE" != "bios" ] && probe_ovmf
+ [ -f "$DISK" ] || err "Disk missing, run without args first"
+ [ -n "$(find_iso)" ] || build_iso
+ BOOT_ORDER="d"
+ ATTACH_ISO=true
+ ;;
+ default)
+ check_host_deps
+ [ "$FW_MODE" != "bios" ] && probe_ovmf
+ [ -n "$(find_iso)" ] || build_iso
+ rm -f "$DISK"
+ qemu-img create -f qcow2 "$DISK" "$DISK_SIZE"
+ BOOT_ORDER="d"
+ ATTACH_ISO=true
+ ;;
+ *)
+ err "Unknown argument: $ARG (try -h)"
+ ;;
+esac
+
+if [ "$FW_MODE" != "bios" ]; then
+ [ -f "$OVMF_VARS" ] || cp "$OVMF_VARS_ORIG" "$OVMF_VARS"
+fi
+
+QEMU_ARGS=(
+ -machine q35
+ -cpu host
+ -enable-kvm
+ -m "$RAM"
+ -smp "$CPUS"
+ -drive "file=$DISK,format=qcow2,if=virtio"
+ -device virtio-net-pci,netdev=net0
+ -netdev user,id=net0
+ -device "virtio-vga,xres=$SCREEN_W,yres=$SCREEN_H"
+ -display gtk,zoom-to-fit=off
+ -monitor stdio
+ -virtfs "local,path=$PROJECT_DIR,mount_tag=dev,security_model=mapped-xattr,readonly=on"
+ -boot "order=$BOOT_ORDER"
+)
+
+if [ "$FW_MODE" != "bios" ]; then
+ QEMU_ARGS+=(
+ -drive "if=pflash,format=raw,readonly=on,file=$OVMF_CODE"
+ -drive "if=pflash,format=raw,file=$OVMF_VARS"
+ )
+fi
+
+# Optional second 9p share for test configs, only if host folder exists
+if [ -d "$CONFIGS_DIR" ]; then
+ QEMU_ARGS+=(-virtfs "local,path=$CONFIGS_DIR,mount_tag=cfg,security_model=mapped-xattr")
+fi
+
+# Logs share: always mounted, host folder auto-created so install.log is
+# visible from the host (.dev/logs/install.log) without re-entering the VM.
+mkdir -p "$LOGS_DIR"
+QEMU_ARGS+=(-virtfs "local,path=$LOGS_DIR,mount_tag=logs,security_model=mapped-xattr")
+
+if [ "$ATTACH_ISO" = "true" ]; then
+ ISO="$(find_iso)"
+ [ -n "$ISO" ] && [ -f "$ISO" ] || err "No dev ISO after build step"
+ QEMU_ARGS+=(-cdrom "$ISO")
+fi
+
+exec qemu-system-x86_64 "${QEMU_ARGS[@]}"