From 3208bf8e9a9fa2addca4d3cda0818d71b98ad87d Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 24 Apr 2026 14:37:07 -0700 Subject: [PATCH 1/4] feat(stack-cli): auto-install emulator deps on pull MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stack emulator pull` now detects missing QEMU/VM dependencies (and aarch64 UEFI firmware on arm64), prints the list, and — with user confirmation — installs them via brew (bootstrapping Homebrew first if absent) or apt. Skipped under --skip-snapshot. --- packages/stack-cli/src/commands/emulator.ts | 87 +++++++++++++++++++-- 1 file changed, 82 insertions(+), 5 deletions(-) diff --git a/packages/stack-cli/src/commands/emulator.ts b/packages/stack-cli/src/commands/emulator.ts index 3a67d26c07..972ed46a00 100644 --- a/packages/stack-cli/src/commands/emulator.ts +++ b/packages/stack-cli/src/commands/emulator.ts @@ -1,9 +1,10 @@ import { Command } from "commander"; -import { execFileSync, spawn } from "child_process"; +import { execFileSync, execSync, spawn } from "child_process"; import extract from "extract-zip"; import { chmodSync, createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync } from "fs"; import { homedir } from "os"; import { dirname, join, resolve } from "path"; +import { createInterface } from "readline"; import { Readable } from "stream"; import { pipeline } from "stream/promises"; import { fileURLToPath } from "url"; @@ -386,7 +387,7 @@ export function formatDuration(seconds: number): string { // --- Dependency preflight --------------------------------------------------- -type BinarySpec = { name: string, install: string }; +type BinarySpec = { name: string, linuxPkg: string, macPkg: string }; function commandExists(bin: string): boolean { try { @@ -412,13 +413,17 @@ export function platformInstallHint(linuxPkg: string, macPkg: string): string { } function bin(name: string, linuxPkg: string, macPkg: string): BinarySpec { - return { name, install: platformInstallHint(linuxPkg, macPkg) }; + return { name, linuxPkg, macPkg }; +} + +function installHint(b: BinarySpec): string { + return platformInstallHint(b.linuxPkg, b.macPkg); } function requireBinaries(commandName: string, bins: BinarySpec[]): void { const missing = bins.filter((b) => !commandExists(b.name)); if (missing.length === 0) return; - const lines = missing.map((b) => ` - ${b.name} → ${b.install}`); + const lines = missing.map((b) => ` - ${b.name} → ${installHint(b)}`); throw new CliError( `\`stack emulator ${commandName}\` requires the following missing binaries:\n${lines.join("\n")}`, ); @@ -428,8 +433,77 @@ function warnIfMissing(commandName: string, bins: BinarySpec[]): void { const missing = bins.filter((b) => !commandExists(b.name)); if (missing.length === 0) return; for (const b of missing) { - console.warn(`[stack emulator ${commandName}] optional dep '${b.name}' missing — feature degraded. Install: ${b.install}`); + console.warn(`[stack emulator ${commandName}] optional dep '${b.name}' missing — feature degraded. Install: ${installHint(b)}`); + } +} + +async function confirmPrompt(question: string): Promise { + if (!process.stdin.isTTY) { + throw new CliError("Cannot prompt for confirmation: stdin is not a TTY. Install the missing dependencies manually and retry."); + } + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolvePromise) => { + rl.question(`${question} [y/N] `, (answer) => { + rl.close(); + resolvePromise(/^y(es)?$/i.test(answer.trim())); + }); + }); +} + +async function ensureDepsForPull(arch: "arm64" | "amd64"): Promise { + const allBins = [archSpecificQemuBin(arch), ...commonVmBins(), bin("zstd", "zstd", "zstd")]; + const missingBins = allBins.filter((b) => !commandExists(b.name)); + const firmwareMissing = arch === "arm64" && !aarch64FirmwareAvailable(); + if (missingBins.length === 0 && !firmwareMissing) return; + + const platform = process.platform; + if (platform !== "darwin" && platform !== "linux") { + // Auto-install is only implemented for macOS and Linux; fall through to + // the standard error with manual install hints. + preflightForVmStart("pull", arch); + return; } + + console.log("The emulator needs the following dependencies that aren't installed:"); + for (const b of missingBins) console.log(` - ${b.name}`); + if (firmwareMissing) console.log(" - aarch64 UEFI firmware"); + console.log(); + + const pkgs = new Set(); + for (const b of missingBins) { + pkgs.add(platform === "darwin" ? b.macPkg : b.linuxPkg); + } + // macOS qemu formula bundles the aarch64 firmware; Linux needs a separate package. + if (firmwareMissing && platform === "linux") pkgs.add("qemu-efi-aarch64"); + const pkgList = Array.from(pkgs).sort(); + + const brewMissing = platform === "darwin" && !commandExists("brew"); + console.log("Proposed install plan:"); + if (brewMissing) console.log(" - install Homebrew (https://brew.sh)"); + if (platform === "darwin") console.log(` - brew install ${pkgList.join(" ")}`); + else console.log(` - sudo apt-get update && sudo apt-get install -y ${pkgList.join(" ")}`); + console.log(); + + const ok = await confirmPrompt("Proceed with install?"); + if (!ok) { + throw new CliError("Dependency install declined. Install the missing packages manually and retry."); + } + + if (brewMissing) { + console.log("\nInstalling Homebrew..."); + execSync('/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"', { + stdio: "inherit", + }); + } + + console.log("\nInstalling packages..."); + if (platform === "darwin") { + execFileSync("brew", ["install", ...pkgList], { stdio: "inherit" }); + } else { + execFileSync("sudo", ["apt-get", "update"], { stdio: "inherit" }); + execFileSync("sudo", ["apt-get", "install", "-y", ...pkgList], { stdio: "inherit" }); + } + console.log(); } function aarch64FirmwareAvailable(): boolean { @@ -509,6 +583,9 @@ export function registerEmulatorCommand(program: Command) { .option("--skip-snapshot", "Download only the qcow2; skip the one-time local snapshot capture") .action(async (opts: { arch?: string, repo?: string, branch?: string, tag?: string, pr?: string, run?: string, skipSnapshot?: boolean }) => { const arch = resolveArch(opts.arch); + if (!opts.skipSnapshot) { + await ensureDepsForPull(arch); + } const repo = opts.repo ?? DEFAULT_REPO; if (opts.run || opts.pr) { From 135b717c4d7613262539ef507cb2e326f4c529a9 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 24 Apr 2026 14:43:41 -0700 Subject: [PATCH 2/4] fix(stack-cli): address review feedback on emulator dep auto-install - Resolve `brew` by absolute path after a fresh Homebrew bootstrap, since the installer only updates shell profiles and not the current process's PATH (breaks on clean Apple Silicon installs). - Fall back to `preflightForVmStart` when stdin is not a TTY so CI/piped runs get the standard per-binary install hints instead of a generic TTY error. - Disclose in the confirmation prompt that Homebrew bootstrap executes remote code from raw.githubusercontent.com. --- packages/stack-cli/src/commands/emulator.ts | 25 +++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/stack-cli/src/commands/emulator.ts b/packages/stack-cli/src/commands/emulator.ts index 972ed46a00..ca736083f9 100644 --- a/packages/stack-cli/src/commands/emulator.ts +++ b/packages/stack-cli/src/commands/emulator.ts @@ -464,6 +464,14 @@ async function ensureDepsForPull(arch: "arm64" | "amd64"): Promise { return; } + // In non-interactive environments (CI, piped stdin) we cannot prompt, so + // surface the standard per-binary install hints instead of erroring with + // only a TTY complaint. + if (!process.stdin.isTTY) { + preflightForVmStart("pull", arch); + return; + } + console.log("The emulator needs the following dependencies that aren't installed:"); for (const b of missingBins) console.log(` - ${b.name}`); if (firmwareMissing) console.log(" - aarch64 UEFI firmware"); @@ -479,7 +487,11 @@ async function ensureDepsForPull(arch: "arm64" | "amd64"): Promise { const brewMissing = platform === "darwin" && !commandExists("brew"); console.log("Proposed install plan:"); - if (brewMissing) console.log(" - install Homebrew (https://brew.sh)"); + if (brewMissing) { + console.log(" - install Homebrew by running the official installer:"); + console.log(" /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""); + console.log(" (executes remote code from raw.githubusercontent.com — review https://brew.sh if unsure)"); + } if (platform === "darwin") console.log(` - brew install ${pkgList.join(" ")}`); else console.log(` - sudo apt-get update && sudo apt-get install -y ${pkgList.join(" ")}`); console.log(); @@ -498,7 +510,16 @@ async function ensureDepsForPull(arch: "arm64" | "amd64"): Promise { console.log("\nInstalling packages..."); if (platform === "darwin") { - execFileSync("brew", ["install", ...pkgList], { stdio: "inherit" }); + // After a fresh Homebrew bootstrap, `brew` lives at /opt/homebrew/bin + // (Apple Silicon) or /usr/local/bin (Intel); the installer only updates + // shell profiles, not the current process's PATH, so resolve it by + // absolute path when needed. + const brewBin = commandExists("brew") + ? "brew" + : existsSync("/opt/homebrew/bin/brew") + ? "/opt/homebrew/bin/brew" + : "/usr/local/bin/brew"; + execFileSync(brewBin, ["install", ...pkgList], { stdio: "inherit" }); } else { execFileSync("sudo", ["apt-get", "update"], { stdio: "inherit" }); execFileSync("sudo", ["apt-get", "install", "-y", ...pkgList], { stdio: "inherit" }); From 19d2faab6bbc317937451003bc66529344a5535f Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 24 Apr 2026 15:08:23 -0700 Subject: [PATCH 3/4] fix(stack-cli): gate Linux auto-install on apt-get; handle firmware-only miss on macOS - Only take the apt-get path when `apt-get` is actually available; otherwise fall through to the standard per-binary install hints so Fedora/Arch/Alpine/etc. users get something actionable. - On macOS arm64 when all binaries are present but aarch64 firmware is missing, add `qemu` to the brew install set so a reinstall recreates the bundled firmware; guard against an empty package list by falling back to preflight. --- packages/stack-cli/src/commands/emulator.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/stack-cli/src/commands/emulator.ts b/packages/stack-cli/src/commands/emulator.ts index ca736083f9..c0334a21e9 100644 --- a/packages/stack-cli/src/commands/emulator.ts +++ b/packages/stack-cli/src/commands/emulator.ts @@ -457,9 +457,11 @@ async function ensureDepsForPull(arch: "arm64" | "amd64"): Promise { if (missingBins.length === 0 && !firmwareMissing) return; const platform = process.platform; - if (platform !== "darwin" && platform !== "linux") { - // Auto-install is only implemented for macOS and Linux; fall through to - // the standard error with manual install hints. + // Auto-install targets macOS (brew) and Debian/Ubuntu-family Linux + // (apt-get). On other distros or platforms, fall back to the standard + // per-binary install hints. + const linuxHasApt = platform === "linux" && commandExists("apt-get"); + if (platform !== "darwin" && !linuxHasApt) { preflightForVmStart("pull", arch); return; } @@ -483,7 +485,15 @@ async function ensureDepsForPull(arch: "arm64" | "amd64"): Promise { } // macOS qemu formula bundles the aarch64 firmware; Linux needs a separate package. if (firmwareMissing && platform === "linux") pkgs.add("qemu-efi-aarch64"); + // Edge case: on macOS arm64, firmware can be missing while all binaries + // are present (e.g. a partial qemu install). Reinstalling `qemu` recreates + // the bundled firmware files. + if (firmwareMissing && platform === "darwin") pkgs.add("qemu"); const pkgList = Array.from(pkgs).sort(); + if (pkgList.length === 0) { + preflightForVmStart("pull", arch); + return; + } const brewMissing = platform === "darwin" && !commandExists("brew"); console.log("Proposed install plan:"); From 9fab6cc3ba92f788d55878421fc4b1c6c9af762a Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 24 Apr 2026 15:49:08 -0700 Subject: [PATCH 4/4] fix(stack-cli): satisfy return-await lint in confirmPrompt --- packages/stack-cli/src/commands/emulator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stack-cli/src/commands/emulator.ts b/packages/stack-cli/src/commands/emulator.ts index c0334a21e9..72e24ed7ad 100644 --- a/packages/stack-cli/src/commands/emulator.ts +++ b/packages/stack-cli/src/commands/emulator.ts @@ -442,7 +442,7 @@ async function confirmPrompt(question: string): Promise { throw new CliError("Cannot prompt for confirmation: stdin is not a TTY. Install the missing dependencies manually and retry."); } const rl = createInterface({ input: process.stdin, output: process.stdout }); - return new Promise((resolvePromise) => { + return await new Promise((resolvePromise) => { rl.question(`${question} [y/N] `, (answer) => { rl.close(); resolvePromise(/^y(es)?$/i.test(answer.trim()));