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`.
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── pyenv / Python ──────────────────────────────────────────────────────
|
||||
|
||||
func installPyenv() {
|
||||
fmt.Println(" Installing pyenv via curl ...")
|
||||
if !runShell("curl https://pyenv.run | bash", CmdOpts{}).OK() {
|
||||
errLog("pyenv installation failed")
|
||||
return
|
||||
}
|
||||
fmt.Println(" pyenv installed to ~/.pyenv")
|
||||
}
|
||||
|
||||
func python3DecimalOK() bool {
|
||||
if !hasCmd("python3") {
|
||||
return false
|
||||
}
|
||||
r, ok := probe([]string{"python3", "-c", "from decimal import Decimal"}, 10*time.Second)
|
||||
return ok && r.ExitCode == 0
|
||||
}
|
||||
|
||||
func fixPython3Decimal() bool {
|
||||
switch pkgMgr {
|
||||
case "apt-get":
|
||||
runCmd([]string{"apt-get", "install", "-y", "python3-full"}, CmdOpts{AsSudo: true})
|
||||
case "dnf":
|
||||
runCmd([]string{"dnf", "install", "-y", "python3-libs"}, CmdOpts{AsSudo: true})
|
||||
case "pacman":
|
||||
runCmd([]string{"pacman", "-S", "--noconfirm", "--needed", "python"}, CmdOpts{AsSudo: true})
|
||||
}
|
||||
return python3DecimalOK()
|
||||
}
|
||||
|
||||
func installPip() {
|
||||
if !hasCmd("python3") {
|
||||
errLog("python3 is not installed — cannot install pip")
|
||||
return
|
||||
}
|
||||
if !python3DecimalOK() {
|
||||
warn("Python 3 _decimal C extension failed to import — attempting fix ...")
|
||||
if fixPython3Decimal() {
|
||||
fmt.Println(" Python 3 _decimal extension restored.")
|
||||
} else {
|
||||
errLog("Python 3 _decimal C extension could not be fixed. " +
|
||||
"Run: sudo apt-get install python3-full (Debian/Ubuntu), " +
|
||||
"sudo dnf install python3-libs (Fedora/RHEL), or " +
|
||||
"sudo pacman -S python (Arch)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println(" Bootstrapping pip via 'python3 -m ensurepip --upgrade' ...")
|
||||
bootstrap := runCmd([]string{"python3", "-m", "ensurepip", "--upgrade"}, CmdOpts{AsSudo: true})
|
||||
if !bootstrap.OK() {
|
||||
switch pkgMgr {
|
||||
case "apt-get":
|
||||
warn("ensurepip unavailable in system Python — installing python3-pip via apt-get")
|
||||
if !runCmd([]string{"apt-get", "install", "-y", "python3-pip"}, CmdOpts{AsSudo: true}).OK() {
|
||||
errLog("python3-pip failed to install via apt-get — skipping pip bootstrap")
|
||||
return
|
||||
}
|
||||
case "pacman":
|
||||
warn("ensurepip unavailable in system Python — installing python-pip via pacman")
|
||||
if !runCmd([]string{"pacman", "-S", "--noconfirm", "--needed", "python-pip"}, CmdOpts{AsSudo: true}).OK() {
|
||||
errLog("python-pip failed to install via pacman — skipping pip bootstrap")
|
||||
return
|
||||
}
|
||||
default:
|
||||
errLog("python3 -m ensurepip failed (system Python may need a distro 'python3-pip' package)")
|
||||
return
|
||||
}
|
||||
}
|
||||
fmt.Println(" Upgrading pip to the latest version ...")
|
||||
upgrade := runCmd([]string{"python3", "-m", "pip", "install", "--upgrade", "pip"}, CmdOpts{AsSudo: true})
|
||||
if !upgrade.OK() {
|
||||
warn("pip self-upgrade failed (likely PEP 668 externally-managed); ensurepip-provided pip remains")
|
||||
}
|
||||
}
|
||||
|
||||
func latestStablePython(pyenvBin string) string {
|
||||
r, ok := probe([]string{pyenvBin, "install", "--list"}, 2*time.Minute)
|
||||
if !ok {
|
||||
errLog("pyenv install --list failed")
|
||||
return ""
|
||||
}
|
||||
if r.ExitCode != 0 {
|
||||
errLog("pyenv install --list failed")
|
||||
return ""
|
||||
}
|
||||
stableRe := regexp.MustCompile(`^\s*(\d+)\.(\d+)\.(\d+)\s*$`)
|
||||
type ver struct{ a, b, c int }
|
||||
var versions []ver
|
||||
for _, line := range strings.Split(string(r.Stdout), "\n") {
|
||||
m := stableRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
a, _ := strconv.Atoi(m[1])
|
||||
b, _ := strconv.Atoi(m[2])
|
||||
c, _ := strconv.Atoi(m[3])
|
||||
if a >= 3 {
|
||||
versions = append(versions, ver{a, b, c})
|
||||
}
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Slice(versions, func(i, j int) bool {
|
||||
if versions[i].a != versions[j].a {
|
||||
return versions[i].a < versions[j].a
|
||||
}
|
||||
if versions[i].b != versions[j].b {
|
||||
return versions[i].b < versions[j].b
|
||||
}
|
||||
return versions[i].c < versions[j].c
|
||||
})
|
||||
v := versions[len(versions)-1]
|
||||
return fmt.Sprintf("%d.%d.%d", v.a, v.b, v.c)
|
||||
}
|
||||
|
||||
// ensurePythonLatest installs the latest stable Python via pyenv if not
|
||||
// present, then sets it as global. Returns a wait group if an install was
|
||||
// kicked off in the background; the caller must call .Wait() before exiting.
|
||||
func ensurePythonLatest() *sync.WaitGroup {
|
||||
home, _ := os.UserHomeDir()
|
||||
pyenvDir := filepath.Join(home, ".pyenv")
|
||||
if _, err := os.Stat(pyenvDir); err != nil {
|
||||
return nil
|
||||
}
|
||||
pyenvBin := filepath.Join(pyenvDir, "bin", "pyenv")
|
||||
if _, err := os.Stat(pyenvBin); err != nil {
|
||||
warn(fmt.Sprintf("pyenv binary not found at %s", pyenvBin))
|
||||
return nil
|
||||
}
|
||||
|
||||
latest := latestStablePython(pyenvBin)
|
||||
if latest == "" {
|
||||
errLog("Could not determine latest stable Python from pyenv")
|
||||
return nil
|
||||
}
|
||||
|
||||
r, ok := probe([]string{pyenvBin, "versions", "--bare"}, 30*time.Second)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
installed := strings.Fields(string(r.Stdout))
|
||||
for _, v := range installed {
|
||||
if v == latest {
|
||||
fmt.Printf("\n[pyenv] Python %s already installed.\n", latest)
|
||||
fmt.Printf("[pyenv] Setting Python %s as global default ...\n", latest)
|
||||
if !runCmd([]string{pyenvBin, "global", latest}, CmdOpts{}).OK() {
|
||||
errLog(fmt.Sprintf("pyenv global %s failed", latest))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n[pyenv] Backgrounding install of Python %s (compile may take several minutes) ...\n", latest)
|
||||
start := time.Now()
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
r := runCmd([]string{pyenvBin, "install", "--skip-existing", latest},
|
||||
CmdOpts{Timeout: 60 * time.Minute, Capture: true})
|
||||
elapsed := int(time.Since(start).Seconds())
|
||||
if !r.OK() {
|
||||
errLog(fmt.Sprintf("pyenv install %s failed after %ds", latest, elapsed))
|
||||
if len(r.Stderr) > 0 {
|
||||
lines := strings.Split(string(r.Stderr), "\n")
|
||||
if len(lines) > 20 {
|
||||
lines = lines[len(lines)-20:]
|
||||
}
|
||||
fmt.Printf("\n[pyenv stderr tail]\n%s\n", strings.Join(lines, "\n"))
|
||||
}
|
||||
return
|
||||
}
|
||||
if !runCmd([]string{pyenvBin, "global", latest},
|
||||
CmdOpts{Timeout: time.Minute}).OK() {
|
||||
errLog(fmt.Sprintf("pyenv global %s failed", latest))
|
||||
return
|
||||
}
|
||||
fmt.Printf("\n[pyenv] Python %s installed and set as global default (%ds).\n", latest, elapsed)
|
||||
}()
|
||||
return &wg
|
||||
}
|
||||
|
||||
// ── nvm / Node ──────────────────────────────────────────────────────────
|
||||
|
||||
func installNVM() {
|
||||
var rel ghRelease
|
||||
if !fetchJSON("https://api.github.com/repos/nvm-sh/nvm/releases/latest", &rel) {
|
||||
return
|
||||
}
|
||||
version := rel.TagName
|
||||
if version == "" {
|
||||
errLog("NVM tag_name missing")
|
||||
return
|
||||
}
|
||||
installURL := fmt.Sprintf("https://raw.githubusercontent.com/nvm-sh/nvm/%s/install.sh", version)
|
||||
fmt.Printf(" Installing NVM %s via curl ...\n", version)
|
||||
if !runShell(fmt.Sprintf("curl -o- %s | bash", installURL), CmdOpts{}).OK() {
|
||||
errLog("NVM installation failed")
|
||||
return
|
||||
}
|
||||
fmt.Printf(" NVM %s installed to ~/.nvm\n", version)
|
||||
}
|
||||
|
||||
func ensureNodeLTS() {
|
||||
home, _ := os.UserHomeDir()
|
||||
if _, err := os.Stat(filepath.Join(home, ".nvm")); err != nil {
|
||||
return
|
||||
}
|
||||
check := runShell(`bash -c "source ~/.nvm/nvm.sh 2>/dev/null && nvm version lts/* 2>/dev/null"`,
|
||||
CmdOpts{Capture: true})
|
||||
installed := strings.TrimSpace(string(check.Stdout))
|
||||
if installed != "" && installed != "N/A" {
|
||||
fmt.Printf("\n[NVM] Node LTS (%s) already installed.\n", installed)
|
||||
} else {
|
||||
fmt.Println("\n[NVM] Installing Node.js LTS ...")
|
||||
if !runShell(`bash -c "source ~/.nvm/nvm.sh && nvm install --lts"`, CmdOpts{}).OK() {
|
||||
errLog("Node.js LTS install via nvm failed")
|
||||
return
|
||||
}
|
||||
fmt.Println(" Node.js LTS installed.")
|
||||
}
|
||||
|
||||
fmt.Println("[NVM] Setting Node LTS as default ...")
|
||||
if !runShell(`bash -c "source ~/.nvm/nvm.sh && nvm alias default 'lts/*' && nvm use --lts"`, CmdOpts{}).OK() {
|
||||
errLog("Setting nvm default to LTS failed")
|
||||
}
|
||||
}
|
||||
|
||||
// ── oh-my-zsh ───────────────────────────────────────────────────────────
|
||||
|
||||
func installOhMyZsh() {
|
||||
if !hasCmd("zsh") {
|
||||
errLog("zsh is not installed — required by oh-my-zsh")
|
||||
return
|
||||
}
|
||||
if !hasCmd("git") {
|
||||
errLog("git is not installed — required by oh-my-zsh")
|
||||
return
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
target := filepath.Join(home, ".oh-my-zsh")
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
fmt.Printf(" oh-my-zsh already present at %s; updating theme only\n", target)
|
||||
} else {
|
||||
fmt.Println(" Installing oh-my-zsh via the official installer ...")
|
||||
installer := `sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended`
|
||||
if !runShell(installer, CmdOpts{}).OK() {
|
||||
errLog("oh-my-zsh installer failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
zshrc := filepath.Join(home, ".zshrc")
|
||||
data, err := os.ReadFile(zshrc)
|
||||
if err != nil {
|
||||
warn("~/.zshrc not present after oh-my-zsh install; cannot set theme")
|
||||
return
|
||||
}
|
||||
text := string(data)
|
||||
re := regexp.MustCompile(`(?m)^\s*ZSH_THEME=.*$`)
|
||||
var newText string
|
||||
if re.MatchString(text) {
|
||||
newText = re.ReplaceAllString(text, `ZSH_THEME="gnzh"`)
|
||||
} else {
|
||||
newText = strings.TrimRight(text, "\n") + "\nZSH_THEME=\"gnzh\"\n"
|
||||
}
|
||||
if newText != text {
|
||||
if err := os.WriteFile(zshrc, []byte(newText), 0o644); err != nil {
|
||||
errLog(fmt.Sprintf("could not write ~/.zshrc: %v", err))
|
||||
return
|
||||
}
|
||||
fmt.Println(` Set ZSH_THEME="gnzh" in ~/.zshrc`)
|
||||
} else {
|
||||
fmt.Println(` ~/.zshrc already has ZSH_THEME="gnzh"`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── default-shell + neovim config + gh auth ─────────────────────────────
|
||||
|
||||
func invokingUser() string {
|
||||
if u := os.Getenv("SUDO_USER"); u != "" {
|
||||
return u
|
||||
}
|
||||
if u, err := user.Current(); err == nil {
|
||||
return u.Username
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func ensureZshDefault() {
|
||||
if !hasCmd("zsh") {
|
||||
warn("zsh not installed — skipping default-shell change")
|
||||
return
|
||||
}
|
||||
zshPath := "/bin/zsh"
|
||||
if r, ok := probe([]string{"which", "zsh"}, 5*time.Second); ok && r.ExitCode == 0 {
|
||||
if p := strings.TrimSpace(string(r.Stdout)); p != "" {
|
||||
zshPath = p
|
||||
}
|
||||
}
|
||||
username := invokingUser()
|
||||
if username == "" {
|
||||
warn("could not determine invoking user; skipping default-shell change")
|
||||
return
|
||||
}
|
||||
u, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
warn(fmt.Sprintf("user %s not found in passwd; skipping default-shell change", username))
|
||||
return
|
||||
}
|
||||
current := userLoginShell(u.Uid)
|
||||
if current == zshPath {
|
||||
fmt.Printf("\n[zsh] %s's default shell is already %s.\n", username, zshPath)
|
||||
return
|
||||
}
|
||||
|
||||
family := "Debian-family"
|
||||
if isRHELFamily {
|
||||
family = "RHEL-family"
|
||||
} else if isArchFamily {
|
||||
family = "Arch-family"
|
||||
} else if isMacOS {
|
||||
family = "macOS"
|
||||
}
|
||||
fmt.Printf("\n[zsh] Setting default shell for %s to %s (%s) ...\n", username, zshPath, family)
|
||||
|
||||
var cmd []string
|
||||
if isRHELFamily {
|
||||
cmd = []string{"usermod", "-s", zshPath, username}
|
||||
} else {
|
||||
cmd = []string{"chsh", "-s", zshPath, username}
|
||||
}
|
||||
if !runCmd(cmd, CmdOpts{AsSudo: true}).OK() {
|
||||
errLog(fmt.Sprintf("Failed to set default shell to zsh for %s", username))
|
||||
} else {
|
||||
fmt.Println("[zsh] Default shell updated. Log out and back in for it to take effect.")
|
||||
}
|
||||
}
|
||||
|
||||
// userLoginShell returns the login shell for uid by parsing /etc/passwd. On
|
||||
// macOS the shell may be set by dscl; getent isn't available either, so we
|
||||
// just read passwd directly which works on every supported platform.
|
||||
func userLoginShell(uid string) string {
|
||||
data, err := os.ReadFile("/etc/passwd")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) < 7 {
|
||||
continue
|
||||
}
|
||||
if parts[2] == uid {
|
||||
return parts[6]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cloneNvimConfig() {
|
||||
home, _ := os.UserHomeDir()
|
||||
configDir := filepath.Join(home, ".config", "nvim")
|
||||
repoURL := "git@github.com:JMR-dev/nvim-config.git"
|
||||
|
||||
fmt.Printf("\n[Neovim] Setting up configuration from %s ...\n", repoURL)
|
||||
|
||||
if _, err := os.Stat(configDir); err == nil {
|
||||
n := 1
|
||||
var backup string
|
||||
for {
|
||||
backup = filepath.Join(filepath.Dir(configDir), fmt.Sprintf("nvim-%d", n))
|
||||
if _, err := os.Stat(backup); os.IsNotExist(err) {
|
||||
break
|
||||
}
|
||||
n++
|
||||
}
|
||||
fmt.Printf(" Renaming existing %s → %s ...\n", configDir, backup)
|
||||
if err := os.Rename(configDir, backup); err != nil {
|
||||
errLog(fmt.Sprintf("could not back up existing nvim config: %v", err))
|
||||
return
|
||||
}
|
||||
notice(fmt.Sprintf("Previous Neovim config preserved at %s", backup))
|
||||
}
|
||||
|
||||
os.MkdirAll(filepath.Dir(configDir), 0o755)
|
||||
|
||||
repoName := strings.TrimSuffix(filepath.Base(repoURL), ".git")
|
||||
tempClone := filepath.Join(filepath.Dir(configDir), repoName)
|
||||
os.RemoveAll(tempClone)
|
||||
|
||||
fmt.Printf(" Cloning to %s ...\n", configDir)
|
||||
if !runCmd([]string{"git", "clone", repoURL, tempClone}, CmdOpts{}).OK() {
|
||||
errLog("Neovim configuration clone failed")
|
||||
return
|
||||
}
|
||||
if tempClone != configDir {
|
||||
fmt.Printf(" Renaming %s to %s ...\n", filepath.Base(tempClone), filepath.Base(configDir))
|
||||
os.Rename(tempClone, configDir)
|
||||
}
|
||||
fmt.Printf(" Neovim configuration ready at %s\n", configDir)
|
||||
}
|
||||
|
||||
func ghLoggedIn() bool {
|
||||
r, ok := probe([]string{"gh", "auth", "status"}, 30*time.Second)
|
||||
return ok && r.ExitCode == 0
|
||||
}
|
||||
|
||||
func checkAndSetupSSH() {
|
||||
if !hasCmd("gh") {
|
||||
fmt.Println("\n[GitHub CLI] gh not installed — skipping authentication.")
|
||||
return
|
||||
}
|
||||
if ghLoggedIn() {
|
||||
fmt.Println("\n[GitHub CLI] Already authenticated.")
|
||||
return
|
||||
}
|
||||
if !askYN("\n[GitHub CLI] Would you like to authenticate the GitHub CLI? [y/N] ") {
|
||||
return
|
||||
}
|
||||
res := runCmd([]string{"gh", "auth", "login"}, CmdOpts{Timeout: 15 * time.Minute})
|
||||
if !res.OK() {
|
||||
errLog("gh auth login failed — skipping key upload.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// askYN prompts on stdin. Returns true only for an exact "y" (case-insensitive).
|
||||
func askYN(prompt string) bool {
|
||||
fmt.Print(prompt)
|
||||
var buf [256]byte
|
||||
n, err := os.Stdin.Read(buf[:])
|
||||
if err != nil && err != io.EOF {
|
||||
fmt.Println()
|
||||
return false
|
||||
}
|
||||
answer := strings.ToLower(strings.TrimSpace(string(buf[:n])))
|
||||
return answer == "y"
|
||||
}
|
||||
Reference in New Issue
Block a user