CLI (nix run .#): - `vmix build` and `vmix copy` subcommands - --image, --generalize key=val, --to-disk, --to-remote-disk - SDL display auto-detected via DISPLAY temp file passthrough - --print-build-logs for visible build progress - -S 4k sparse writes for faster disk copy Images: - win10.laptop and win11.laptop bundles (no VirtIO, keeps defender/hibernation) - templates.bundles.laptop shared template list - win11 adds reg.disableUCPD on top Build improvements: - consistent === vmix: === log prefixes - SDL display via /tmp/.vmix-display-$$ temp file Env helpers: - .env-export-vmix-cli-local: vmix alias for local flake Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
228 lines
8.1 KiB
Nix
228 lines
8.1 KiB
Nix
{
|
|
description = "vmix — composable QEMU VM image building and orchestration";
|
|
|
|
inputs = {
|
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
|
};
|
|
|
|
outputs = { self, nixpkgs, ... }:
|
|
let
|
|
system = "x86_64-linux";
|
|
pkgs = import nixpkgs {
|
|
inherit system;
|
|
config.allowUnfree = true;
|
|
};
|
|
lib = pkgs.lib;
|
|
vmixLib = import ./lib { inherit pkgs lib system; };
|
|
in {
|
|
overlays.default = import ./overlay.nix;
|
|
|
|
nixosModules.default = import ./module.nix;
|
|
|
|
lib.${system} = vmixLib;
|
|
|
|
packages.${system}.default = pkgs.writeShellScriptBin "vmix" ''
|
|
set -euo pipefail
|
|
|
|
usage() {
|
|
echo "Usage:"
|
|
echo " vmix build --image <path> [--generalize key=val,...] [--out-link PATH]"
|
|
echo " vmix copy --image <path> [--generalize key=val,...] --to-disk /dev/sdX"
|
|
echo " vmix copy --image <path> [--generalize key=val,...] --to-remote-disk user@host:/dev/sdX"
|
|
echo ""
|
|
echo "Commands:"
|
|
echo " build Build a vmix image (optionally generalized)"
|
|
echo " copy Build a vmix image and write it to a disk"
|
|
echo ""
|
|
echo "Options:"
|
|
echo " --image PATH Image path in vmixLib (e.g. windows.images.win10.laptop)"
|
|
echo " --generalize KEY=VAL,... Generalize with comma-separated options:"
|
|
echo " username=User password= hostname=WIN-PC"
|
|
echo " timezone=UTC bgColor=8e8cd8"
|
|
echo " --to-disk DEVICE Write to local disk and expand partitions"
|
|
echo " --to-remote-disk SSH:DEV Stream to remote disk via SSH and expand partitions"
|
|
echo " e.g. root@10.10.10.100:/dev/sda"
|
|
echo " --out-link PATH Symlink for the build result (default: ./result)"
|
|
echo ""
|
|
echo "Examples:"
|
|
echo " vmix build --image windows.images.win10.laptop \\"
|
|
echo " --generalize username=Sagar,password=secret,hostname=LAPTOP"
|
|
echo ""
|
|
echo " vmix copy --image windows.images.win10.laptop \\"
|
|
echo " --generalize username=Sagar,password=secret,hostname=LAPTOP \\"
|
|
echo " --to-disk /dev/sda"
|
|
echo ""
|
|
echo " vmix copy --image windows.images.win10.laptop \\"
|
|
echo " --generalize username=Sagar,password=secret,hostname=LAPTOP \\"
|
|
echo " --to-remote-disk root@10.10.10.100:/dev/nvme0n1"
|
|
exit 1
|
|
}
|
|
|
|
[[ ''${#} -eq 0 ]] && usage
|
|
|
|
COMMAND="$1"; shift
|
|
|
|
case "$COMMAND" in
|
|
build|copy) ;;
|
|
--help|-h) usage ;;
|
|
*) echo "Unknown command: $COMMAND"; usage ;;
|
|
esac
|
|
|
|
IMAGE_NAME=""
|
|
GENERALIZE=""
|
|
TO_DISK=""
|
|
TO_REMOTE_DISK=""
|
|
OUT_LINK="./result"
|
|
|
|
while [[ ''${#} -gt 0 ]]; do
|
|
case "$1" in
|
|
--image) IMAGE_NAME="$2"; shift 2 ;;
|
|
--generalize) GENERALIZE="$2"; shift 2 ;;
|
|
--to-disk) TO_DISK="$2"; shift 2 ;;
|
|
--to-remote-disk) TO_REMOTE_DISK="$2"; shift 2 ;;
|
|
--out-link) OUT_LINK="$2"; shift 2 ;;
|
|
--help|-h) usage ;;
|
|
*) echo "Unknown option: $1"; usage ;;
|
|
esac
|
|
done
|
|
|
|
[[ -z "$IMAGE_NAME" ]] && { echo "Error: --image is required"; usage; }
|
|
|
|
if [[ "$COMMAND" == "copy" && -z "$TO_DISK" && -z "$TO_REMOTE_DISK" ]]; then
|
|
echo "Error: copy requires --to-disk or --to-remote-disk"
|
|
usage
|
|
fi
|
|
|
|
# parse --generalize key=val,key=val into nix attrs
|
|
GENERALIZE_EXPR=""
|
|
if [[ -n "$GENERALIZE" ]]; then
|
|
NIX_ARGS=""
|
|
IFS=',' read -ra PAIRS <<< "$GENERALIZE"
|
|
for pair in "''${PAIRS[@]}"; do
|
|
key="''${pair%%=*}"
|
|
val="''${pair#*=}"
|
|
NIX_ARGS+="$key = \"$val\"; "
|
|
done
|
|
GENERALIZE_EXPR=".generalize { $NIX_ARGS }"
|
|
fi
|
|
|
|
FLAKE_DIR="${self}"
|
|
|
|
echo "=== vmix $COMMAND ==="
|
|
echo "Image: $IMAGE_NAME"
|
|
[[ -n "$GENERALIZE" ]] && echo "Generalize: $GENERALIZE"
|
|
[[ -n "$TO_DISK" ]] && echo "To disk: $TO_DISK"
|
|
[[ -n "$TO_REMOTE_DISK" ]] && echo "To remote: $TO_REMOTE_DISK"
|
|
echo ""
|
|
|
|
# pass DISPLAY to nix builds via temp file (daemon sanitizes env vars)
|
|
VMIX_DISPLAY_FILE="/tmp/.vmix-display-$$"
|
|
if [[ -n "''${DISPLAY:-}" ]]; then
|
|
echo "$DISPLAY" > "$VMIX_DISPLAY_FILE"
|
|
chmod 666 "$VMIX_DISPLAY_FILE"
|
|
fi
|
|
trap 'rm -f "$VMIX_DISPLAY_FILE"' EXIT
|
|
|
|
echo "Building image..."
|
|
${pkgs.nix}/bin/nix build --out-link "$OUT_LINK" --print-build-logs --impure --expr "
|
|
let
|
|
vmixLib = (builtins.getFlake \"$FLAKE_DIR\").lib.${system};
|
|
image = vmixLib.$IMAGE_NAME;
|
|
in image$GENERALIZE_EXPR
|
|
"
|
|
|
|
IMAGE_FILE=$(readlink -f "$OUT_LINK")
|
|
echo "Built: $IMAGE_FILE"
|
|
|
|
if [[ -n "$TO_DISK" ]]; then
|
|
echo ""
|
|
|
|
[[ $EUID -ne 0 ]] && { echo "Error: --to-disk requires root"; exit 1; }
|
|
[[ ! -b "$TO_DISK" ]] && { echo "Error: not a block device: $TO_DISK"; exit 1; }
|
|
|
|
if ${pkgs.util-linux}/bin/mount | grep -q "^''${TO_DISK}"; then
|
|
echo "Error: $TO_DISK has mounted partitions — unmount first"
|
|
exit 1
|
|
fi
|
|
|
|
DISK_SIZE=$(${pkgs.util-linux}/bin/blockdev --getsize64 "$TO_DISK")
|
|
DISK_SIZE_GB=$(( DISK_SIZE / 1024 / 1024 / 1024 ))
|
|
|
|
echo "=== writing to disk ==="
|
|
echo "Target: $TO_DISK ($DISK_SIZE_GB GB)"
|
|
echo ""
|
|
echo "WARNING: This will DESTROY all data on $TO_DISK"
|
|
read -rp "Continue? [y/N] " confirm
|
|
[[ "$confirm" != "y" && "$confirm" != "Y" ]] && { echo "Aborted."; exit 0; }
|
|
|
|
echo ""
|
|
echo "[1/4] Writing image to disk..."
|
|
${pkgs.qemu}/bin/qemu-img convert -p -S 4k -f qcow2 -O raw "$IMAGE_FILE" "$TO_DISK"
|
|
|
|
echo "[2/4] Fixing GPT backup header..."
|
|
${pkgs.gptfdisk}/bin/sgdisk -e "$TO_DISK"
|
|
|
|
echo "[3/4] Expanding Windows partition (partition 3) to fill disk..."
|
|
${pkgs.parted}/bin/parted -s "$TO_DISK" resizepart 3 100%
|
|
|
|
if [[ "$TO_DISK" == *nvme* ]] || [[ "$TO_DISK" == *mmcblk* ]]; then
|
|
WIN_PART="''${TO_DISK}p3"
|
|
else
|
|
WIN_PART="''${TO_DISK}3"
|
|
fi
|
|
|
|
echo "[4/4] Expanding NTFS filesystem on $WIN_PART..."
|
|
${pkgs.ntfs3g}/bin/ntfsresize --force --no-action "$WIN_PART"
|
|
echo "y" | ${pkgs.ntfs3g}/bin/ntfsresize --force "$WIN_PART"
|
|
|
|
echo ""
|
|
echo "Done. $TO_DISK is ready to boot."
|
|
fi
|
|
|
|
if [[ -n "$TO_REMOTE_DISK" ]]; then
|
|
echo ""
|
|
|
|
REMOTE_HOST="''${TO_REMOTE_DISK%%:*}"
|
|
REMOTE_DISK="''${TO_REMOTE_DISK#*:}"
|
|
|
|
[[ -z "$REMOTE_HOST" || -z "$REMOTE_DISK" ]] && { echo "Error: --to-remote-disk format is user@host:/dev/sdX"; exit 1; }
|
|
|
|
echo "=== writing to remote disk ==="
|
|
echo "Host: $REMOTE_HOST"
|
|
echo "Disk: $REMOTE_DISK"
|
|
echo ""
|
|
echo "WARNING: This will DESTROY all data on $REMOTE_HOST:$REMOTE_DISK"
|
|
read -rp "Continue? [y/N] " confirm
|
|
[[ "$confirm" != "y" && "$confirm" != "Y" ]] && { echo "Aborted."; exit 0; }
|
|
|
|
echo ""
|
|
echo "[1/4] Streaming image to remote disk..."
|
|
${pkgs.qemu}/bin/qemu-img convert -f qcow2 -O raw "$IMAGE_FILE" /dev/stdout \
|
|
| ssh "$REMOTE_HOST" "dd of=$REMOTE_DISK bs=4M status=progress oflag=direct"
|
|
|
|
echo "[2/4] Fixing GPT backup header..."
|
|
ssh "$REMOTE_HOST" "sgdisk -e $REMOTE_DISK"
|
|
|
|
echo "[3/4] Expanding Windows partition (partition 3) to fill disk..."
|
|
ssh "$REMOTE_HOST" "parted -s $REMOTE_DISK resizepart 3 100%"
|
|
|
|
if [[ "$REMOTE_DISK" == *nvme* ]] || [[ "$REMOTE_DISK" == *mmcblk* ]]; then
|
|
REMOTE_WIN_PART="''${REMOTE_DISK}p3"
|
|
else
|
|
REMOTE_WIN_PART="''${REMOTE_DISK}3"
|
|
fi
|
|
|
|
echo "[4/4] Expanding NTFS filesystem on $REMOTE_WIN_PART..."
|
|
ssh "$REMOTE_HOST" "echo y | ntfsresize --force $REMOTE_WIN_PART"
|
|
|
|
echo ""
|
|
echo "Done. $REMOTE_HOST:$REMOTE_DISK is ready to boot."
|
|
fi
|
|
'';
|
|
|
|
apps.${system}.default = {
|
|
type = "app";
|
|
program = "${self.packages.${system}.default}/bin/vmix";
|
|
};
|
|
};
|
|
}
|