Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 113 additions & 5 deletions packages/stack-cli/src/commands/emulator.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand All @@ -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")}`,
);
Expand All @@ -428,10 +433,110 @@ 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<boolean> {
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 await 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<void> {
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;
// 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;
}

// 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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<string>();
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");
// 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;
}

Comment thread
BilalG1 marked this conversation as resolved.
const brewMissing = platform === "darwin" && !commandExists("brew");
console.log("Proposed install plan:");
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();

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",
});
Comment thread
BilalG1 marked this conversation as resolved.
}

console.log("\nInstalling packages...");
if (platform === "darwin") {
// 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" });
}
Comment thread
BilalG1 marked this conversation as resolved.
console.log();
}

function aarch64FirmwareAvailable(): boolean {
return AARCH64_FIRMWARE_PATHS.some((p) => existsSync(p));
}
Expand Down Expand Up @@ -509,6 +614,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) {
Expand Down
Loading