Compare commits

...
10 Commits
Author SHA1 Message Date
GuofangandClaude Sonnet 5.5 71c0c3d029 Add build-windows-base.sh to build the Windows 11 base image
Installs Windows unattended from the ISO in a scratch VM (under /var/tmp
by default), generalizes it with sysprep over SSH and stores the shut-down
disk as the read-only base image that run-windows11.py clones.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 16:12:23 +08:00
guofangandClaude Sonnet 5.5 30342b4e1b Make the staged ISO copy writable on macOS
macOS's openrsync ignores --chmod, so the staged directories kept the
read-only permissions of the UDF mount and wimlib-imagex split failed
with Permission denied. Run chmod -R u+rwX on the staging directory on
Darwin.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 21:43:53 +08:00
GuofangandClaude Sonnet 5.5 592c7294c3 Add a windows11 target that downloads the Windows 11 ISO with headless Chromium
Move the Node.js script and its dependencies into nodejs/ and let
download-images.sh call it for the image architecture.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 16:45:36 +08:00
GuofangandClaude Sonnet 5.5 5e58722aca Add an openssh-win target to download the Win32-OpenSSH zip
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 14:28:13 +08:00
GuofangandClaude Sonnet 5.5 f5881cd5c8 Default the work directory to /var/tmp
/tmp is memory-backed on some systems, and the work directory holds about
10 GiB.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 13:38:47 +08:00
GuofangandClaude Sonnet 5.5 e1148cce7b Drop the redundant mkdir of the staged sources directory
rsync already creates sources/ because the ISO has one.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 13:28:44 +08:00
GuofangandClaude Sonnet 5.5 d9707fd57b Stage the Windows ISO files with rsync before copying them into the image
Copy the ISO without install.wim to a staging directory, split install.wim
into it, and fill the image with one recursive mcopy instead of a mmd and
mcopy per directory. rsync --chmod keeps the staged directories writable so
that cleanup can remove them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 13:26:24 +08:00
GuofangandClaude Sonnet 5.5 dd29fd92a7 Drop .install from the Windows install disk image name
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 09:28:54 +08:00
GuofangandClaude Sonnet 5.5 2aae652569 Ignore aria2 progress files
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 14:29:06 +08:00
GuofangandClaude Sonnet 5.5 5468fdc033 Log each command in build-windows-install-disk.sh; build the image in place
Print the command line before every external command (hdiutil, udisksctl,
wimlib-imagex, sgdisk, mkfs.fat, mmd, mcopy and so on) so the log shows
which command is running. Write the image straight to the output path
instead of moving it from the work directory, and remove a partial output
if the run fails or is cancelled.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 14:20:31 +08:00
8 changed files with 590 additions and 49 deletions

No files matched your search

