Files
bootstrap_dev_env/system.go
T

713 lines
20 KiB
Go

package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// Special packages: installed outside the regular package manager because
// they're not in standard repos, or because they need extra setup. Linux only;
// on macOS brew covers all of these.
func specialPkgs() map[string]bool {
if isMacOS {
return map[string]bool{}
}
return map[string]bool{
"github-desktop": true, "zoom": true, "obsidian": true,
"minikube": true, "pipx": true,
"poetry": true, "pulumi": true, "semgrep": true,
"nvidia-drivers": true, "cuda-toolkit": true,
"nvidia-container-toolkit": true, "ollama": true,
"huggingface-cli": true,
}
}
// guiSystemPkgs are skipped by default (headless mode) and included only
// when --gui is passed.
var guiSystemPkgs = map[string]bool{
"github-desktop": true,
"google-chrome-stable": true,
"obs-studio": true,
"obsidian": true,
"shutter": true,
"virt-manager": true,
"vivaldi-stable": true,
"webcamoid": true,
"wireshark": true,
"zoom": true,
}
func isSpecialPkgInstalled(pkg string) bool {
exists := func(p string) bool { _, err := osStat(p); return err == nil }
switch pkg {
case "obsidian":
return exists("/usr/local/bin/obsidian")
case "minikube":
return exists("/usr/local/bin/minikube") || hasCmd("minikube")
case "pulumi":
return exists("/opt/pulumi/pulumi") || hasCmd("pulumi")
case "pipx":
return hasCmd("pipx")
case "poetry":
return hasCmd("poetry")
case "semgrep":
return hasCmd("semgrep")
case "nvidia-drivers":
return hasCmd("nvidia-smi")
case "cuda-toolkit":
return hasCmd("nvcc") || exists("/usr/local/cuda/bin/nvcc")
case "nvidia-container-toolkit":
return hasCmd("nvidia-ctk")
case "ollama":
return hasCmd("ollama")
case "huggingface-cli":
return hasCmd("huggingface-cli")
}
return isSystemPkgInstalled(pkg)
}
// ── special installers ────────────────────────────────────────────────────
type ghAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
Digest string `json:"digest"`
}
type ghRelease struct {
TagName string `json:"tag_name"`
Assets []ghAsset `json:"assets"`
}
func installGitHubDesktop(tmp string) {
var rel ghRelease
if !fetchJSON("https://api.github.com/repos/shiftkey/desktop/releases/latest", &rel) {
return
}
var suffix string
switch pkgMgr {
case "dnf":
suffix = ".rpm"
case "apt-get":
suffix = ".deb"
default:
warn("github-desktop has no installer for this package manager — skipping")
return
}
hostTokens := archTokens[archName]
excludeTokens := archTokens[otherArch()]
matches := func(name string) bool {
n := strings.ToLower(name)
if !strings.HasSuffix(n, suffix) {
return false
}
matched := false
for _, t := range hostTokens {
if strings.Contains(n, t) {
matched = true
break
}
}
if !matched {
return false
}
for _, t := range excludeTokens {
inHost := false
for _, h := range hostTokens {
if h == t {
inHost = true
break
}
}
if !inHost && strings.Contains(n, t) {
return false
}
}
return true
}
var asset *ghAsset
for i := range rel.Assets {
if matches(rel.Assets[i].Name) {
asset = &rel.Assets[i]
break
}
}
if asset == nil {
errLog(fmt.Sprintf("No GitHub Desktop %s asset found for %s", suffix, archName))
return
}
dest := filepath.Join(tmp, asset.Name)
if !download(asset.BrowserDownloadURL, dest) {
return
}
installer := pkgMgr
if pkgMgr == "apt-get" {
installer = "apt-get"
}
runCmd([]string{installer, "install", "-y", dest}, CmdOpts{AsSudo: true})
}
func installZoom(tmp string) {
if archName != "x86_64" {
warn("Zoom has no aarch64 Linux client — skipping")
return
}
switch pkgMgr {
case "dnf":
dest := filepath.Join(tmp, "zoom.rpm")
if !download("https://zoom.us/client/latest/zoom_x86_64.rpm", dest) {
return
}
runCmd([]string{"dnf", "install", "-y", dest}, CmdOpts{AsSudo: true})
case "apt-get":
dest := filepath.Join(tmp, "zoom.deb")
if !download("https://zoom.us/client/latest/zoom_amd64.deb", dest) {
return
}
runCmd([]string{"apt-get", "install", "-y", dest}, CmdOpts{AsSudo: true})
default:
warn("zoom: no installer for this distro — skipping")
}
}
func installObsidian(tmp string) {
var rel ghRelease
if !fetchJSON("https://api.github.com/repos/obsidianmd/obsidian-releases/releases/latest", &rel) {
return
}
hostTokens := archTokens[archName]
otherTokens := archTokens[otherArch()]
hasAnyToken := func(n string, tokens []string) bool {
for _, t := range tokens {
if strings.Contains(n, t) {
return true
}
}
return false
}
matches := func(name string) bool {
n := strings.ToLower(name)
if !strings.HasSuffix(n, ".appimage") {
return false
}
// Obsidian publishes the x86_64 AppImage without an arch suffix
// (e.g. "Obsidian-1.12.7.AppImage") and the arm64 build as
// "Obsidian-1.12.7-arm64.AppImage". Treat a token-less AppImage as x86_64.
if !hasAnyToken(n, hostTokens) && !hasAnyToken(n, otherTokens) {
return archName == "x86_64"
}
matched := hasAnyToken(n, hostTokens)
if !matched {
return false
}
for _, t := range otherTokens {
inHost := false
for _, h := range hostTokens {
if h == t {
inHost = true
break
}
}
if !inHost && strings.Contains(n, t) {
return false
}
}
return true
}
var asset *ghAsset
for i := range rel.Assets {
if matches(rel.Assets[i].Name) {
asset = &rel.Assets[i]
break
}
}
if asset == nil {
errLog(fmt.Sprintf("No Obsidian AppImage found for %s", archName))
return
}
dest := filepath.Join(tmp, asset.Name)
if !download(asset.BrowserDownloadURL, dest) {
return
}
installPath := "/usr/local/bin/obsidian"
runCmd([]string{"cp", dest, installPath}, CmdOpts{AsSudo: true})
runCmd([]string{"chmod", "755", installPath}, CmdOpts{AsSudo: true})
fmt.Printf(" Obsidian AppImage installed at %s\n", installPath)
}
func installMinikube(tmp string) {
archTok := archMinikube[archName]
baseURL := fmt.Sprintf("https://storage.googleapis.com/minikube/releases/latest/minikube-linux-%s", archTok)
dest := filepath.Join(tmp, "minikube")
if !download(baseURL, dest) {
return
}
fmt.Println(" Fetching SHA256 ...")
shaText := fetchText(baseURL + ".sha256")
if shaText == "" {
return
}
expected := strings.Fields(shaText)[0]
actual, err := sha256Of(dest)
if err != nil {
errLog(fmt.Sprintf("minikube hash failed: %v", err))
return
}
if actual != expected {
errLog(fmt.Sprintf("minikube SHA256 mismatch: expected %s, got %s", expected, actual))
return
}
fmt.Println(" SHA256 OK")
installPath := "/usr/local/bin/minikube"
runCmd([]string{"cp", dest, installPath}, CmdOpts{AsSudo: true})
runCmd([]string{"chmod", "755", installPath}, CmdOpts{AsSudo: true})
fmt.Printf(" minikube installed to %s\n", installPath)
}
func installPulumi(tmp string) {
version := fetchText("https://www.pulumi.com/latest-version")
if version == "" {
errLog("Could not determine latest Pulumi version")
return
}
osTok := osGo[osName]
archTok := archPulumi[archName]
tarball := fmt.Sprintf("pulumi-v%s-%s-%s.tar.gz", version, osTok, archTok)
base := fmt.Sprintf("https://github.com/pulumi/pulumi/releases/download/v%s", version)
dest := filepath.Join(tmp, tarball)
if !download(base+"/"+tarball, dest) {
return
}
checksums := fetchText(fmt.Sprintf("%s/pulumi-%s-checksums.txt", base, version))
if checksums == "" {
errLog("Could not fetch Pulumi checksums")
return
}
var expected string
for _, line := range strings.Split(checksums, "\n") {
if strings.HasSuffix(strings.TrimSpace(line), tarball) {
expected = strings.Fields(line)[0]
break
}
}
if expected == "" {
errLog(fmt.Sprintf("No checksum entry for %s", tarball))
return
}
actual, err := sha256Of(dest)
if err != nil {
errLog(fmt.Sprintf("Pulumi hash failed: %v", err))
return
}
if actual != expected {
errLog(fmt.Sprintf("Pulumi SHA256 mismatch: expected %s, got %s", expected, actual))
return
}
fmt.Println(" SHA256 OK")
installDir := "/opt/pulumi"
fmt.Println(" Extracting Pulumi to /opt ...")
runCmd([]string{"mkdir", "-p", "/opt"}, CmdOpts{AsSudo: true})
runCmd([]string{"rm", "-rf", installDir}, CmdOpts{AsSudo: true})
runCmd([]string{"tar", "-C", "/opt", "-xzf", dest}, CmdOpts{AsSudo: true})
appendProfileLine("pulumi", fmt.Sprintf(`export PATH="$PATH:%s"`, installDir))
fmt.Printf(" Pulumi %s installed to %s\n", version, installDir)
}
func installPipx(_ string) {
if !hasCmd("python3") {
errLog("Python 3 is not installed — cannot install pipx")
return
}
pkgInstall("pipx")
if hasCmd("pipx") {
runCmd([]string{"pipx", "ensurepath"}, CmdOpts{})
} else {
errLog("pipx command not found after install")
}
}
func installPoetry(_ string) {
if !hasCmd("pipx") {
errLog("pipx is not installed — cannot install poetry")
return
}
runCmd([]string{"pipx", "install", "poetry"}, CmdOpts{})
}
func installSemgrep(_ string) {
if !hasCmd("pipx") {
errLog("pipx is not installed — cannot install semgrep")
return
}
runCmd([]string{"pipx", "install", "semgrep"}, CmdOpts{})
}
func installSpecialPkg(pkg, tmp string) {
switch pkg {
case "github-desktop":
installGitHubDesktop(tmp)
case "zoom":
installZoom(tmp)
case "obsidian":
installObsidian(tmp)
case "minikube":
installMinikube(tmp)
case "pulumi":
installPulumi(tmp)
case "pipx":
installPipx(tmp)
case "poetry":
installPoetry(tmp)
case "semgrep":
installSemgrep(tmp)
case "nvidia-drivers":
installNvidiaDrivers()
case "cuda-toolkit":
installCUDAToolkit()
case "nvidia-container-toolkit":
installNvidiaContainerToolkit()
case "ollama":
installOllama()
case "huggingface-cli":
installHuggingFaceCLI()
}
}
func installNvidiaDrivers() {
if pkgMgr != "apt-get" {
errLog("nvidia-drivers can only be automatically installed via apt-get on Ubuntu/Debian")
return
}
fmt.Println(" Installing NVIDIA driver (nvidia-driver-550) ...")
res := pkgInstall("nvidia-driver-550")
if !res.OK() {
errLog(fmt.Sprintf("failed to install nvidia-driver-550: %v", res.Err))
}
}
func installCUDAToolkit() {
if pkgMgr != "apt-get" {
errLog("cuda-toolkit can only be automatically installed via apt-get on Ubuntu/Debian")
return
}
fmt.Println(" Setting up CUDA repository keyring ...")
setupCUDARepo()
fmt.Println(" Installing cuda-toolkit ...")
res := pkgInstall("cuda-toolkit")
if !res.OK() {
errLog(fmt.Sprintf("failed to install cuda-toolkit: %v", res.Err))
return
}
fmt.Println(" Configuring system path for CUDA ...")
appendProfileLine("cuda", `export PATH="/usr/local/cuda/bin:$PATH"`)
appendProfileLine("cuda", `export LD_LIBRARY_PATH="/usr/local/cuda/lib64:$LD_LIBRARY_PATH"`)
}
func installNvidiaContainerToolkit() {
if pkgMgr != "apt-get" {
errLog("nvidia-container-toolkit can only be automatically installed via apt-get on Ubuntu/Debian")
return
}
fmt.Println(" Setting up NVIDIA Container Toolkit repository ...")
setupNvidiaContainerToolkitRepo()
fmt.Println(" Installing nvidia-container-toolkit ...")
res := pkgInstall("nvidia-container-toolkit")
if !res.OK() {
errLog(fmt.Sprintf("failed to install nvidia-container-toolkit: %v", res.Err))
return
}
fmt.Println(" Configuring Docker runtime for NVIDIA Container Toolkit ...")
configureRes := runCmd([]string{"nvidia-ctk", "runtime", "configure", "--runtime=docker"}, CmdOpts{AsSudo: true})
if !configureRes.OK() {
warn(fmt.Sprintf("failed to configure docker runtime: %v", configureRes.Err))
}
fmt.Println(" Restarting Docker service ...")
restartRes := runCmd([]string{"systemctl", "restart", "docker"}, CmdOpts{AsSudo: true})
if !restartRes.OK() {
warn(fmt.Sprintf("failed to restart docker service: %v", restartRes.Err))
}
}
func installOllama() {
fmt.Println(" Installing Ollama via official install script ...")
res := runShell("curl -fsSL https://ollama.com/install.sh | sh", CmdOpts{})
if !res.OK() {
errLog(fmt.Sprintf("Ollama installation failed: %v", res.Err))
return
}
isCI := os.Getenv("BOOTSTRAP_CI") == "true"
if isCI {
fmt.Println(" [CI] Skipping pulling large Ollama models in integration test container.")
return
}
// Pull Gemma 4 E4B and Qwen2.5-Coder 7B
fmt.Println(" Pulling Gemma 4 E4B and Qwen2.5-Coder 7B models ...")
serverRunning := false
// Check if Ollama server is already responding
for i := 0; i < 5; i++ {
r := runCmd([]string{"ollama", "list"}, CmdOpts{Capture: true})
if r.OK() {
serverRunning = true
break
}
if isTesting {
break
}
time.Sleep(1 * time.Second)
}
var cmd *exec.Cmd
if !serverRunning && !isTesting {
fmt.Println(" Ollama server not running. Starting in background for model pulling ...")
cmd = exec.Command("ollama", "serve")
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
warn(fmt.Sprintf("Failed to start Ollama server in background: %v", err))
} else {
// Wait up to 30 seconds for server to start
for i := 0; i < 30; i++ {
r := runCmd([]string{"ollama", "list"}, CmdOpts{Capture: true})
if r.OK() {
serverRunning = true
break
}
time.Sleep(1 * time.Second)
}
}
}
if serverRunning || isTesting {
fmt.Println(" Pulling Gemma 4 E4B (gemma4:e4b) ...")
pull1 := runCmd([]string{"ollama", "pull", "gemma4:e4b"}, CmdOpts{})
if !pull1.OK() {
errLog(fmt.Sprintf("Failed to pull gemma4:e4b: %v", pull1.Err))
}
fmt.Println(" Pulling Qwen2.5-Coder 7B (qwen2.5-coder:7b) ...")
pull2 := runCmd([]string{"ollama", "pull", "qwen2.5-coder:7b"}, CmdOpts{})
if !pull2.OK() {
errLog(fmt.Sprintf("Failed to pull qwen2.5-coder:7b: %v", pull2.Err))
}
} else {
errLog("Ollama server failed to start — cannot pull models")
}
if cmd != nil && cmd.Process != nil {
fmt.Println(" Stopping background Ollama server ...")
cmd.Process.Kill()
}
}
func installHuggingFaceCLI() {
if !hasCmd("pipx") {
errLog("pipx is not installed — cannot install huggingface-cli")
return
}
fmt.Println(" Installing huggingface-cli via pipx ...")
res := runCmd([]string{"pipx", "install", "huggingface_hub[cli]"}, CmdOpts{})
if !res.OK() {
errLog(fmt.Sprintf("failed to install huggingface-cli: %v", res.Err))
}
}
// pkgInstall invokes the host package manager to install a single name.
// Centralized so pacman's "--noconfirm" doesn't leak everywhere.
func pkgInstall(pkg string) CmdResult {
switch pkgMgr {
case "pacman":
return runCmd([]string{"pacman", "-S", "--noconfirm", "--needed", pkg}, CmdOpts{AsSudo: true})
case "brew":
if brewCasks[pkg] {
return runCmd([]string{"brew", "install", "--cask", pkg}, CmdOpts{})
}
return runCmd([]string{"brew", "install", pkg}, CmdOpts{})
default:
return runCmd([]string{pkgMgr, "install", "-y", pkg}, CmdOpts{AsSudo: true})
}
}
// pkgInstallMany installs all named packages in a single invocation of the
// host package manager. This is dramatically faster than per-package install
// loops because apt/dnf/pacman/brew amortize metadata refresh, dependency
// resolution, and (most importantly) only acquire the install lock once.
//
// On batch failure we fall back to per-package installs so callers can
// continue to report which specific packages failed via errLog. brew is
// split into formula vs cask batches because `--cask` is mutually exclusive
// with formula installs in one invocation. We deliberately do NOT run brew
// invocations in parallel — brew acquires per-Cellar locks on transitive
// dependencies (cmake, ninja, libsodium, etc.), and concurrent invocations
// that both pull in the same dep abort with "process has already locked".
func pkgInstallMany(pkgs []string) (failed []string) {
if len(pkgs) == 0 {
return nil
}
if pkgMgr == "brew" {
return brewInstallMany(pkgs)
}
var argv []string
switch pkgMgr {
case "pacman":
argv = append([]string{"pacman", "-S", "--noconfirm", "--needed"}, pkgs...)
default:
argv = append([]string{pkgMgr, "install", "-y"}, pkgs...)
}
if runCmd(argv, CmdOpts{AsSudo: true}).OK() {
return nil
}
// Batch failed — retry per-package so we can report exactly which
// packages broke. Slower, but only happens on the error path.
warn(fmt.Sprintf("Batched install failed; retrying %d packages individually to isolate failures ...", len(pkgs)))
for _, p := range pkgs {
if !pkgInstall(p).OK() {
failed = append(failed, p)
}
}
return failed
}
// brewInstallMany installs pkgs via brew, batching formulas and casks into
// two single invocations (`brew install f1 f2 …` and `brew install --cask
// c1 c2 …`). Brew resolves and parallelizes the internal dep graph itself,
// so a single batched call is both faster and lock-safe — multiple
// concurrent `brew install` processes deadlock on shared deps. On batch
// failure we retry per-package serially to identify which specific package
// broke.
func brewInstallMany(pkgs []string) (failed []string) {
var formulas, casks []string
for _, p := range pkgs {
if brewCasks[p] {
casks = append(casks, p)
} else {
formulas = append(formulas, p)
}
}
tryBatch := func(label string, names []string, extra ...string) (batchFailed []string) {
if len(names) == 0 {
return nil
}
argv := append([]string{"brew", "install"}, extra...)
argv = append(argv, names...)
if runCmd(argv, CmdOpts{}).OK() {
return nil
}
warn(fmt.Sprintf("Batched brew %s install failed; retrying %d packages individually ...", label, len(names)))
for _, p := range names {
if !pkgInstall(p).OK() {
batchFailed = append(batchFailed, p)
}
}
return batchFailed
}
failed = append(failed, tryBatch("formula", formulas)...)
failed = append(failed, tryBatch("cask", casks, "--cask")...)
return failed
}
// installSystemPackages installs the regular + special package lists.
func installSystemPackages(regular, special []string) {
fmt.Println("\n=== System Packages ===")
// Ensure aria2 is installed first and on the system path
var installAria2 bool
var remainingRegular []string
for _, p := range regular {
if p == "aria2" {
installAria2 = true
} else {
remainingRegular = append(remainingRegular, p)
}
}
if installAria2 || !hasCmd("aria2c") {
fmt.Println(" Ensuring aria2 is installed first and on the system path ...")
var res CmdResult
switch pkgMgr {
case "brew":
res = runCmd([]string{"brew", "install", "aria2"}, CmdOpts{})
case "pacman":
res = runCmd([]string{"pacman", "-S", "--noconfirm", "--needed", "aria2"}, CmdOpts{AsSudo: true})
default: // dnf, apt-get
res = runCmd([]string{pkgMgr, "install", "-y", "aria2"}, CmdOpts{AsSudo: true})
}
if !res.OK() {
warn(fmt.Sprintf("Failed to install aria2: %v", res.Err))
} else if !hasCmd("aria2c") {
warn("aria2 was installed but 'aria2c' is not found on the system path")
} else {
fmt.Println(" aria2 is installed and on the system path.")
}
regular = remainingRegular
}
if pkgMgr == "brew" {
failed := pkgInstallMany(regular)
for _, p := range failed {
taskPrintf(" [WARN] System package failed to install: %s\n", p)
}
// No special packages on macOS — brew covers all of them.
return
}
seenRepos := map[int]bool{}
groups := repoGroups()
for _, pkg := range regular {
for i, g := range groups {
if g.members[pkg] && !seenRepos[i] {
fmt.Printf(" [REPO] Setting up repository for %s ...\n", pkg)
g.setup()
seenRepos[i] = true
}
}
}
failed := pkgInstallMany(regular)
for _, p := range failed {
taskPrintf(" [WARN] System package failed to install: %s\n", p)
}
if len(special) > 0 {
tmp, err := os.MkdirTemp("", "bootstrap-special-")
if err != nil {
errLog(fmt.Sprintf("could not create temp dir for special packages: %v", err))
return
}
defer osRemoveAll(tmp)
for _, pkg := range special {
fmt.Printf("\n [SPECIAL] Installing %s ...\n", pkg)
installSpecialPkg(pkg, tmp)
}
}
if pkgMgr == "apt-get" && hasCmd("fdfind") {
runCmd([]string{"ln", "-sf", "/usr/bin/fdfind", "/usr/local/bin/fd"}, CmdOpts{AsSudo: true})
}
}
// appendProfileLine adds a PATH/env line to a system-wide login-shell profile,
// idempotently. On Linux uses /etc/profile.d/<name>.sh; macOS uses /etc/zprofile.
func appendProfileLine(scriptName, line string) {
target := fmt.Sprintf("/etc/profile.d/%s.sh", scriptName)
if isMacOS {
target = "/etc/zprofile"
}
cmd := fmt.Sprintf("grep -qxF %q %s 2>/dev/null || echo %q >> %s", line, target, line, target)
runCmd([]string{"bash", "-c", cmd}, CmdOpts{AsSudo: true})
}