Files
bootstrap_dev_env/macos.go
T
Claude 658460fe4c port: rewrite bootstrap tool in Go
Replace the Python bootstrap script with a Go implementation that
cross-compiles to native binaries for Linux and macOS on x86_64 and
aarch64. The Go port preserves all sections of the original (system
packages, optional Flatpak GUI apps, custom downloads, and the macOS
firecracker VM bridge) and adds first-class pacman support so the tool
works on Arch-family distros alongside Debian, RHEL, and macOS.

A Makefile produces a host binary via `make build` and the full
four-target matrix under dist/ via `make build-all`.
2026-05-22 21:48:57 +00:00

660 lines
20 KiB
Go

package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// ── Xcode + Homebrew prereqs ────────────────────────────────────────────
func brewPrefix() string {
if archName == "aarch64" {
return "/opt/homebrew"
}
return "/usr/local"
}
func ensureXcodeCLT() {
if !isMacOS {
return
}
res, ok := probe([]string{"xcode-select", "-p"}, 10*time.Second)
if ok && res.ExitCode == 0 {
fmt.Printf("[Xcode CLT] Already installed at %s\n", strings.TrimSpace(string(res.Stdout)))
return
}
fmt.Println("[Xcode CLT] Installing Xcode Command Line Tools ...")
fmt.Println(" A GUI dialog will appear — click 'Install' to proceed.")
runCmd([]string{"xcode-select", "--install"}, CmdOpts{Timeout: 30 * time.Second})
fmt.Println(" Waiting for installation to complete ...")
for {
r, ok := probe([]string{"xcode-select", "-p"}, 10*time.Second)
if ok && r.ExitCode == 0 {
break
}
time.Sleep(5 * time.Second)
}
fmt.Println("[Xcode CLT] Installation complete.")
}
func ensureHomebrew() {
if !isMacOS {
return
}
if hasCmd("brew") {
path, _ := exec.LookPath("brew")
fmt.Printf("[Homebrew] Already installed at %s\n", path)
return
}
fmt.Println("[Homebrew] Installing Homebrew ...")
installer := `NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"`
if !runShell(installer, CmdOpts{}).OK() {
fmt.Fprintln(os.Stderr, "Homebrew installation failed")
os.Exit(1)
}
brewBinDir := filepath.Join(brewPrefix(), "bin")
brewPath := filepath.Join(brewBinDir, "brew")
if _, err := os.Stat(brewPath); err != nil {
fmt.Fprintf(os.Stderr, "Homebrew installed but brew not found at %s\n", brewPath)
os.Exit(1)
}
os.Setenv("PATH", brewBinDir+":"+os.Getenv("PATH"))
shellenvLine := fmt.Sprintf(`eval "$(%s shellenv)"`, brewPath)
runCmd([]string{
"bash", "-c",
fmt.Sprintf("grep -qxF %q /etc/zprofile 2>/dev/null || echo %q >> /etc/zprofile",
shellenvLine, shellenvLine),
}, CmdOpts{AsSudo: true})
fmt.Printf("[Homebrew] Installed at %s; added shellenv to /etc/zprofile\n", brewPrefix())
}
// ── macOS firecracker VM bridge ─────────────────────────────────────────
//
// Firecracker is Linux-only (needs KVM). On macOS we provision a Fedora cloud
// VM via QEMU/HVF, install firecracker inside it, and expose a `firecracker`
// zsh function on the host that proxies invocations over SSH.
const (
vmSSHPort = 2222
vmUser = "fc"
vmQcow2Name = "fedora.qcow2"
vmSeedISOName = "seed.iso"
vmPIDName = "vm.pid"
vmKeyName = "id_ed25519"
firecrackerFnBeg = "# >>> firecracker-vm wrapper >>>"
firecrackerFnEnd = "# <<< firecracker-vm wrapper <<<"
)
func vmDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".firecracker-vm")
}
func macosMajor() int {
if !isMacOS {
return 0
}
r, ok := probe([]string{"sw_vers", "-productVersion"}, 10*time.Second)
if !ok || r.ExitCode != 0 {
return 0
}
v := strings.TrimSpace(string(r.Stdout))
if v == "" {
return 0
}
parts := strings.Split(v, ".")
n, err := strconv.Atoi(parts[0])
if err != nil {
return 0
}
return n
}
func appleSiliconGeneration() int {
if !isMacOS || archName != "aarch64" {
return 0
}
r, ok := probe([]string{"sysctl", "-n", "machdep.cpu.brand_string"}, 10*time.Second)
if !ok || r.ExitCode != 0 {
return 0
}
brand := strings.TrimSpace(string(r.Stdout))
idx := strings.Index(brand, "Apple M")
if idx < 0 {
return 0
}
rest := brand[idx+len("Apple M"):]
var digits []byte
for i := 0; i < len(rest) && rest[i] >= '0' && rest[i] <= '9'; i++ {
digits = append(digits, rest[i])
}
if len(digits) == 0 {
return 0
}
n, _ := strconv.Atoi(string(digits))
return n
}
func selectVMBackend() string {
if !isMacOS {
return ""
}
if archName == "x86_64" {
fmt.Println("\n[firecracker VM] Intel Mac — using VirtualBox (supports nested VT-x for in-guest KVM).")
return "virtualbox"
}
gen := appleSiliconGeneration()
macos := macosMajor()
if gen >= 3 && macos >= 15 {
fmt.Printf("\n[firecracker VM] Apple Silicon M%d on macOS %d — using QEMU/HVF with nested virtualization (-cpu host,el2=on).\n", gen, macos)
return "qemu"
}
chip := "Apple Silicon"
if gen != 0 {
chip = fmt.Sprintf("Apple M%d", gen)
}
osStr := "this macOS"
if macos != 0 {
osStr = fmt.Sprintf("macOS %d", macos)
}
fmt.Println()
fmt.Println("[firecracker VM] Skipping firecracker VM provisioning.")
fmt.Printf(" Detected %s on %s. HVF only exposes nested\n", chip, osStr)
fmt.Println(" virtualization on M3+ chips running macOS 15 Sequoia or later,")
fmt.Println(" and VirtualBox does not support Apple Silicon hosts, so there")
fmt.Println(" is no local hypervisor that can run firecracker microVMs here.")
fmt.Println(" To use firecracker, provision a Linux cloud VM (e.g. AWS EC2,")
fmt.Println(" GCP) and run firecracker there over SSH.")
return ""
}
func latestFedoraCloudImage() (filename, qcowURL, checksumURL string, ok bool) {
base := "https://dl.fedoraproject.org/pub/fedora/linux/releases/"
listing := fetchText(base)
if listing == "" {
return
}
verRe := regexp.MustCompile(`href="(\d+)/?"`)
seen := map[int]bool{}
var versions []int
for _, m := range verRe.FindAllStringSubmatch(listing, -1) {
v, err := strconv.Atoi(m[1])
if err != nil {
continue
}
if !seen[v] {
seen[v] = true
versions = append(versions, v)
}
}
sort.Sort(sort.Reverse(sort.IntSlice(versions)))
for _, ver := range versions {
imagesURL := fmt.Sprintf("%s%d/Cloud/%s/images/", base, ver, archName)
idx := fetchText(imagesURL)
if idx == "" {
continue
}
qcowRe := regexp.MustCompile(fmt.Sprintf(`href="(Fedora-Cloud-Base[A-Za-z0-9_-]*-%d-[\d.]+\.%s\.qcow2)"`, ver, archName))
ckRe := regexp.MustCompile(`href="([^"]*CHECKSUM)"`)
qm := qcowRe.FindStringSubmatch(idx)
cm := ckRe.FindStringSubmatch(idx)
if qm == nil || cm == nil {
continue
}
return qm[1], imagesURL + qm[1], imagesURL + cm[1], true
}
return
}
func verifyFedoraQcow2(qcow2, checksumURL string) bool {
text := fetchText(checksumURL)
if text == "" {
return false
}
name := filepath.Base(qcow2)
re := regexp.MustCompile(fmt.Sprintf(`SHA256 \(%s\) = ([0-9a-fA-F]+)`, regexp.QuoteMeta(name)))
m := re.FindStringSubmatch(text)
if m == nil {
errLog(fmt.Sprintf("No SHA256 entry for %s in checksum file", name))
return false
}
expected := strings.ToLower(m[1])
fmt.Println(" Verifying SHA256 (this can take a minute) ...")
actual, err := sha256Of(qcow2)
if err != nil {
errLog(fmt.Sprintf("Fedora image hash failed: %v", err))
return false
}
if strings.ToLower(actual) != expected {
errLog(fmt.Sprintf("Fedora image SHA256 mismatch (got %s, expected %s)", actual, expected))
return false
}
fmt.Println(" SHA256 OK")
return true
}
func downloadFedoraImage(qcowURL, dest string) bool {
if !hasCmd("curl") {
return download(qcowURL, dest)
}
fmt.Printf(" Downloading %s ...\n", filepath.Base(dest))
return runCmd([]string{"curl", "-L", "--fail", "-#", "-o", dest, qcowURL}, CmdOpts{}).OK()
}
const firecrackerUserdataTmpl = `#cloud-config
hostname: firecracker-vm
users:
- name: %s
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
ssh_authorized_keys:
- %s
ssh_pwauth: false
packages:
- curl
- tar
- qemu-kvm
write_files:
- path: /usr/local/sbin/install-firecracker.sh
permissions: '0755'
content: |
#!/usr/bin/env bash
set -euo pipefail
ARCH=$(uname -m)
TAG=$(curl -fsSL https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest \
| grep -oE '"tag_name":[[:space:]]*"v[^"]+"' | head -1 \
| sed -E 's/.*"v([^"]+)"/\1/')
cd /tmp
curl -fsSL -o fc.tgz \
"https://github.com/firecracker-microvm/firecracker/releases/download/v${TAG}/firecracker-v${TAG}-${ARCH}.tgz"
tar -xzf fc.tgz
BIN=$(find . -maxdepth 3 -type f -name "firecracker-v${TAG}-${ARCH}" ! -name '*.debug' | head -1)
install -m 0755 "$BIN" /usr/local/bin/firecracker
touch /var/lib/firecracker-ready
runcmd:
- /usr/local/sbin/install-firecracker.sh
`
func writeCloudInitSeed(seedDir, pubkey string) error {
if err := os.MkdirAll(seedDir, 0o755); err != nil {
return err
}
userData := fmt.Sprintf(firecrackerUserdataTmpl, vmUser, strings.TrimSpace(pubkey))
if err := os.WriteFile(filepath.Join(seedDir, "user-data"), []byte(userData), 0o644); err != nil {
return err
}
return os.WriteFile(filepath.Join(seedDir, "meta-data"),
[]byte("instance-id: firecracker-vm\nlocal-hostname: firecracker-vm\n"), 0o644)
}
func buildSeedISO(seedDir, isoPath string) bool {
os.Remove(isoPath)
return runCmd([]string{
"hdiutil", "makehybrid", "-iso", "-joliet",
"-default-volume-name", "cidata",
"-o", isoPath, seedDir,
}, CmdOpts{}).OK()
}
func writeQEMUStartScript() string {
dir := vmDir()
brewShare := filepath.Join(brewPrefix(), "share", "qemu")
scriptPath := filepath.Join(dir, "vm-start.sh")
edkCode := filepath.Join(brewShare, "edk2-aarch64-code.fd")
edkVarsTemplate := filepath.Join(brewShare, "edk2-arm-vars.fd")
qemuBlock := fmt.Sprintf(`# Ensure a writable NVRAM file exists (UEFI vars persist here).
if [[ ! -f edk2-aarch64-vars.fd ]]; then
if [[ -f "%s" ]]; then
cp "%s" edk2-aarch64-vars.fd
else
truncate -s 64M edk2-aarch64-vars.fd
fi
fi
exec qemu-system-aarch64 \
-machine virt,accel=hvf,highmem=on \
-cpu host,el2=on \
-smp 2 -m 2048 \
-drive if=pflash,format=raw,readonly=on,file="%s" \
-drive if=pflash,format=raw,file=edk2-aarch64-vars.fd \
-drive file=%s,if=virtio,format=qcow2 \
-drive file=%s,format=raw,if=virtio,readonly=on \
-display none -serial file:vm.log \
-netdev user,id=net0,hostfwd=tcp::%d-:22 \
-device virtio-net-device,netdev=net0 \
-daemonize -pidfile %s
`, edkVarsTemplate, edkVarsTemplate, edkCode, vmQcow2Name, vmSeedISOName, vmSSHPort, vmPIDName)
script := fmt.Sprintf(`#!/usr/bin/env bash
# Start the Fedora-on-QEMU VM that backs the host firecracker zsh function.
set -euo pipefail
cd "%s"
if [[ -f %s ]] && kill -0 "$(cat %s)" 2>/dev/null; then
exit 0
fi
rm -f %s
%s`, dir, vmPIDName, vmPIDName, vmPIDName, qemuBlock)
os.WriteFile(scriptPath, []byte(script), 0o755)
return scriptPath
}
func sshToVM(privKey string, remote []string, timeout time.Duration) CmdResult {
if timeout == 0 {
timeout = 3 * time.Second
}
args := []string{
"-q",
"-i", privKey,
"-p", strconv.Itoa(vmSSHPort),
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", fmt.Sprintf("ConnectTimeout=%d", int(timeout.Seconds())),
"-o", "LogLevel=ERROR",
fmt.Sprintf("%s@127.0.0.1", vmUser),
}
args = append(args, remote...)
r, ok := probe(append([]string{"ssh"}, args...), timeout+30*time.Second)
if !ok {
return CmdResult{ExitCode: 124}
}
return r
}
func waitForVMSSH(privKey string, timeout time.Duration) bool {
fmt.Printf(" Waiting for VM SSH on port %d (up to %s) ...\n", vmSSHPort, timeout)
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if sshToVM(privKey, []string{"true"}, 0).OK() {
fmt.Println(" VM SSH ready.")
return true
}
time.Sleep(5 * time.Second)
}
return false
}
func waitForFirecrackerInVM(privKey string, timeout time.Duration) bool {
fmt.Printf(" Waiting for cloud-init to install firecracker inside the VM (up to %s) ...\n", timeout)
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if sshToVM(privKey, []string{"test", "-f", "/var/lib/firecracker-ready"}, 0).OK() {
fmt.Println(" firecracker is installed inside the VM.")
return true
}
time.Sleep(10 * time.Second)
}
return false
}
func firecrackerZshFunction(privKey string) string {
return fmt.Sprintf(`%s
firecracker() {
local vm_dir="%s"
if [[ ! -f "$vm_dir/%s" ]]; then
echo "firecracker: Fedora VM not provisioned (expected $vm_dir/%s)." >&2
return 1
fi
if ! "$vm_dir/vm-start.sh"; then
echo "firecracker: failed to start backing VM (see $vm_dir/vm.log)." >&2
return 1
fi
local i
for i in $(seq 1 60); do
ssh -q -i "%s" -p %d \
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=2 -o LogLevel=ERROR \
%s@127.0.0.1 true && break
sleep 1
done
local args=() a
for a in "$@"; do args+=("$(printf %%q "$a")"); done
ssh -t -q -i "%s" -p %d \
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR \
%s@127.0.0.1 "sudo /usr/local/bin/firecracker ${args[*]}"
}
%s
`,
firecrackerFnBeg,
vmDir(),
vmQcow2Name, vmQcow2Name,
privKey, vmSSHPort, vmUser,
privKey, vmSSHPort, vmUser,
firecrackerFnEnd,
)
}
func installFirecrackerZshFunction(content string) {
home, _ := os.UserHomeDir()
zshrc := filepath.Join(home, ".zshrc")
existing := ""
if b, err := os.ReadFile(zshrc); err == nil {
existing = string(b)
}
pattern := regexp.MustCompile(`(?s)` + regexp.QuoteMeta(firecrackerFnBeg) + `.*?` + regexp.QuoteMeta(firecrackerFnEnd) + `\n?`)
var newContent string
if pattern.MatchString(existing) {
newContent = pattern.ReplaceAllString(existing, content)
} else {
if existing != "" {
newContent = strings.TrimRight(existing, "\n") + "\n\n" + content
} else {
newContent = content
}
}
os.WriteFile(zshrc, []byte(newContent), 0o644)
fmt.Printf(" Wrote firecracker() function block to %s\n", zshrc)
}
func ensureVirtualBox() bool {
if hasCmd("VBoxManage") {
return true
}
fmt.Println(" Installing VirtualBox via brew cask ...")
if !runCmd([]string{"brew", "install", "--cask", "virtualbox"}, CmdOpts{}).OK() {
errLog("VirtualBox cask install failed. macOS may require kernel-extension " +
"approval in System Settings → Privacy & Security; once approved, re-run this script.")
return false
}
if !hasCmd("VBoxManage") {
errLog("VirtualBox installed but VBoxManage not in PATH. macOS may need a reboot or kext approval.")
return false
}
return true
}
func provisionVirtualBoxVM(qcow2, seedISO string) string {
if !ensureVirtualBox() {
return ""
}
vmName := "firecracker-vm"
dir := vmDir()
vboxBase := filepath.Join(dir, "vbox")
vdi := filepath.Join(dir, "fedora.vdi")
r, ok := probe([]string{"VBoxManage", "showvminfo", vmName}, 30*time.Second)
exists := ok && r.ExitCode == 0
if !exists {
if _, err := os.Stat(vdi); os.IsNotExist(err) {
fmt.Printf(" Converting %s → %s (VirtualBox VDI) ...\n", filepath.Base(qcow2), filepath.Base(vdi))
if !runCmd([]string{"VBoxManage", "clonemedium", "disk", qcow2, vdi, "--format", "VDI"}, CmdOpts{}).OK() {
errLog("VBoxManage clonemedium failed")
return ""
}
runCmd([]string{"VBoxManage", "modifymedium", "disk", vdi, "--resize", "10240"}, CmdOpts{})
}
fmt.Printf(" Creating VirtualBox VM '%s' ...\n", vmName)
os.MkdirAll(vboxBase, 0o755)
if !runCmd([]string{
"VBoxManage", "createvm",
"--name", vmName,
"--ostype", "Fedora_64",
"--basefolder", vboxBase,
"--register",
}, CmdOpts{}).OK() {
errLog("VBoxManage createvm failed")
return ""
}
runCmd([]string{
"VBoxManage", "modifyvm", vmName,
"--cpus", "2",
"--memory", "2048",
"--nested-hw-virt", "on",
"--nic1", "nat",
"--natpf1", fmt.Sprintf("ssh,tcp,,%d,,22", vmSSHPort),
}, CmdOpts{})
runCmd([]string{"VBoxManage", "storagectl", vmName, "--name", "SATA", "--add", "sata"}, CmdOpts{})
runCmd([]string{
"VBoxManage", "storageattach", vmName,
"--storagectl", "SATA",
"--port", "0", "--device", "0", "--type", "hdd",
"--medium", vdi,
}, CmdOpts{})
runCmd([]string{"VBoxManage", "storagectl", vmName, "--name", "IDE", "--add", "ide"}, CmdOpts{})
runCmd([]string{
"VBoxManage", "storageattach", vmName,
"--storagectl", "IDE",
"--port", "0", "--device", "0", "--type", "dvddrive",
"--medium", seedISO,
}, CmdOpts{})
} else {
fmt.Printf(" VirtualBox VM '%s' already registered — reusing.\n", vmName)
}
scriptPath := filepath.Join(dir, "vm-start.sh")
script := fmt.Sprintf(`#!/usr/bin/env bash
# Start the VirtualBox-backed Fedora VM that powers the host firecracker() fn.
set -euo pipefail
if VBoxManage list runningvms | grep -q '"%s"'; then
exit 0
fi
exec VBoxManage startvm %s --type headless
`, vmName, vmName)
os.WriteFile(scriptPath, []byte(script), 0o755)
return scriptPath
}
func setupFirecrackerVM() {
if !isMacOS {
return
}
backend := selectVMBackend()
if backend == "" {
return
}
fmt.Println("\n=== macOS firecracker VM (Fedora) ===")
dir := vmDir()
os.MkdirAll(dir, 0o755)
privKey := filepath.Join(dir, vmKeyName)
pubKey := privKey + ".pub"
if _, err := os.Stat(privKey); os.IsNotExist(err) {
fmt.Printf(" Generating SSH keypair at %s ...\n", privKey)
if !runCmd([]string{"ssh-keygen", "-t", "ed25519", "-N", "", "-f", privKey, "-q"}, CmdOpts{}).OK() {
errLog("ssh-keygen failed — aborting VM setup")
return
}
}
qcow2 := filepath.Join(dir, vmQcow2Name)
if _, err := os.Stat(qcow2); err == nil {
fmt.Printf(" Reusing existing Fedora image at %s\n", qcow2)
} else {
fmt.Println(" Looking up latest Fedora cloud image ...")
filename, qcowURL, checksumURL, ok := latestFedoraCloudImage()
if !ok {
errLog("Could not resolve latest Fedora cloud image — aborting VM setup")
return
}
fmt.Printf(" Latest: %s\n", filename)
downloadDest := filepath.Join(dir, filename)
if !downloadFedoraImage(qcowURL, downloadDest) {
errLog("Fedora image download failed — aborting VM setup")
return
}
if !verifyFedoraQcow2(downloadDest, checksumURL) {
os.Remove(downloadDest)
return
}
os.Rename(downloadDest, qcow2)
if hasCmd("qemu-img") {
fmt.Println(" Resizing image to 10G ...")
runCmd([]string{"qemu-img", "resize", qcow2, "10G"}, CmdOpts{})
}
}
fmt.Println(" Building cloud-init seed ISO ...")
seedDir := filepath.Join(dir, "seed")
pubKeyBytes, err := os.ReadFile(pubKey)
if err != nil {
errLog(fmt.Sprintf("could not read public key: %v", err))
return
}
if err := writeCloudInitSeed(seedDir, string(pubKeyBytes)); err != nil {
errLog(fmt.Sprintf("cloud-init seed write failed: %v", err))
return
}
seedISO := filepath.Join(dir, vmSeedISOName)
if !buildSeedISO(seedDir, seedISO) {
errLog("hdiutil failed to build seed ISO — aborting VM setup")
return
}
var startScript string
if backend == "qemu" {
if !hasCmd("qemu-system-aarch64") {
errLog("qemu-system-aarch64 not found — install qemu via brew first.")
return
}
fmt.Println(" Writing QEMU start script ...")
startScript = writeQEMUStartScript()
} else {
startScript = provisionVirtualBoxVM(qcow2, seedISO)
if startScript == "" {
return
}
}
fmt.Printf(" Booting VM via %s ...\n", startScript)
if !runCmd([]string{startScript}, CmdOpts{}).OK() {
errLog(fmt.Sprintf("VM start failed — see %s", filepath.Join(dir, "vm.log")))
return
}
if !waitForVMSSH(privKey, 5*time.Minute) {
errLog(fmt.Sprintf("VM SSH never came up — see %s", filepath.Join(dir, "vm.log")))
return
}
if !waitForFirecrackerInVM(privKey, 15*time.Minute) {
warn("firecracker did not appear in the VM within the timeout; " +
"cloud-init may still be running. Check `sudo cloud-init status` " +
"inside the VM (ssh -i ~/.firecracker-vm/id_ed25519 -p 2222 fc@127.0.0.1).")
}
fmt.Println(" Installing firecracker() wrapper into ~/.zshrc ...")
installFirecrackerZshFunction(firecrackerZshFunction(privKey))
fmt.Printf(" firecracker VM ready (backend: %s).\n", backend)
fmt.Printf(" Start manually with: %s\n", startScript)
if backend == "qemu" {
fmt.Println(" Nested virt is on (el2=on); the guest's KVM can launch firecracker microVMs.")
} else {
fmt.Println(" Nested VT-x is on; the guest's KVM can launch firecracker microVMs.")
}
}