+5
View File
@@ -4,10 +4,15 @@
*.img *.img
*.vhd *.vhd
*.vhdx *.vhdx
*.zip
*.part *.part
*.part.url *.part.url
*.aria2
# Keep the intended directory layout in Git. # Keep the intended directory layout in Git.
!base-qcow2/.gitkeep !base-qcow2/.gitkeep
!install-iso/.gitkeep !install-iso/.gitkeep
!tools-iso/.gitkeep !tools-iso/.gitkeep
# Node dependencies of nodejs/get-windows-iso-url.js.
node_modules/
+46 -2
View File
@@ -30,6 +30,26 @@ arm64 hosts) and `.iso` in that order (`*Win*11*x64*.iso` or
version-sorted name is used. Check which file was picked with version-sorted name is used. Check which file was picked with
`./download-images.sh --print-path windows11 --iso`. `./download-images.sh --print-path windows11 --iso`.
To get a fresh temporary URL, `nodejs/get-windows-iso-url.js` drives Microsoft's
download page with headless Chromium (needs Node.js and an installed
Chromium, for example from Playwright):
```bash
cd nodejs
npm ci # once, installs playwright-core
node get-windows-iso-url.js # print the URL for the host architecture
node get-windows-iso-url.js --arch arm64 # choose the architecture explicitly
```
The script only prints the URL. `./download-images.sh windows11` downloads that
URL into `install-iso/` like the other images (aria2c, resumable) for the image
architecture (`VM_IMAGE_ARCH=arm64` for arm64), and skips it when a matching ISO
is already there. The `all` target does not include it.
The URL expires after about 24 hours and may be bound to the IP that requested
it, so download from the machine that ran the script. Run it with `--help` for
the language and path options.
## Query image paths ## Query image paths
The path interface prints one absolute path without downloading anything: The path interface prints one absolute path without downloading anything:
@@ -51,7 +71,8 @@ ISO (for example `Win11_25H2_English_x64_v2.iso` →
existing `*Win*11*x64*.qcow2` (`*Win*11*arm64*.qcow2` on arm64) in `base-qcow2/` is used instead. existing `*Win*11*x64*.qcow2` (`*Win*11*arm64*.qcow2` on arm64) in `base-qcow2/` is used instead.
Download URLs can be overridden with `DEBIAN_IMAGE_URL`, `ALMA_IMAGE_URL`, Download URLs can be overridden with `DEBIAN_IMAGE_URL`, `ALMA_IMAGE_URL`,
`FEDORA_IMAGE_URL`, `UBUNTU_IMAGE_URL`, and `VIRTIO_WIN_ISO_URL`. Each may `FEDORA_IMAGE_URL`, `UBUNTU_IMAGE_URL`, `VIRTIO_WIN_ISO_URL`, and
`OPENSSH_WIN_ZIP_URL`. Each may
list several mirrors of the same file, separated by whitespace; the Fedora list several mirrors of the same file, separated by whitespace; the Fedora
image defaults to the TUNA, BFSU and NJU mirrors, and the Ubuntu image to the image defaults to the TUNA, BFSU and NJU mirrors, and the Ubuntu image to the
TUNA, NJU and official cloud-images.ubuntu.com mirrors. TUNA, NJU and official cloud-images.ubuntu.com mirrors.
@@ -63,6 +84,10 @@ download is only resumed with the same downloader and mirror list; otherwise
it starts over. The Fedora and Ubuntu checksums can be overridden with it starts over. The Fedora and Ubuntu checksums can be overridden with
`FEDORA_IMAGE_SHA256` and `UBUNTU_IMAGE_SHA256` when using a custom image. `FEDORA_IMAGE_SHA256` and `UBUNTU_IMAGE_SHA256` when using a custom image.
The `openssh-win` target downloads the Win32-OpenSSH zip matching the
architecture into `tools-iso/`: `OpenSSH-Win64.zip` on amd64 hosts and
`OpenSSH-ARM64.zip` on arm64 hosts.
## Windows installation disk image ## Windows installation disk image
QEMU's emulated USB CD-ROM reads the Windows ISO at only a few MB/s on macOS. QEMU's emulated USB CD-ROM reads the Windows ISO at only a few MB/s on macOS.
@@ -79,8 +104,27 @@ sudo apt install wimtools dosfstools gdisk mtools p7zip-full
./build-windows-install-disk.sh [--force] install-iso/Windows11_..._arm64_....iso ./build-windows-install-disk.sh [--force] install-iso/Windows11_..._arm64_....iso
``` ```
The output defaults to the ISO path with `.install.img` in place of `.iso`. The The output defaults to the ISO path with `.img` in place of `.iso`. The
image is built without root. The ISO has to be read as UDF (its ISO 9660 side only image is built without root. The ISO has to be read as UDF (its ISO 9660 side only
holds a README): macOS uses `hdiutil`, Linux uses `mount -t udf` as root, holds a README): macOS uses `hdiutil`, Linux uses `mount -t udf` as root,
otherwise `udisksctl`, otherwise `7z` extracts it into a temporary directory otherwise `udisksctl`, otherwise `7z` extracts it into a temporary directory
(which needs the free space of the whole ISO). The `*.img` file is ignored by Git. (which needs the free space of the whole ISO). The `*.img` file is ignored by Git.
## Windows base image
`run-windows11.py` creates a missing system disk as a QCOW2 overlay on the base
image that `./download-images.sh --print-path windows11` prints, so a new VM does
not run Windows Setup again. `build-windows-base.sh` makes that base image: it
installs Windows unattended from the ISO in `install-iso/`, generalizes it with
`sysprep` over SSH, and stores the shut-down disk read-only.
```bash
./build-windows-base.sh [--force] [--no-sysprep] [--compress] [OUTPUT.qcow2]
```
It refuses to replace an existing base image without `--force`, because the
overlays of existing VMs still point at that file. Build to another `OUTPUT`
(for example with a version in its name) and select it with
`WIN11_VM_BASE_IMAGE` instead. The installation runs in a scratch directory
`/var/tmp/build-windows-base.*` (set `BASE_BUILD_TMPDIR` to use another parent; about 25 GB are needed) and needs a private key that matches
the public key put on the answer media.
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env bash
# Build the Windows 11 base QCOW2 image that run-windows11.py clones.
#
# run-windows11.py creates a missing system disk as a QCOW2 overlay on this
# base image, so a new VM starts in seconds instead of running Windows Setup
# again. The base image is made once:
# 1. run-windows11.py --install-from-iso installs Windows unattended into a
# scratch state directory (Autounattend.xml, Configure-Windows.ps1: VirtIO
# drivers, OpenSSH, the administrator key from build-seed.sh);
# 2. once SSH answers, this script runs sysprep /generalize over SSH, which
# removes the machine identity and shuts the guest down;
# 3. the shut-down disk is converted into the output file and made read-only.
# The first boot of every clone then runs the specialize and OOBE passes of the
# answer file below (host name, account, automatic logon) without any prompt.
#
# Needs the Windows ISO in install-iso/ (see README.md), qemu-img, ssh, scp and
# a private key that matches the public key put on the answer media (the
# default ~/.ssh keys, or SSH_PUBLIC_KEY_FILE for the public half and
# SSH_PRIVATE_KEY_FILE for this script).
#
# Usage: build-windows-base.sh [--force] [--no-sysprep] [--keep-work] [--compress]
# [--ssh-port PORT] [OUTPUT.qcow2]
# OUTPUT defaults to the path that "download-images.sh --print-path windows11"
# prints, which is where run-windows11.py looks for the base image.
set -euo pipefail
images_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_dir="$(cd -- "${images_dir}/.." && pwd)"
launcher="${repo_dir}/run-windows11.py"
downloader="${images_dir}/download-images.sh"
usage() {
cat <<EOF
Usage: $(basename "$0") [--force] [--no-sysprep] [--keep-work] [--compress]
[--ssh-port PORT] [OUTPUT.qcow2]
Install Windows 11 from the ISO, generalize it with sysprep and store the disk
as the read-only base image that run-windows11.py clones.
--force Replace OUTPUT if it exists
--no-sysprep Skip sysprep and only shut the guest down; clones then share
the machine SID and host name (fine for stand-alone VMs)
--keep-work Keep the scratch state directory (also kept on failure)
--compress Compress the output (smaller, but clones read it slower)
--ssh-port PORT Host port forwarded to the guest's SSH during the build
(default: 2299; overrides BASE_BUILD_SSH_PORT)
-h, --help Show this help
Environment variables:
SSH_PRIVATE_KEY_FILE Private key to log in with (default: ssh's own choice)
SSH_PUBLIC_KEY_FILE Public key put on the answer media (see build-seed.sh)
VM_IMAGE_ARCH amd64 or arm64 (default: the host's)
BASE_BUILD_TMPDIR Parent of the scratch state directory, which needs about
25 GB (default: /var/tmp)
BASE_BUILD_TIMEOUT Seconds to wait for the installation (default: 5400)
EOF
}
die() {
printf 'Error: %s\n' "$1" >&2
exit 1
}
force=0
sysprep=1
keep_work=0
compress=0
ssh_port="${BASE_BUILD_SSH_PORT:-2299}"
output=""
while (($#)); do
case "$1" in
-h|--help) usage; exit 0 ;;
--force) force=1 ;;
--no-sysprep) sysprep=0 ;;
--keep-work) keep_work=1 ;;
--compress) compress=1 ;;
--ssh-port) shift; ssh_port="${1:?--ssh-port needs a value}" ;;
--ssh-port=*) ssh_port="${1#*=}" ;;
-*) usage >&2; exit 2 ;;
*) [[ -z "$output" ]] || { usage >&2; exit 2; }; output="$1" ;;
esac
shift
done
[[ "$ssh_port" =~ ^[0-9]+$ ]] || die "--ssh-port must be a number: ${ssh_port}"
for tool in qemu-img ssh scp python3; do
command -v "$tool" >/dev/null || die "${tool} is not installed."
done
[[ -x "$launcher" ]] || die "run-windows11.py was not found: ${launcher}"
[[ -x "$downloader" ]] || die "download-images.sh was not found: ${downloader}"
output="${output:-$("$downloader" --print-path windows11)}"
if [[ -e "$output" && $force -eq 0 ]]; then
die "${output} exists (it may be the backing file of existing VM disks); use --force or another OUTPUT."
fi
case "${VM_IMAGE_ARCH:-$(uname -m)}" in
arm64|aarch64) arch=arm64 ;;
x86_64|amd64) arch=amd64 ;;
*) die "unsupported architecture: ${VM_IMAGE_ARCH:-$(uname -m)}" ;;
esac
total_steps=5
step_number=0
step() {
step_number=$((step_number + 1))
printf '[%d/%d] %s\n' "$step_number" "$total_steps" "$*"
}
note() {
printf ' %s\n' "$*" >&2
}
mkdir -p "${BASE_BUILD_TMPDIR:-/var/tmp}"
work="$(mktemp -d "${BASE_BUILD_TMPDIR:-/var/tmp}/build-windows-base.XXXXXX")"
pid_file="${work}/qemu.pid"
succeeded=0
ssh_options=(-o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null
-o LogLevel=ERROR -o ConnectTimeout=5)
[[ -z "${SSH_PRIVATE_KEY_FILE:-}" ]] || ssh_options+=(-i "$SSH_PRIVATE_KEY_FILE")
guest_ssh() { ssh -n "${ssh_options[@]}" -p "$ssh_port" guofang@127.0.0.1 "$@"; }
vm_running() {
[[ -f "$pid_file" ]] && kill -0 "$(cat "$pid_file")" 2>/dev/null
}
cleanup() {
if vm_running; then
kill "$(cat "$pid_file")" 2>/dev/null || true
fi
if ((succeeded && !keep_work)); then
rm -rf "$work"
else
note "Scratch directory kept: ${work}"
fi
}
trap cleanup EXIT
# Poll "$@" every 10 s until it succeeds; give up after $1 seconds.
wait_for() {
local limit="$1"
shift
local deadline=$((SECONDS + limit))
until "$@"; do
((SECONDS < deadline)) || return 1
sleep 10
done
}
# Only the SSH rule is forwarded so that a running VM's RDP/DSH ports do not clash.
printf 'SSH %s 22\n' "$ssh_port" >"${work}/forwards.conf"
step "Installing Windows 11 from the ISO (unattended; takes a while)"
WIN11_VM_FORWARDS="${work}/forwards.conf" "$launcher" \
--daemon --vnc=127.0.0.1:90 --install-from-iso --arch "$arch" --state-dir "$work"
step "Waiting for the guest to finish its first-logon setup (SSH answers)"
note "Screen: VNC 127.0.0.1:5990; console log: ${work}/console.log"
if ! wait_for "${BASE_BUILD_TIMEOUT:-5400}" guest_ssh 'echo ready' >/dev/null 2>&1; then
die "SSH did not answer in time; inspect the guest over VNC."
fi
# The answer file the clones use on their first boot. generalize removes the
# machine identity but keeps the guofang account, so OOBE only needs the
# language, the EULA and the automatic logon; the specialize pass restores the
# settings Autounattend.xml applies (host name, time zone, Remote Desktop).
write_sysprep_answer() {
cat >"$1" <<EOF
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend">
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="${arch}"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<ComputerName>WIN11</ComputerName>
<TimeZone>China Standard Time</TimeZone>
</component>
<component name="Microsoft-Windows-TerminalServices-LocalSessionManager"
processorArchitecture="${arch}" publicKeyToken="31bf3856ad364e35" language="neutral"
versionScope="nonSxS">
<fDenyTSConnections>false</fDenyTSConnections>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-International-Core" processorArchitecture="${arch}"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<InputLocale>en-US</InputLocale>
<SystemLocale>en-US</SystemLocale>
<UILanguage>en-US</UILanguage>
<UserLocale>en-US</UserLocale>
</component>
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="${arch}"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<SkipMachineOOBE>true</SkipMachineOOBE>
<SkipUserOOBE>true</SkipUserOOBE>
<ProtectYourPC>3</ProtectYourPC>
</OOBE>
<AutoLogon>
<Enabled>true</Enabled>
<Username>guofang</Username>
<Password>
<Value>wang</Value>
<PlainText>true</PlainText>
</Password>
</AutoLogon>
</component>
</settings>
</unattend>
EOF
}
if ((sysprep)); then
step "Generalizing the guest with sysprep"
write_sysprep_answer "${work}/sysprep-unattend.xml"
scp "${ssh_options[@]}" -P "$ssh_port" "${work}/sysprep-unattend.xml" \
guofang@127.0.0.1:C:/Windows/Temp/sysprep-unattend.xml
# The connection drops when the guest shuts down, which ssh reports as 255.
status=0
guest_ssh 'C:\Windows\System32\Sysprep\sysprep.exe /generalize /oobe /shutdown /quiet /unattend:C:\Windows\Temp\sysprep-unattend.xml' || status=$?
if ((status != 0 && status != 255)); then
guest_ssh 'type C:\Windows\System32\Sysprep\Panther\setuperr.log' >&2 || true
die "sysprep failed (exit code ${status}); the log is above."
fi
else
step "Shutting the guest down (sysprep skipped)"
guest_ssh 'shutdown /s /t 5' || true
fi
step "Waiting for the guest to power off"
if ! wait_for 900 bash -c '! kill -0 "$(cat "$1")" 2>/dev/null' _ "$pid_file"; then
if ((sysprep)); then
guest_ssh 'type C:\Windows\System32\Sysprep\Panther\setuperr.log' >&2 || true
fi
die "the guest did not power off; sysprep probably failed (log above, if SSH still answers)."
fi
step "Converting the disk to ${output}"
mkdir -p "$(dirname "$output")"
convert_options=(-f qcow2 -O qcow2)
((compress)) && convert_options+=(-c)
rm -f "${output}.part"
qemu-img convert -p "${convert_options[@]}" "${work}/disk.qcow2" "${output}.part"
[[ ! -e "$output" ]] || chmod u+w "$output"
mv -f "${output}.part" "$output"
chmod a-w "$output"
succeeded=1
printf 'Windows 11 base image is ready: %s\n' "$output"
+66 -44
View File
@@ -15,10 +15,10 @@
# #
# Dependencies # Dependencies
# macOS (Homebrew) wimlib dosfstools gptfdisk mtools # macOS (Homebrew) wimlib dosfstools gptfdisk mtools
# Debian wimtools dosfstools gdisk mtools udisks2 # Debian wimtools dosfstools gdisk mtools rsync udisks2
# #
# Usage: build-windows-install-disk.sh [--force] ISO [OUTPUT.img] # Usage: build-windows-install-disk.sh [--force] ISO [OUTPUT.img]
# OUTPUT defaults to the ISO path with ".install.img" in place of ".iso". # OUTPUT defaults to the ISO path with ".img" in place of ".iso".
set -euo pipefail set -euo pipefail
usage() { usage() {
@@ -39,7 +39,7 @@ if ((${#args[@]} < 1 || ${#args[@]} > 2)); then
exit 2 exit 2
fi fi
iso="${args[0]}" iso="${args[0]}"
output="${args[1]:-${iso%.*}.install.img}" output="${args[1]:-${iso%.*}.img}"
die() { die() {
printf 'Error: %s\n' "$1" >&2 printf 'Error: %s\n' "$1" >&2
@@ -60,6 +60,18 @@ note() {
printf ' %s\n' "$*" >&2 printf ' %s\n' "$*" >&2
} }
# show logs a command line before it runs; run logs it and then runs it. Use show
# alone where the output of the command is captured.
show() {
local line
printf -v line '%q ' "$@"
note "\$ ${line% }"
}
run() {
show "$@"
"$@"
}
os="$(uname -s)" os="$(uname -s)"
case "$os" in case "$os" in
Darwin|Linux) ;; Darwin|Linux) ;;
@@ -76,7 +88,7 @@ for dir in ${brew_prefix:+"$brew_prefix/sbin"} /opt/homebrew/sbin /usr/local/sbi
[[ -d "$dir" ]] && PATH="$PATH:$dir" [[ -d "$dir" ]] && PATH="$PATH:$dir"
done done
tools=(wimlib-imagex mkfs.fat sgdisk mcopy mmd) tools=(wimlib-imagex mkfs.fat sgdisk mcopy rsync)
[[ "$os" == Darwin ]] && tools+=(hdiutil) [[ "$os" == Darwin ]] && tools+=(hdiutil)
missing=() missing=()
for tool in "${tools[@]}"; do for tool in "${tools[@]}"; do
@@ -86,7 +98,7 @@ if ((${#missing[@]} > 0)); then
if [[ "$os" == Darwin ]]; then if [[ "$os" == Darwin ]]; then
hint="brew install wimlib dosfstools gptfdisk mtools" hint="brew install wimlib dosfstools gptfdisk mtools"
else else
hint="apt install wimtools dosfstools gdisk mtools" hint="apt install wimtools dosfstools gdisk mtools rsync"
fi fi
die "missing tools: ${missing[*]} ($hint)" die "missing tools: ${missing[*]} ($hint)"
fi fi
@@ -99,19 +111,27 @@ fi
# stat prints a file size with -f %z on macOS (BSD) and -c %s on Linux (GNU). # stat prints a file size with -f %z on macOS (BSD) and -c %s on Linux (GNU).
if [[ "$os" == Darwin ]]; then size_args=(-f %z); else size_args=(-c %s); fi if [[ "$os" == Darwin ]]; then size_args=(-f %z); else size_args=(-c %s); fi
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/win11-install-disk.XXXXXX")" # The work directory holds about 10 GiB, so it defaults to /var/tmp: /tmp is
# memory-backed on some systems (Debian 13, Fedora, Arch).
work_dir="$(mktemp -d "${TMPDIR:-/var/tmp}/win11-install-disk.XXXXXX")"
mount_dir="$work_dir/iso" mount_dir="$work_dir/iso"
mkdir "$mount_dir" mkdir "$mount_dir"
note "work directory: $work_dir" note "work directory: $work_dir"
mount_method="" mount_method=""
loop_device="" loop_device=""
# Set while the output image is being written, so that a failed or cancelled
# run does not leave a half-built image behind.
partial_output=""
cleanup() { cleanup() {
[[ -z "$partial_output" ]] || rm -f "$partial_output"
case "$mount_method" in case "$mount_method" in
hdiutil) hdiutil detach -quiet "$mount_dir" >/dev/null 2>&1 || true ;; hdiutil) hdiutil detach -quiet "$mount_dir" >/dev/null 2>&1 || true ;;
udisks) udisks)
udisksctl unmount -b "$loop_device" >/dev/null 2>&1 || true udisksctl unmount -b "$loop_device" >/dev/null 2>&1 || true
udisksctl loop-delete -b "$loop_device" >/dev/null 2>&1 || true ;; udisksctl loop-delete -b "$loop_device" >/dev/null 2>&1 || true ;;
esac esac
# Staged directories may be read-only when the run was cancelled early.
chmod -R u+rwX "$work_dir" >/dev/null 2>&1 || true
rm -rf "$work_dir" rm -rf "$work_dir"
} }
trap cleanup EXIT trap cleanup EXIT
@@ -127,6 +147,7 @@ mount_with_udisks() {
return 1 return 1
fi fi
local setup local setup
show udisksctl loop-setup -r -f "$iso"
if ! setup="$(udisksctl loop-setup -r -f "$iso" 2>&1)"; then if ! setup="$(udisksctl loop-setup -r -f "$iso" 2>&1)"; then
note "udisksctl loop-setup failed: $setup" note "udisksctl loop-setup failed: $setup"
return 1 return 1
@@ -142,8 +163,10 @@ mount_with_udisks() {
# A desktop session may have mounted the loop device already, which makes # A desktop session may have mounted the loop device already, which makes
# udisksctl mount fail with AlreadyMounted; use that mount point then. # udisksctl mount fail with AlreadyMounted; use that mount point then.
local mounted point local mounted point
show lsblk -no MOUNTPOINT "$loop_device"
point="$(lsblk -no MOUNTPOINT "$loop_device" 2>/dev/null | head -n 1)" point="$(lsblk -no MOUNTPOINT "$loop_device" 2>/dev/null | head -n 1)"
if [[ -z "$point" ]]; then if [[ -z "$point" ]]; then
show udisksctl mount -b "$loop_device" -t udf -o ro
if mounted="$(udisksctl mount -b "$loop_device" -t udf -o ro 2>&1)"; then if mounted="$(udisksctl mount -b "$loop_device" -t udf -o ro 2>&1)"; then
# Some udisks versions end the message with a period, others do not. # Some udisks versions end the message with a period, others do not.
point="$(sed -n 's/^Mounted .* at \(.*\)$/\1/p' <<<"$mounted")" point="$(sed -n 's/^Mounted .* at \(.*\)$/\1/p' <<<"$mounted")"
@@ -157,7 +180,7 @@ mount_with_udisks() {
fi fi
if [[ ! -d "$point" ]]; then if [[ ! -d "$point" ]]; then
note "no usable mount point for $loop_device (parsed mount point: '${point}')" note "no usable mount point for $loop_device (parsed mount point: '${point}')"
udisksctl loop-delete -b "$loop_device" >/dev/null 2>&1 || true run udisksctl loop-delete -b "$loop_device" >/dev/null 2>&1 || true
mount_method="" mount_method=""
return 1 return 1
fi fi
@@ -169,7 +192,7 @@ step "Reading the ISO $iso (it is UDF, so it has to be mounted)"
note "ISO size: $(stat -L "${size_args[@]}" "$iso") bytes" note "ISO size: $(stat -L "${size_args[@]}" "$iso") bytes"
if [[ "$os" == Darwin ]]; then if [[ "$os" == Darwin ]]; then
note "mounting with hdiutil at $mount_dir" note "mounting with hdiutil at $mount_dir"
hdiutil attach -readonly -nobrowse -mountpoint "$mount_dir" "$iso" >/dev/null run hdiutil attach -readonly -nobrowse -mountpoint "$mount_dir" "$iso" >/dev/null
mount_method=hdiutil mount_method=hdiutil
else else
note "mounting with udisksctl" note "mounting with udisksctl"
@@ -183,20 +206,31 @@ install_wim="$mount_dir/sources/install.wim"
[[ -d "$mount_dir/efi/boot" ]] || die "efi/boot was not found in the ISO; is it a UEFI Windows ISO?" [[ -d "$mount_dir/efi/boot" ]] || die "efi/boot was not found in the ISO; is it a UEFI Windows ISO?"
note "install.wim: $(stat "${size_args[@]}" "$install_wim") bytes" note "install.wim: $(stat "${size_args[@]}" "$install_wim") bytes"
# The staging directory holds everything that goes into the image: the ISO files
# without install.wim, and the install.swm parts in sources/.
stage_dir="$work_dir/stage"
mkdir "$stage_dir"
step "Staging the ISO files without install.wim"
# The UDF mount is read-only, so its directories are too; --chmod keeps the copies
# writable, otherwise cleanup cannot remove them.
run rsync -rt --chmod=u+rwX --exclude=/sources/install.wim "$mount_dir/" "$stage_dir/"
if [[ "$os" == Darwin ]]; then
# macOS's openrsync ignores --chmod, so make the copies writable here (the split
# below writes into sources/).
run chmod -R u+rwX "$stage_dir"
fi
# Parts must stay below the 4 GiB FAT32 file limit; wimlib takes MiB. # Parts must stay below the 4 GiB FAT32 file limit; wimlib takes MiB.
step "Splitting install.wim into install.swm parts (FAT32 files must stay below 4 GiB)" step "Splitting install.wim into install.swm parts (FAT32 files must stay below 4 GiB)"
split_dir="$work_dir/split" run wimlib-imagex split "$install_wim" "$stage_dir/sources/install.swm" 3800 >/dev/null
mkdir "$split_dir" for part in "$stage_dir"/sources/install*.swm; do
wimlib-imagex split "$install_wim" "$split_dir/install.swm" 3800 >/dev/null
for part in "$split_dir"/install*.swm; do
note "$(basename "$part"): $(stat "${size_args[@]}" "$part") bytes" note "$(basename "$part"): $(stat "${size_args[@]}" "$part") bytes"
done done
# Size the image: everything but install.wim, the split parts, and 5% plus 64 MiB # Size the image: the staged files, and 5% plus 64 MiB of room for FAT32
# of room for FAT32 metadata and partition alignment. # metadata and partition alignment.
content_bytes=$( content_bytes=$(
{ find "$mount_dir" -type f ! -path "$install_wim" -exec stat "${size_args[@]}" {} + find "$stage_dir" -type f -exec stat "${size_args[@]}" {} + |
find "$split_dir" -type f -exec stat "${size_args[@]}" {} +; } |
awk '{ total += $1 } END { printf "%d", total }' awk '{ total += $1 } END { printf "%d", total }'
) )
mib=$((1024 * 1024)) mib=$((1024 * 1024))
@@ -204,42 +238,30 @@ image_mib=$(( (content_bytes + content_bytes / 20) / mib + 64 ))
step "Creating a $image_mib MiB image with a GPT and one FAT32 partition" step "Creating a $image_mib MiB image with a GPT and one FAT32 partition"
note "content to copy: $content_bytes bytes (without install.wim)" note "content to copy: $content_bytes bytes (without install.wim)"
image="$work_dir/disk.img" image="$output"
truncate -s "${image_mib}m" "$image" run mkdir -p "$(dirname -- "$output")"
sgdisk -o -n 1:2048:0 -t 1:0700 -c 1:WINPE "$image" >/dev/null # --force replaces an existing image; truncate alone would keep its old content.
run rm -f "$image"
partial_output="$image"
run truncate -s "${image_mib}m" "$image"
run sgdisk -o -n 1:2048:0 -t 1:0700 -c 1:WINPE "$image" >/dev/null
show sgdisk -i 1 "$image"
sectors=$(sgdisk -i 1 "$image" | awk '/^Partition size:/ { print $3 }') sectors=$(sgdisk -i 1 "$image" | awk '/^Partition size:/ { print $3 }')
[[ -n "$sectors" ]] || die "could not read the partition size" [[ -n "$sectors" ]] || die "could not read the partition size"
mkfs.fat -F 32 -n WINPE --offset 2048 "$image" $((sectors / 2)) >/dev/null run mkfs.fat -F 32 -n WINPE --offset 2048 "$image" $((sectors / 2)) >/dev/null
# mtools addresses the partition by its byte offset inside the image. # mtools addresses the partition by its byte offset inside the image.
mtools_image="$image@@$((2048 * 512))" mtools_image="$image@@$((2048 * 512))"
note "partition: $sectors sectors, formatted as FAT32 (label WINPE)" note "partition: $sectors sectors, formatted as FAT32 (label WINPE)"
export MTOOLS_SKIP_CHECK=1 export MTOOLS_SKIP_CHECK=1
copy_dir() { # copy_dir SOURCE_DIR TARGET_DIR_IN_IMAGE
local source="$1" target="$2" files=() entry
while IFS= read -r -d '' entry; do
files+=("$entry")
done < <(find "$source" -maxdepth 1 -type f ! -path "$install_wim" -print0)
if ((${#files[@]} > 0)); then
mcopy -Q -m -i "$mtools_image" "${files[@]}" "::$target/"
fi
}
step "Copying the installation files into the image" step "Copying the installation files into the image"
copy_dir "$mount_dir" "" entries=()
dir_count=0 while IFS= read -r -d '' entry; do
while IFS= read -r -d '' dir; do entries+=("$entry")
relative="${dir#"$mount_dir"/}" done < <(find "$stage_dir" -mindepth 1 -maxdepth 1 -print0)
dir_count=$((dir_count + 1)) note "\$ mcopy -s -Q -m -i $mtools_image <${#entries[@]} entries from the staging directory> ::/"
mmd -i "$mtools_image" "::$relative" mcopy -s -Q -m -i "$mtools_image" "${entries[@]}" "::/"
copy_dir "$dir" "$relative"
done < <(find "$mount_dir" -mindepth 1 -type d -print0 | sort -z)
note "copied $dir_count directories"
note "copying the install*.swm parts to sources/"
mcopy -Q -m -i "$mtools_image" "$split_dir"/install*.swm "::sources/"
step "Moving the image to $output" partial_output=""
mkdir -p "$(dirname -- "$output")"
mv -f "$image" "$output"
printf 'Built Windows 11 installation disk: %s\n' "$output" printf 'Built Windows 11 installation disk: %s\n' "$output"
+67 -3
View File
@@ -57,6 +57,15 @@ fi
virtio_image="${tools_iso_dir}/virtio-win.iso" virtio_image="${tools_iso_dir}/virtio-win.iso"
# Win32-OpenSSH names its zip after the architecture: Win64 for x64 and ARM64
# for arm64.
if [[ "$image_arch" == arm64 ]]; then
openssh_zip=OpenSSH-ARM64.zip
else
openssh_zip=OpenSSH-Win64.zip
fi
openssh_image="${tools_iso_dir}/${openssh_zip}"
# Windows 11 media is named after the architecture: x64 on amd64 hosts and # Windows 11 media is named after the architecture: x64 on amd64 hosts and
# Arm64 on arm64 hosts. # Arm64 on arm64 hosts.
if [[ "$image_arch" == arm64 ]]; then if [[ "$image_arch" == arm64 ]]; then
@@ -111,6 +120,7 @@ debian_url="${DEBIAN_IMAGE_URL:-$debian_url_default}"
alma_url="${ALMA_IMAGE_URL:-$alma_url_default}" alma_url="${ALMA_IMAGE_URL:-$alma_url_default}"
fedora_url="${FEDORA_IMAGE_URL:-$fedora_url_default}" fedora_url="${FEDORA_IMAGE_URL:-$fedora_url_default}"
ubuntu_url="${UBUNTU_IMAGE_URL:-$ubuntu_url_default}" ubuntu_url="${UBUNTU_IMAGE_URL:-$ubuntu_url_default}"
openssh_url="${OPENSSH_WIN_ZIP_URL:-https://github.com/PowerShell/Win32-OpenSSH/releases/download/10.0.0.0p2-Preview/${openssh_zip}}"
virtio_url="${VIRTIO_WIN_ISO_URL:-https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso}" virtio_url="${VIRTIO_WIN_ISO_URL:-https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso}"
debian_sha512="${DEBIAN_IMAGE_SHA512:-$debian_sha512_default}" debian_sha512="${DEBIAN_IMAGE_SHA512:-$debian_sha512_default}"
alma_sha256="${ALMA_IMAGE_SHA256:-$alma_sha256_default}" alma_sha256="${ALMA_IMAGE_SHA256:-$alma_sha256_default}"
@@ -120,7 +130,7 @@ force_download=false
usage() { usage() {
cat <<EOF cat <<EOF
Usage: $(basename "$0") [--force] [all|debian13|almalinux10|fedora44|ubuntu2604|virtio-win] ... Usage: $(basename "$0") [--force] [all|debian13|almalinux10|fedora44|ubuntu2604|virtio-win|openssh-win|windows11] ...
$(basename "$0") --print-path TARGET [--iso] $(basename "$0") --print-path TARGET [--iso]
Download one or more shared VM images. With no arguments, all targets are Download one or more shared VM images. With no arguments, all targets are
@@ -131,6 +141,10 @@ When aria2c is installed and an image has several mirrors, the image is
downloaded from all of them at once, with one connection per mirror. downloaded from all of them at once, with one connection per mirror.
Otherwise curl or wget downloads from the mirrors in order. Otherwise curl or wget downloads from the mirrors in order.
The windows11 target is not part of "all". It downloads the Windows 11 ISO for
the image architecture with nodejs/get-windows-iso-url.js, which needs Node.js,
\`npm ci\` in nodejs/ and an installed Chromium (see README.md).
--force removes existing files and partial downloads for the selected targets, --force removes existing files and partial downloads for the selected targets,
then downloads them again from the beginning. then downloads them again from the beginning.
--print-path prints only the absolute image path without downloading it. --print-path prints only the absolute image path without downloading it.
@@ -150,6 +164,7 @@ print_image_path() {
fedora44) printf '%s\n' "$fedora_image" ;; fedora44) printf '%s\n' "$fedora_image" ;;
ubuntu2604) printf '%s\n' "$ubuntu_image" ;; ubuntu2604) printf '%s\n' "$ubuntu_image" ;;
virtio-win) printf '%s\n' "$virtio_image" ;; virtio-win) printf '%s\n' "$virtio_image" ;;
openssh-win) printf '%s\n' "$openssh_image" ;;
windows11) windows11)
if [[ "$media" == iso ]]; then if [[ "$media" == iso ]]; then
windows_iso_path windows_iso_path
@@ -195,7 +210,7 @@ fi
for target in "${targets[@]}"; do for target in "${targets[@]}"; do
case "$target" in case "$target" in
all|debian13|almalinux10|fedora44|ubuntu2604|virtio-win) ;; all|debian13|almalinux10|fedora44|ubuntu2604|virtio-win|openssh-win|windows11) ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) printf 'Error: unknown download target: %s\n' "$target" >&2; usage >&2; exit 2 ;; *) printf 'Error: unknown download target: %s\n' "$target" >&2; usage >&2; exit 2 ;;
esac esac
@@ -286,6 +301,9 @@ download_file() {
local destination="$3" local destination="$3"
local checksum_algorithm="${4:-}" local checksum_algorithm="${4:-}"
local expected_checksum="${5:-}" local expected_checksum="${5:-}"
# Signed URLs change on every request; this key identifies the source for
# resuming a partial download instead of the URLs when it is given.
local source_key="${6:-}"
local partial="${destination}.part" local partial="${destination}.part"
# Records how the partial file was started, so that a partial download is # Records how the partial file was started, so that a partial download is
# only resumed with the same downloader and URLs. # only resumed with the same downloader and URLs.
@@ -329,7 +347,7 @@ download_file() {
# aria2c fills a partial file out of order, so it must not be resumed by # aria2c fills a partial file out of order, so it must not be resumed by
# curl or wget, and vice versa. # curl or wget, and vice versa.
download_state="$(printf '%s\n' "$downloader" "${urls[@]}")" download_state="$(printf '%s\n' "$downloader" "${source_key:-${urls[@]}}")"
if [[ -e "$partial" ]]; then if [[ -e "$partial" ]]; then
if [[ ! -f "$partial_url" ]] || [[ "$(<"$partial_url")" != "$download_state" ]]; then if [[ ! -f "$partial_url" ]] || [[ "$(<"$partial_url")" != "$download_state" ]]; then
printf 'Download source changed; restarting partial download.\n' printf 'Download source changed; restarting partial download.\n'
@@ -375,6 +393,41 @@ download_file() {
printf 'Downloaded %s:\n%s\n' "$label" "$destination" printf 'Downloaded %s:\n%s\n' "$label" "$destination"
} }
# The Microsoft download link is temporary, so it is fetched with headless
# Chromium only when no matching ISO exists yet. "all" does not include it.
download_windows_iso() {
local iso
local url
local name
if [[ "$force_download" == true ]]; then
printf 'Forcing a fresh download of the Windows 11 %s ISO.\n' "$windows_arch"
while iso="$(find_windows_image "$install_iso_dir" iso)"; do
rm -f -- "$iso"
done
fi
if iso="$(find_windows_image "$install_iso_dir" iso)"; then
printf 'Windows 11 %s ISO already exists; skipping:\n%s\n' "$windows_arch" "$iso"
return
fi
if ! command -v node >/dev/null 2>&1; then
printf 'Error: node is required to get the Windows 11 ISO URL.\n' >&2
exit 1
fi
url="$(node "${images_dir}/nodejs/get-windows-iso-url.js" --arch "$windows_arch")"
[[ -n "$url" ]] || { printf 'Error: no Windows 11 ISO URL.\n' >&2; exit 1; }
name="${url%%\?*}"
name="${name##*/}"
name="$(printf '%b' "${name//%/\\x}")"
download_file \
"Windows 11 ${windows_arch} ISO" \
"$url" \
"${install_iso_dir}/${name}" \
'' \
'' \
"$name"
}
if wants_target debian13; then if wants_target debian13; then
download_file \ download_file \
'Debian 13 cloud image' \ 'Debian 13 cloud image' \
@@ -417,3 +470,14 @@ if wants_target virtio-win; then
"$virtio_url" \ "$virtio_url" \
"$virtio_image" "$virtio_image"
fi fi
if wants_target openssh-win; then
download_file \
'Win32-OpenSSH zip' \
"$openssh_url" \
"$openssh_image"
fi
if [[ " ${targets[*]} " == *" windows11 "* ]]; then
download_windows_iso
fi
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env node
// Print a signed, temporary download URL for the Windows 11 ISO by driving
// Microsoft's download page with headless Chromium.
//
// Usage: node get-windows-iso-url.js [--arch x64|arm64] [--lang NAME]
// Environment:
// PLAYWRIGHT_CORE playwright-core directory (default: node_modules here)
// CHROME_PATH Chromium executable (default: newest one in ~/.cache/ms-playwright)
// The URL expires after about 24 hours and may be bound to the IP that
// requested it. Only the URL goes to stdout; logs go to stderr.
const fs = require('fs');
const os = require('os');
const path = require('path');
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node get-windows-iso-url.js [options]
Print a signed, temporary download URL for the Windows 11 ISO (only the URL
goes to stdout; logs go to stderr).
Options:
--arch <x64|arm64> Architecture, default: the host's (x64 on amd64 hosts)
--lang <name> Language, default "English (United States)",
for example "Chinese Simplified"
-h, --help Show this help
Environment:
PLAYWRIGHT_CORE playwright-core directory (default: node_modules here)
CHROME_PATH Chromium executable (default: newest one in
~/.cache/ms-playwright)
Examples:
node get-windows-iso-url.js --arch x64 --lang "Chinese Simplified"
wget -c "$(node get-windows-iso-url.js 2>/dev/null)"
The URL expires after about 24 hours and may be bound to the IP that
requested it.`);
process.exit(0);
}
const opt = (name, def) => {
const i = args.indexOf(name);
return i < 0 ? def : (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true);
};
const hostArch = { x64: 'x64', arm64: 'arm64' }[process.arch];
const arch = opt('--arch', hostArch);
const lang = opt('--lang', 'English (United States)');
const pages = { arm64: 'windows11arm64', x64: 'windows11' };
if (!pages[arch]) {
console.error(arch === undefined ? `unsupported host architecture ${process.arch}: pass --arch x64|arm64` : '--arch must be x64 or arm64');
process.exit(2);
}
const log = (...a) => console.error('[iso]', ...a);
function findPlaywright() {
if (process.env.PLAYWRIGHT_CORE) return process.env.PLAYWRIGHT_CORE;
try { return path.dirname(require.resolve('playwright-core/package.json')); } catch {}
console.error('playwright-core not found: run npm ci or set PLAYWRIGHT_CORE');
process.exit(2);
}
function findChrome() {
if (process.env.CHROME_PATH) return process.env.CHROME_PATH;
const root = path.join(os.homedir(), '.cache/ms-playwright');
const dirs = fs.existsSync(root) ? fs.readdirSync(root).filter(d => /^chromium-\d+$/.test(d)).sort() : [];
if (!dirs.length) { console.error('Chromium not found: set CHROME_PATH or run npx playwright-core install chromium'); process.exit(2); }
return path.join(root, dirs.pop(), 'chrome-linux64/chrome');
}
(async () => {
const { chromium } = require(findPlaywright());
const browser = await chromium.launch({ executablePath: findChrome(), headless: true });
try {
const ctx = await browser.newContext({
locale: 'en-US',
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36',
});
const page = await ctx.newPage();
let api = null;
page.on('response', async r => {
if (/GetProductDownloadLinks/i.test(r.url())) api = await r.json().catch(() => null);
});
log('opening', pages[arch]);
await page.goto(`https://www.microsoft.com/en-us/software-download/${pages[arch]}`, { waitUntil: 'networkidle', timeout: 60000 });
await page.click('button:has-text("Accept")', { timeout: 3000 }).catch(() => {});
// Pick the first real edition (currently the only multi-edition ISO).
const edition = await page.$$eval('#product-edition option', o => o.map(x => x.value).find(v => v && v !== 'null'));
if (!edition) throw new Error('no edition option found; the page layout may have changed');
await page.selectOption('#product-edition', edition);
await page.click('#submit-product-edition');
await page.waitForSelector('#product-languages option:not([value="null"])', { state: 'attached', timeout: 60000 });
const langs = await page.$$eval('#product-languages option', o => o.map(x => ({ value: x.value, text: x.text })));
const hit = langs.find(l => l.text === lang);
if (!hit) throw new Error(`no language "${lang}"; available: ${langs.map(l => l.text).filter(t => t !== 'Choose one').join(', ')}`);
await page.selectOption('#product-languages', hit.value);
await page.click('#submit-sku');
await page.waitForSelector('a[href*="prss.microsoft.com"]', { timeout: 60000 });
const url = await page.$eval('a[href*="prss.microsoft.com"]', a => a.href);
const opts = api && api.ProductDownloadOptions;
log(opts ? opts[0].Name : '(API response not captured)', '| expires:', api && api.DownloadExpirationDatetime);
console.log(url);
} finally {
await browser.close();
}
})().catch(e => { console.error('[iso] failed:', e.message); process.exit(1); });
+28
View File
@@ -0,0 +1,28 @@
{
"name": "win-iso",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "win-iso",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"playwright-core": "^1.64.1"
}
},
"node_modules/playwright-core": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "win-iso",
"version": "1.0.0",
"description": "",
"main": "get-windows-iso-url.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"playwright-core": "^1.64.1"
}
}