port: rewrite bootstrap tool in Go #14

Merged
JMR-dev merged 2 commits from claude/port-tool-golang-ziaMN into main 2026-05-22 22:00:30 +00:00
21 changed files with 3772 additions and 2693 deletions
+61
View File
@@ -0,0 +1,61 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Tag to release (e.g. v0.1.0). The tag must already exist.'
required: true
type: string
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Resolve ref
id: ref
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "ref=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
else
echo "ref=${{ github.ref }}" >> "$GITHUB_OUTPUT"
echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
fi
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ steps.ref.outputs.ref }}
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
- name: Build all targets
run: make build-all
- name: Generate SHA256SUMS
working-directory: dist
run: |
sha256sum bootstrap_environment-* > SHA256SUMS
cat SHA256SUMS
- name: Create release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.ref.outputs.tag }}
name: ${{ steps.ref.outputs.tag }}
generate_release_notes: true
fail_on_unmatched_files: true
files: |
dist/bootstrap_environment-*
dist/SHA256SUMS
+4 -1
View File
@@ -1 +1,4 @@
__pycache__/
dist/
bootstrap_dev_env
bootstrap_environment
bootstrap_run.log
+44
View File
@@ -0,0 +1,44 @@
BINARY := bootstrap_environment
DIST := dist
PKG := .
# Statically-linked, stripped binaries for distribution.
GOFLAGS := -trimpath -ldflags="-s -w"
TARGETS := \
linux/amd64 \
linux/arm64 \
darwin/amd64 \
darwin/arm64
.PHONY: all build build-all test vet fmt clean
all: build
build:
go build $(GOFLAGS) -o $(BINARY) $(PKG)
# Cross-compile native binaries for each supported (OS, arch) pair into dist/.
build-all: $(DIST)
@for t in $(TARGETS); do \
os=$${t%/*}; arch=$${t#*/}; \
out=$(DIST)/$(BINARY)-$$os-$$arch; \
echo "==> $$os/$$arch -> $$out"; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch \
go build $(GOFLAGS) -o $$out $(PKG) || exit 1; \
done
$(DIST):
mkdir -p $(DIST)
test:
go test ./...
vet:
go vet ./...
fmt:
gofmt -w .
clean:
rm -rf $(DIST) $(BINARY)
+42 -6
View File
@@ -1,8 +1,44 @@
# Usage
# bootstrap_dev_env
``` shell
curl -L -o bootstrap.zip https://github.com/JMR-dev/bootstrap_dev_env/archive/refs/heads/main.zip && \
unzip bootstrap.zip && \
cd bootstrap_dev_env-main && \
python3 bootstrap_environment.py
A Go-based bootstrap tool that installs a development environment across
macOS, Debian/Ubuntu, RHEL/Fedora, and Arch Linux on `x86_64` and `aarch64`.
It installs system packages, optional Flatpak GUI apps, and a set of custom
third-party tools (Go, Neovim, Zig, NVM, pyenv, oh-my-zsh, Firecracker on
Linux).
## Install
Download the native binary for your platform from a release, or build from
source:
```shell
git clone https://github.com/JMR-dev/bootstrap_dev_env.git
cd bootstrap_dev_env
make build # builds ./bootstrap_environment for the host
```
To produce native binaries for all four supported targets at once:
```shell
make build-all # writes dist/bootstrap_environment-{linux,darwin}-{amd64,arm64}
```
## Usage
```shell
# Linux (do NOT use sudo on macOS — Homebrew refuses to run as root)
sudo ./bootstrap_environment [--only system|flatpak|custom] [--gui]
# macOS
./bootstrap_environment [--only system|custom] [--gui] [--no-vm]
```
Flags:
- `--only` — restrict to one section (`system`, `flatpak`, or `custom`).
- `--gui` — include GUI applications and the Flatpak section. Default is
headless: both are skipped.
- `--no-vm` — macOS only: skip provisioning the Fedora-on-QEMU/VirtualBox VM
that backs the `firecracker()` zsh wrapper.
Package lists live in `packages.go`. Edit and rebuild.
File diff suppressed because it is too large Load Diff
+177
View File
@@ -0,0 +1,177 @@
package main
import (
"fmt"
"strings"
)
type systemCheckResult struct {
toInstallRegular []string
toInstallSpecial []string
alreadyInstalled []string
skipped []string
remapped []remap // for display only
}
type remap struct {
From string
To []string
}
type flatpakCheckResult struct {
toInstall []string
alreadyInstalled []string
}
type customCheckResult struct {
toInstall []*CustomPackage
alreadyInstalled []customStatus
}
type customStatus struct {
pkg *CustomPackage
path string
}
func checkSystemPackages(names []string) systemCheckResult {
overrides := packageOverrides[pkgMgr]
resolved, skipped := resolveSystemPkgs(names)
var remapped []remap
for _, n := range names {
if ov, ok := overrides[n]; ok && !ov.Skip {
remapped = append(remapped, remap{From: n, To: ov.Replacement})
}
}
specials := specialPkgs()
var special, regular []string
for _, p := range resolved {
if specials[p] {
special = append(special, p)
} else {
regular = append(regular, p)
}
}
var toR, alreadyR []string
for _, p := range regular {
if isSystemPkgInstalled(p) {
alreadyR = append(alreadyR, p)
} else {
toR = append(toR, p)
}
}
var toS, alreadyS []string
for _, p := range special {
if isSpecialPkgInstalled(p) {
alreadyS = append(alreadyS, p)
} else {
toS = append(toS, p)
}
}
return systemCheckResult{
toInstallRegular: toR,
toInstallSpecial: toS,
alreadyInstalled: append(alreadyR, alreadyS...),
skipped: skipped,
remapped: remapped,
}
}
func checkFlatpakPackages(ids []string) flatpakCheckResult {
var to, already []string
for _, p := range ids {
if isFlatpakInstalled(p) {
already = append(already, p)
} else {
to = append(to, p)
}
}
return flatpakCheckResult{toInstall: to, alreadyInstalled: already}
}
func checkCustomPackages(pkgs []*CustomPackage) customCheckResult {
var to []*CustomPackage
var already []customStatus
for _, p := range pkgs {
installed, path := isCustomPkgInstalled(p)
if installed {
already = append(already, customStatus{pkg: p, path: path})
} else {
to = append(to, p)
}
}
return customCheckResult{toInstall: to, alreadyInstalled: already}
}
func fmtList(items []string, limit int) string {
if len(items) <= limit {
return strings.Join(items, " ")
}
return strings.Join(items[:limit], " ") + fmt.Sprintf(" … +%d more", len(items)-limit)
}
func printCheckSummary(sys systemCheckResult, flat flatpakCheckResult, cust customCheckResult, only string) int {
total := 0
if only == "" || only == "system" {
toR := sys.toInstallRegular
toS := sys.toInstallSpecial
ok := sys.alreadyInstalled
fmt.Println("\nSystem packages:")
if len(ok) > 0 {
fmt.Printf(" [OK] %3d already installed\n", len(ok))
}
n := len(toR) + len(toS)
if n > 0 {
combined := append([]string{}, toR...)
combined = append(combined, toS...)
fmt.Printf(" [INSTALL] %3d to install: %s\n", n, fmtList(combined, 6))
}
if len(sys.skipped) > 0 {
fmt.Printf(" [SKIP] %3d overridden (→ skip): %s\n", len(sys.skipped), fmtList(sys.skipped, 6))
}
if len(sys.remapped) > 0 {
var parts []string
for _, r := range sys.remapped {
parts = append(parts, fmt.Sprintf("%s→%s", r.From, strings.Join(r.To, ",")))
}
fmt.Printf(" [REMAP] remapped: %s\n", strings.Join(parts, " "))
}
total += n
}
if (only == "" || only == "flatpak") && (len(flat.toInstall) > 0 || len(flat.alreadyInstalled) > 0) {
fmt.Println("\nFlatpak packages:")
if len(flat.alreadyInstalled) > 0 {
fmt.Printf(" [OK] %3d already installed\n", len(flat.alreadyInstalled))
}
if len(flat.toInstall) > 0 {
fmt.Printf(" [INSTALL] %3d to install: %s\n", len(flat.toInstall), fmtList(flat.toInstall, 6))
}
total += len(flat.toInstall)
}
if only == "" || only == "custom" {
fmt.Println("\nCustom packages:")
for _, s := range cust.alreadyInstalled {
suffix := ""
if s.path != "" {
suffix = fmt.Sprintf(" (%s)", s.path)
}
fmt.Printf(" [OK] %s%s\n", s.pkg.displayName(), suffix)
}
for _, p := range cust.toInstall {
_, path := isCustomPkgInstalled(p)
suffix := ""
if path != "" {
suffix = fmt.Sprintf(" → %s", path)
}
fmt.Printf(" [INSTALL] %s%s\n", p.displayName(), suffix)
}
total += len(cust.toInstall)
}
return total
}
+509
View File
@@ -0,0 +1,509 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)
// resolveURL returns the formatted download URL or "" if no template is set.
func (p *CustomPackage) resolveURL() string {
if p.URLTemplate == "" {
return ""
}
return formatURL(p.URLTemplate, p.Version)
}
func (p *CustomPackage) resolveSHA256URL() string {
if p.SHA256URLTemplate == "" {
return ""
}
return formatURL(p.SHA256URLTemplate, p.Version)
}
func (p *CustomPackage) resolvedSHA256() string {
if p.SHA256 != "" {
return strings.ToLower(p.SHA256)
}
if p.SHA256Map != nil {
key := osName + "-" + archName
if v, ok := p.SHA256Map[key]; ok {
return strings.ToLower(v)
}
}
return ""
}
func (p *CustomPackage) displayName() string {
if p.Version != "" {
return p.Name + "-" + p.Version
}
return p.Name
}
var defaultInstallPaths = map[string]string{
"go": "/usr/local/go",
"firecracker": "/usr/local/bin/firecracker",
"zig": "/usr/local/bin/zig",
"nvm": "~/.nvm",
"pyenv": "~/.pyenv",
"neovim": "/usr/local/bin/nvim",
"oh-my-zsh": "~/.oh-my-zsh",
}
func expandHome(p string) string {
if strings.HasPrefix(p, "~") {
home, err := os.UserHomeDir()
if err == nil {
return filepath.Join(home, strings.TrimPrefix(p, "~"))
}
}
return p
}
func defaultInstallPath(pkg *CustomPackage) string {
if p, ok := defaultInstallPaths[strings.ToLower(pkg.Name)]; ok {
return p
}
return ""
}
func pipInstalled() bool {
if !hasCmd("python3") {
return false
}
r, ok := probe([]string{"python3", "-m", "pip", "--version"}, 0)
return ok && r.ExitCode == 0
}
// isCustomPkgInstalled returns (installed, checkPath). pip ships inside the
// Python distribution rather than at a fixed path, so it's detected with
// `python3 -m pip --version`.
func isCustomPkgInstalled(pkg *CustomPackage) (bool, string) {
if strings.ToLower(pkg.Name) == "pip" {
return pipInstalled(), ""
}
raw := pkg.InstallPath
if raw == "" {
raw = defaultInstallPath(pkg)
}
if raw == "" {
return false, ""
}
check := expandHome(raw)
if _, err := os.Stat(check); err == nil {
return true, check
}
return false, check
}
// verifyArchive validates a downloaded archive against either a pinned
// sha256 or a .minisig signature. Returns true when verified or nothing to
// verify (latter case logs a warning).
func verifyArchive(archive string, pkg *CustomPackage) bool {
if expected := pkg.resolvedSHA256(); expected != "" {
actual, err := sha256Of(archive)
if err != nil {
errLog(fmt.Sprintf("hash failed for %s: %v", pkg.Name, err))
return false
}
if actual != expected {
errLog(fmt.Sprintf("SHA256 mismatch for %s: expected %s, got %s", pkg.Name, expected, actual))
return false
}
fmt.Println(" SHA256 OK")
return true
}
if sigURL := pkg.resolveSHA256URL(); sigURL != "" {
sigPath := filepath.Join(filepath.Dir(archive), filepath.Base(sigURL))
if !download(sigURL, sigPath) {
return false
}
if !hasCmd("minisign") {
warn(fmt.Sprintf("minisign not installed — skipping signature verification for %s", pkg.Name))
return true
}
cmd := []string{"minisign", "-Vm", archive, "-x", sigPath}
if pkg.MinisignKey != "" {
cmd = append(cmd, "-P", pkg.MinisignKey)
}
if !runCmd(cmd, CmdOpts{}).OK() {
errLog(fmt.Sprintf("minisign verification failed for %s", pkg.Name))
return false
}
fmt.Println(" minisign OK")
}
return true
}
func urlArchOK(pkg *CustomPackage) bool {
url := pkg.resolveURL()
if url == "" {
return true
}
if archMatches(url, archName) {
return true
}
if hasOtherArchToken(url) {
warn(fmt.Sprintf("%s: URL targets %s but host is %s. Update packages.go with a matching URL/SHA256.",
pkg.Name, otherArch(), archName))
return false
}
return true
}
// ── per-package install handlers ────────────────────────────────────────
func installGo(archive string) {
goRoot := "/usr/local/go"
if _, err := os.Stat(goRoot); err == nil {
fmt.Printf(" Removing existing Go at %s ...\n", goRoot)
runCmd([]string{"rm", "-rf", goRoot}, CmdOpts{AsSudo: true})
}
runCmd([]string{"tar", "-C", "/usr/local", "-xzf", archive}, CmdOpts{AsSudo: true})
appendProfileLine("local_go", "export PATH=$PATH:/usr/local/go/bin")
fmt.Printf(" Go installed to %s\n", goRoot)
}
func installFirecracker(archive, tmp string) {
if !runCmd([]string{"tar", "-C", tmp, "-xzf", archive}, CmdOpts{}).OK() {
errLog("firecracker tar extraction failed")
return
}
var binary string
filepath.Walk(tmp, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
name := info.Name()
if !strings.HasPrefix(name, "firecracker") {
return nil
}
if strings.HasSuffix(name, ".debug") || strings.Contains(name, "debug") {
return nil
}
if binary == "" {
binary = path
}
return nil
})
if binary == "" {
errLog("firecracker binary not found in archive")
return
}
dest := "/usr/local/bin/firecracker"
runCmd([]string{"cp", binary, dest}, CmdOpts{AsSudo: true})
runCmd([]string{"chmod", "755", dest}, CmdOpts{AsSudo: true})
fmt.Printf(" firecracker installed to %s\n", dest)
}
func installZig(pkg *CustomPackage, archive string) {
parent := "/usr/local"
zigDir := filepath.Join(parent, "zig-"+pkg.Version)
if _, err := os.Stat(zigDir); err == nil {
runCmd([]string{"rm", "-rf", zigDir}, CmdOpts{AsSudo: true})
}
runCmd([]string{"tar", "-C", parent, "-xJf", archive}, CmdOpts{AsSudo: true})
pattern := filepath.Join(parent, fmt.Sprintf("zig-%s-%s*", archName, osZig[osName]))
matches, _ := filepath.Glob(pattern)
for _, m := range matches {
if m != zigDir {
runCmd([]string{"mv", m, zigDir}, CmdOpts{AsSudo: true})
break
}
}
symlink := "/usr/local/bin/zig"
runCmd([]string{"ln", "-sf", filepath.Join(zigDir, "zig"), symlink}, CmdOpts{AsSudo: true})
fmt.Printf(" Zig installed to %s, symlinked at %s\n", zigDir, symlink)
}
func installNeovim(_ *CustomPackage, tmp string) {
var rel ghRelease
if !fetchJSON("https://api.github.com/repos/neovim/neovim/releases/latest", &rel) {
return
}
archTok := archNvim[archName]
osTok := osNvim[osName]
assetName := fmt.Sprintf("nvim-%s-%s.tar.gz", osTok, archTok)
var asset *ghAsset
for i := range rel.Assets {
if rel.Assets[i].Name == assetName {
asset = &rel.Assets[i]
break
}
}
if asset == nil {
errLog(fmt.Sprintf("Neovim asset %s not found", assetName))
return
}
if !strings.HasPrefix(asset.Digest, "sha256:") {
errLog("Neovim asset digest missing or invalid")
return
}
expected := strings.TrimPrefix(asset.Digest, "sha256:")
dest := filepath.Join(tmp, assetName)
if !download(asset.BrowserDownloadURL, dest) {
return
}
actual, err := sha256Of(dest)
if err != nil {
errLog(fmt.Sprintf("Neovim hash failed: %v", err))
return
}
if actual != expected {
errLog(fmt.Sprintf("Neovim SHA256 mismatch: expected %s, got %s", expected, actual))
return
}
fmt.Println(" SHA256 OK")
installDir := fmt.Sprintf("/opt/nvim-%s-%s", osTok, archTok)
fmt.Println(" Extracting Neovim 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})
runCmd([]string{"mkdir", "-p", "/usr/local/bin"}, CmdOpts{AsSudo: true})
symlink := "/usr/local/bin/nvim"
runCmd([]string{"ln", "-sf", filepath.Join(installDir, "bin", "nvim"), symlink}, CmdOpts{AsSudo: true})
fmt.Printf(" Neovim installed to %s, symlinked at %s\n", installDir, symlink)
}
// ── latest-version resolvers ────────────────────────────────────────────
func resolveLatestGo(_ *CustomPackage) (string, string, bool) {
var raw json.RawMessage
if !fetchJSON("https://go.dev/dl/?mode=json", &raw) {
return "", "", false
}
// API returns an array; first element is the latest stable release.
type goFile struct {
Filename string `json:"filename"`
Kind string `json:"kind"`
SHA256 string `json:"sha256"`
}
type goRelease struct {
Version string `json:"version"`
Files []goFile `json:"files"`
}
var releases []goRelease
if err := json.Unmarshal(raw, &releases); err != nil || len(releases) == 0 {
var single goRelease
if err := json.Unmarshal(raw, &single); err != nil {
return "", "", false
}
releases = []goRelease{single}
}
latest := releases[0]
version := strings.TrimPrefix(latest.Version, "go")
if version == "" {
return "", "", false
}
archiveName := fmt.Sprintf("go%s.%s-%s.tar.gz", version, osGo[osName], archGo[archName])
for _, f := range latest.Files {
if f.Filename == archiveName && f.Kind == "archive" && f.SHA256 != "" {
return version, f.SHA256, true
}
}
return "", "", false
}
func resolveLatestFirecracker(_ *CustomPackage) (string, string, bool) {
if isMacOS {
return "", "", false
}
var rel ghRelease
if !fetchJSON("https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest", &rel) {
return "", "", false
}
version := strings.TrimPrefix(rel.TagName, "v")
if version == "" {
return "", "", false
}
archiveName := fmt.Sprintf("firecracker-v%s-%s.tgz", version, archName)
shaAssetName := archiveName + ".sha256.txt"
for _, a := range rel.Assets {
if a.Name == shaAssetName {
sha := fetchText(a.BrowserDownloadURL)
if sha == "" {
return "", "", false
}
return version, strings.Fields(sha)[0], true
}
}
return "", "", false
}
var zigVersionRe = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
func resolveLatestZig(_ *CustomPackage) (string, string, bool) {
var data map[string]map[string]any
if !fetchJSON("https://ziglang.org/download/index.json", &data) {
return "", "", false
}
var stable []string
for k := range data {
if k != "master" && zigVersionRe.MatchString(k) {
stable = append(stable, k)
}
}
if len(stable) == 0 {
return "", "", false
}
sort.Slice(stable, func(i, j int) bool {
return cmpSemver(stable[i], stable[j]) < 0
})
version := stable[len(stable)-1]
key := archName + "-" + osZig[osName]
entry, ok := data[version][key].(map[string]any)
if !ok {
return "", "", false
}
sha, _ := entry["shasum"].(string)
if sha == "" {
return "", "", false
}
return version, sha, true
}
func cmpSemver(a, b string) int {
pa := strings.Split(a, ".")
pb := strings.Split(b, ".")
for i := 0; i < len(pa) && i < len(pb); i++ {
ai, _ := strconv.Atoi(pa[i])
bi, _ := strconv.Atoi(pb[i])
if ai != bi {
if ai < bi {
return -1
}
return 1
}
}
return len(pa) - len(pb)
}
var latestResolvers = map[string]func(*CustomPackage) (string, string, bool){
"go": resolveLatestGo,
"firecracker": resolveLatestFirecracker,
"zig": resolveLatestZig,
}
// resolveLatest best-effort upgrades pkg.Version/SHA256 to the latest release.
// On any failure, warns and leaves the pinned values in place.
func resolveLatest(pkg *CustomPackage) {
resolver, ok := latestResolvers[pkg.FetchLatest]
if !ok {
return
}
fmt.Printf(" Checking latest version for %s ...\n", pkg.Name)
defer func() {
if r := recover(); r != nil {
warn(fmt.Sprintf("%s: latest-version lookup panicked %v; falling back to pinned version %s",
pkg.Name, r, pkg.Version))
}
}()
version, sha, found := resolver(pkg)
if !found {
warn(fmt.Sprintf("%s: could not resolve latest version; falling back to pinned version %s",
pkg.Name, pkg.Version))
return
}
if version == pkg.Version {
fmt.Printf(" Pinned version %s is already the latest.\n", pkg.Version)
return
}
fmt.Printf(" Latest is %s (pinned was %s); using latest.\n", version, pkg.Version)
pkg.Version = version
pkg.SHA256 = strings.ToLower(sha)
pkg.SHA256URLTemplate = "" // prefer the freshly resolved sha256
}
// ── orchestration ───────────────────────────────────────────────────────
func installCustomPackages(toInstall []*CustomPackage) {
fmt.Println("\n=== Custom Packages ===")
for _, pkg := range toInstall {
name := strings.ToLower(pkg.Name)
_, checkPath := isCustomPkgInstalled(pkg)
extra := ""
if checkPath != "" {
extra = fmt.Sprintf(" (install path: %s)", checkPath)
}
fmt.Printf("\n Installing %s ...%s\n", pkg.displayName(), extra)
if checkPath == "" && name != "pip" {
warn(fmt.Sprintf("%s: no known install path — script will not detect future installs", pkg.Name))
}
if name == "firecracker" && isMacOS {
warn(fmt.Sprintf("%s: Linux-only — skipping on macOS", pkg.Name))
continue
}
switch name {
case "nvm":
installNVM()
continue
case "pyenv":
installPyenv()
continue
case "pip":
installPip()
continue
case "oh-my-zsh":
installOhMyZsh()
continue
case "neovim":
tmp, err := os.MkdirTemp("", "bootstrap-nvim-")
if err != nil {
errLog(fmt.Sprintf("neovim tmp dir failed: %v", err))
continue
}
installNeovim(pkg, tmp)
os.RemoveAll(tmp)
continue
}
resolveLatest(pkg)
url := pkg.resolveURL()
if url == "" {
warn(fmt.Sprintf("No URL or install handler for '%s' — skipping", pkg.Name))
continue
}
if !urlArchOK(pkg) {
continue
}
tmp, err := os.MkdirTemp("", "bootstrap-custom-")
if err != nil {
errLog(fmt.Sprintf("tmp dir failed for %s: %v", pkg.Name, err))
continue
}
archive := filepath.Join(tmp, filepath.Base(url))
if !download(url, archive) {
os.RemoveAll(tmp)
continue
}
if !verifyArchive(archive, pkg) {
os.RemoveAll(tmp)
continue
}
switch name {
case "go":
installGo(archive)
case "firecracker":
installFirecracker(archive, tmp)
case "zig":
installZig(pkg, archive)
default:
warn(fmt.Sprintf("No install handler for '%s' — skipping", pkg.Name))
}
os.RemoveAll(tmp)
}
}
+174
View File
@@ -0,0 +1,174 @@
package main
import (
"fmt"
"os"
"runtime"
"strings"
)
// OS / architecture detection.
//
// Vendors disagree on canonical OS/arch tokens used in download URLs, so we
// keep our own normalized values ("linux"/"macos", "x86_64"/"aarch64") and
// translate at URL-construction time.
var (
osName string // "linux" or "macos"
archName string // "x86_64" or "aarch64"
isMacOS bool
isRHELFamily bool
isArchFamily bool
)
func init() {
osName = detectOS()
archName = detectArch()
isMacOS = osName == "macos"
}
func detectOS() string {
switch runtime.GOOS {
case "linux":
return "linux"
case "darwin":
return "macos"
default:
fmt.Fprintf(os.Stderr, "Unsupported OS: %s (supports Linux, Darwin)\n", runtime.GOOS)
os.Exit(1)
return ""
}
}
func detectArch() string {
switch runtime.GOARCH {
case "amd64":
return "x86_64"
case "arm64":
return "aarch64"
default:
fmt.Fprintf(os.Stderr, "Unsupported architecture: %s (supports x86_64, aarch64)\n", runtime.GOARCH)
os.Exit(1)
return ""
}
}
var (
archGo = map[string]string{"x86_64": "amd64", "aarch64": "arm64"}
archMinikube = map[string]string{"x86_64": "amd64", "aarch64": "arm64"}
archDeb = map[string]string{"x86_64": "amd64", "aarch64": "arm64"}
archNvim = map[string]string{"x86_64": "x86_64", "aarch64": "arm64"}
archPulumi = map[string]string{"x86_64": "x64", "aarch64": "arm64"}
osGo = map[string]string{"linux": "linux", "macos": "darwin"}
osZig = map[string]string{"linux": "linux", "macos": "macos"}
osNvim = map[string]string{"linux": "linux", "macos": "macos"}
archTokens = map[string][]string{
"x86_64": {"x86_64", "amd64", "x64"},
"aarch64": {"aarch64", "arm64"},
}
)
// formatURL interpolates {version}, {arch}, {arch_go}, {os}, {os_go},
// {os_zig}, {os_nvim} into a download URL template.
func formatURL(template, version string) string {
r := strings.NewReplacer(
"{version}", version,
"{arch}", archName,
"{arch_go}", archGo[archName],
"{os}", osName,
"{os_go}", osGo[osName],
"{os_zig}", osZig[osName],
"{os_nvim}", osNvim[osName],
)
return r.Replace(template)
}
func otherArch() string {
if archName == "x86_64" {
return "aarch64"
}
return "x86_64"
}
func archMatches(name, arch string) bool {
n := strings.ToLower(name)
for _, tok := range archTokens[arch] {
if strings.Contains(n, tok) {
return true
}
}
return false
}
func hasOtherArchToken(name string) bool {
n := strings.ToLower(name)
for _, tok := range archTokens[otherArch()] {
if strings.Contains(n, tok) {
return true
}
}
return false
}
// osReleaseField returns the value of /etc/os-release's NAME=value pair,
// stripped of surrounding quotes. Returns "" if the file is missing or the
// field is absent.
func osReleaseField(field string) string {
data, err := os.ReadFile("/etc/os-release")
if err != nil {
return ""
}
prefix := field + "="
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, prefix) {
return strings.Trim(strings.TrimPrefix(line, prefix), `"`)
}
}
return ""
}
func detectRHELFamily() bool {
data, err := os.ReadFile("/etc/os-release")
if err != nil {
return pkgMgr == "dnf"
}
tokens := []string{}
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "ID=") || strings.HasPrefix(line, "ID_LIKE=") {
_, val, _ := strings.Cut(line, "=")
val = strings.Trim(val, `"`)
tokens = append(tokens, strings.Fields(val)...)
}
}
rhel := map[string]bool{"rhel": true, "fedora": true, "centos": true, "rocky": true, "almalinux": true}
for _, t := range tokens {
if rhel[t] {
return true
}
}
return false
}
func detectArchFamily() bool {
data, err := os.ReadFile("/etc/os-release")
if err != nil {
return pkgMgr == "pacman"
}
tokens := []string{}
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "ID=") || strings.HasPrefix(line, "ID_LIKE=") {
_, val, _ := strings.Cut(line, "=")
val = strings.Trim(val, `"`)
tokens = append(tokens, strings.Fields(val)...)
}
}
arch := map[string]bool{"arch": true, "manjaro": true, "endeavouros": true, "artix": true}
for _, t := range tokens {
if arch[t] {
return true
}
}
return false
}
+181
View File
@@ -0,0 +1,181 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// Default per-call cap for runCmd and runShell. Generous enough for heavy
// installs (apt, brew, large downloads) but bounded so a stuck command can't
// hang the bootstrap forever. Override per-call for genuinely longer
// operations (e.g. pyenv compiles).
const defaultSubprocessTimeout = 30 * time.Minute
// CmdOpts captures the optional knobs on runCmd / runShell.
type CmdOpts struct {
AsSudo bool
Check bool // exit on failure (kept for parity but treated as advisory — we return the error instead)
Input []byte
Capture bool
Cwd string
Timeout time.Duration // zero = defaultSubprocessTimeout
}
// CmdResult holds the outcome of a subprocess invocation.
type CmdResult struct {
ExitCode int
Stdout []byte
Stderr []byte
Err error
}
func (r CmdResult) OK() bool { return r.Err == nil && r.ExitCode == 0 }
// runCmd executes argv with the supplied options.
func runCmd(argv []string, opts CmdOpts) CmdResult {
if opts.Timeout == 0 {
opts.Timeout = defaultSubprocessTimeout
}
if opts.AsSudo && os.Geteuid() != 0 {
argv = append([]string{"sudo"}, argv...)
}
fmt.Printf(" $ %s\n", strings.Join(argv, " "))
ctx, cancel := context.WithTimeout(context.Background(), opts.Timeout)
defer cancel()
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
if opts.Input != nil {
cmd.Stdin = bytes.NewReader(opts.Input)
}
var stdout, stderr bytes.Buffer
if opts.Capture {
cmd.Stdout = &stdout
cmd.Stderr = &stderr
} else {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
err := cmd.Run()
res := CmdResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
if ctx.Err() == context.DeadlineExceeded {
warn(fmt.Sprintf("%q timed out after %s", argv[0], opts.Timeout))
res.ExitCode = 124
res.Err = ctx.Err()
return res
}
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
res.ExitCode = exitErr.ExitCode()
res.Err = err
return res
}
warn(fmt.Sprintf("error launching %q: %v", argv[0], err))
res.ExitCode = 1
res.Err = err
}
return res
}
// runShell executes a single shell string via /bin/sh -c (matching the Python
// version's subprocess.run(..., shell=True)).
func runShell(cmd string, opts CmdOpts) CmdResult {
if opts.Timeout == 0 {
opts.Timeout = defaultSubprocessTimeout
}
fmt.Printf(" $ %s\n", cmd)
ctx, cancel := context.WithTimeout(context.Background(), opts.Timeout)
defer cancel()
c := exec.CommandContext(ctx, "/bin/sh", "-c", cmd)
if opts.Cwd != "" {
c.Dir = opts.Cwd
}
if opts.Input != nil {
c.Stdin = bytes.NewReader(opts.Input)
}
var stdout, stderr bytes.Buffer
if opts.Capture {
c.Stdout = &stdout
c.Stderr = &stderr
} else {
c.Stdout = os.Stdout
c.Stderr = os.Stderr
}
err := c.Run()
res := CmdResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
if ctx.Err() == context.DeadlineExceeded {
warn(fmt.Sprintf("shell command timed out after %s", opts.Timeout))
res.ExitCode = 124
res.Err = ctx.Err()
return res
}
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
res.ExitCode = exitErr.ExitCode()
res.Err = err
return res
}
warn(fmt.Sprintf("OSError in shell command: %v", err))
res.ExitCode = 1
res.Err = err
}
return res
}
// hasCmd is shutil.which() — returns true if name resolves on PATH.
func hasCmd(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
// probe is a short, read-only command invocation used for "is this installed"
// checks. Returns (result, true) on completion (including non-zero exit) and
// (zero, false) on timeout/launch failure.
func probe(argv []string, timeout time.Duration) (CmdResult, bool) {
if timeout == 0 {
timeout = 30 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
res := CmdResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
if ctx.Err() == context.DeadlineExceeded {
warn(fmt.Sprintf("%q probe timed out", argv[0]))
return res, false
}
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
res.ExitCode = exitErr.ExitCode()
return res, true
}
warn(fmt.Sprintf("%q probe failed: %v", argv[0], err))
return res, false
}
return res, true
}
+33
View File
@@ -0,0 +1,33 @@
package main
import "fmt"
func installFlatpakPackages(toInstall []string) {
fmt.Println("\n=== Flatpak Packages ===")
if !hasCmd("flatpak") {
fmt.Println(" flatpak is not installed.")
if !askYN(" Install flatpak now? [y/N] ") {
warn("flatpak not installed — skipping Flatpak section")
return
}
res := pkgInstall("flatpak")
if !res.OK() || !hasCmd("flatpak") {
errLog("flatpak installation failed — skipping Flatpak section")
return
}
}
runCmd([]string{
"flatpak", "remote-add", "--if-not-exists", "flathub",
"https://dl.flathub.org/repo/flathub.flatpakrepo",
}, CmdOpts{AsSudo: true})
for _, pkgID := range toInstall {
fmt.Printf("\n Installing %s ...\n", pkgID)
res := runCmd([]string{"flatpak", "install", "--noninteractive", "flathub", pkgID}, CmdOpts{})
if !res.OK() {
errLog(fmt.Sprintf("Flatpak failed to install: %s", pkgID))
}
}
}
-135
View File
@@ -1,135 +0,0 @@
"""Package definitions consumed by bootstrap_environment.py.
System and Flatpak packages are flat lists of names.
Custom packages declare a URL template plus an optional ``fetch_latest`` hint.
At install time the bootstrap script will attempt to look up the most recent
release and fall back to the pinned (version, sha256) tuple on failure.
URL templates use ``str.format`` with the following substitutions:
{version} pkg.version (or the latest resolved version)
{arch} "x86_64" or "aarch64"
{arch_go} Go-style: "amd64" or "arm64"
{os} "linux" or "macos"
{os_go} Go-style: "linux" or "darwin"
{os_zig} Zig-style: "linux" or "macos"
{os_nvim} Neovim-style: "linux" or "macos"
"""
SYSTEM_PACKAGES: list[str] = [
"ansible",
"ansible-core",
"aria2",
"bashtop",
"build-essential",
"buildah",
"containerd.io",
"docker-buildx-plugin",
"docker-ce-cli",
"docker-ce-rootless-extras",
"docker-ce",
"docker-compose-plugin",
"dotnet-sdk-10.0",
"ffmpeg-free",
"gcc",
"gh",
"git",
"github-desktop",
"google-chrome-stable",
"lazygit",
"lua",
"minisign",
"minikube",
"obs-studio",
"obsidian",
"pipx",
"poetry",
"pulumi",
"podman",
"qemu",
"restic",
"rg",
"shutter",
"temurin-25-jdk",
"vagrant",
"virt-manager",
"vivaldi-stable",
"webcamoid",
"wireshark",
"yt-dlp",
"zoom",
"zsh",
"bzip2",
"bzip2-devel",
"curl",
"gdbm-libs",
"libffi-devel",
"libnsl2",
"libuuid-devel",
"libxml2-devel",
"libzstd-devel",
"make",
"ncurses-devel",
"openssl-devel",
"patch",
"readline-devel",
"sqlite",
"sqlite-devel",
"tk-devel",
"xmlsec1-devel",
"xz",
"xz-devel",
"zlib-devel",
]
FLATPAK_PACKAGES: list[str] = [
"com.obsproject.Studio",
"fr.handbrake.ghb",
"io.github.webcamoid.Webcamoid",
"one.ablaze.floorp",
"com.vivaldi.Vivaldi",
"org.darktable.Darktable",
]
CUSTOM_PACKAGES: list[dict] = [
{
"name": "go",
"version": "1.26.3",
"url_template": "https://go.dev/dl/go{version}.{os_go}-{arch_go}.tar.gz",
"sha256_map": {
"linux-x86_64": "2b2cfc7148493da5e73981bffbf3353af381d5f93e789c82c79aff64962eb556",
"linux-aarch64": "9d89a3ea57d141c2b22d70083f2c8459ba3890f2d9e818e7e933b75614936565",
"macos-x86_64": "278d580b32e299fe4a9c990fcf2d02acfe538c7e551a6ee18f9c7164573d2c63",
"macos-aarch64": "875cf54a15311eee2c99b9dd67c68c4a49351d489ab622bf2cfd28c8f2078d3c",
},
"fetch_latest": "go",
},
{"name": "neovim"},
{
"name": "firecracker",
"version": "1.15.1",
"url_template": (
"https://github.com/firecracker-microvm/firecracker/releases/download/"
"v{version}/firecracker-v{version}-{arch}.tgz"
),
"sha256_map": {
"linux-x86_64": "d4a32ab2322d887ca1bc4a4e7afa9cc35393e6362dfc2b3becb389d362e4275a",
"linux-aarch64": "00654ac1e702a22744121ea9f10a4f792ebd7c3a744cba587dfac9fcb79b41a5",
},
"fetch_latest": "firecracker",
},
{
"name": "zig",
"version": "0.16.0",
"url_template": "https://ziglang.org/download/{version}/zig-{arch}-{os_zig}-{version}.tar.xz",
"sha256_url_template": (
"https://ziglang.org/download/{version}/zig-{arch}-{os_zig}-{version}.tar.xz.minisig"
),
"minisign_key": "RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U",
"fetch_latest": "zig",
},
{"name": "nvm"},
{"name": "pyenv"},
{"name": "pip"},
{"name": "oh-my-zsh"},
]
+3
View File
@@ -0,0 +1,3 @@
module github.com/JMR-dev/bootstrap_dev_env
go 1.24.7
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// Issue log accumulated over the run; written to bootstrap_run.log at the end
// when there's something to report.
var (
issuesMu sync.Mutex
issues []string
notices []string
)
func logIssue(level, msg string) {
issuesMu.Lock()
defer issuesMu.Unlock()
fmt.Printf(" [%s] %s\n", level, msg)
issues = append(issues, fmt.Sprintf("[%s] %s", level, msg))
}
func warn(msg string) { logIssue("WARN", msg) }
func errLog(msg string) { logIssue("ERROR", msg) }
func notice(msg string) {
issuesMu.Lock()
defer issuesMu.Unlock()
notices = append(notices, msg)
}
func runLogPath() string {
exe, err := os.Executable()
if err != nil {
return "bootstrap_run.log"
}
return filepath.Join(filepath.Dir(exe), "bootstrap_run.log")
}
func writeRunLog() {
issuesMu.Lock()
defer issuesMu.Unlock()
if len(issues) == 0 {
fmt.Println("\nNo issues — log file not written.")
return
}
path := runLogPath()
ts := time.Now().Format("2006-01-02 15:04:05")
lines := []string{fmt.Sprintf("# Bootstrap run — %s", ts), ""}
lines = append(lines, issues...)
content := strings.Join(lines, "\n") + "\n"
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "failed to write run log: %v\n", err)
return
}
fmt.Printf("\n%d issue(s) logged to: %s\n", len(issues), path)
}
func printNotices() {
issuesMu.Lock()
defer issuesMu.Unlock()
if len(notices) == 0 {
return
}
fmt.Println("\nNotices:")
for _, n := range notices {
fmt.Printf(" • %s\n", n)
}
}
+659
View File
@@ -0,0 +1,659 @@
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.")
}
}
+187
View File
@@ -0,0 +1,187 @@
// bootstrap_environment ports the Python bootstrap script to Go.
//
// Sections handled:
//
// System Packages — installed via dnf, apt-get, pacman, or brew (macOS)
// Flatpak Packages — installed via flatpak from Flathub (Linux only;
// skipped by default and skipped entirely on macOS; use --gui)
// Custom Packages — downloaded, verified, extracted
// macOS firecracker VM — provisions a Fedora cloud image under a hypervisor
// that supports nested virtualization. Suppress with --no-vm.
//
// OS detection is automatic. On macOS the first actions are to install the
// Xcode Command Line Tools and Homebrew, which is then used as the system
// package manager.
//
// Usage:
//
// Linux: sudo bootstrap_environment [--only system|flatpak|custom] [--gui]
// macOS: bootstrap_environment [--only system|custom] [--gui] [--no-vm]
// (do NOT use sudo on macOS — Homebrew refuses to run as root)
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
func main() {
only := flag.String("only", "", "Install only the named section (system|flatpak|custom)")
gui := flag.Bool("gui", false, "Include GUI applications (headed environments).")
noVM := flag.Bool("no-vm", false, "macOS only: skip provisioning the Fedora-on-QEMU VM that backs the firecracker() zsh wrapper.")
flag.Parse()
switch *only {
case "", "system", "flatpak", "custom":
default:
fmt.Fprintf(os.Stderr, "invalid --only value %q (use system|flatpak|custom)\n", *only)
os.Exit(2)
}
initPkgMgr()
systemPkgs := append([]string(nil), SystemPackages...)
flatpakPkgs := append([]string(nil), FlatpakPackages...)
custom := customPackages()
customPtrs := make([]*CustomPackage, 0, len(custom))
for i := range custom {
// Drop firecracker on macOS — it's provisioned inside the Fedora VM
// (see setupFirecrackerVM), not on the host.
if isMacOS && strings.ToLower(custom[i].Name) == "firecracker" {
continue
}
customPtrs = append(customPtrs, &custom[i])
}
fmt.Printf("OS: %s\n", osName)
fmt.Printf("Architecture: %s\n", archName)
fmt.Printf("Package manager: %s\n", pkgMgr)
if !*gui {
fmt.Println("Mode: headless (default) — skipping GUI apps and Flatpak")
}
if isMacOS {
// Refuse to run as root before doing anything (brew won't run as root).
checkSudo()
ensureXcodeCLT()
ensureHomebrew()
}
fmt.Println("Checking installed packages ...")
if !*gui {
var skippedGUI, kept []string
for _, p := range systemPkgs {
if guiSystemPkgs[p] {
skippedGUI = append(skippedGUI, p)
} else {
kept = append(kept, p)
}
}
systemPkgs = kept
if len(skippedGUI) > 0 {
fmt.Printf(" [HEADLESS] Skipping GUI system packages: %s\n", fmtList(skippedGUI, 6))
}
flatpakPkgs = nil
}
doFlatpak := (*only == "" || *only == "flatpak") && *gui && !isMacOS
var sysCheck systemCheckResult
var flatCheck flatpakCheckResult
var custCheck customCheckResult
if *only == "" || *only == "system" {
sysCheck = checkSystemPackages(systemPkgs)
}
if doFlatpak {
flatCheck = checkFlatpakPackages(flatpakPkgs)
}
if *only == "" || *only == "custom" {
custCheck = checkCustomPackages(customPtrs)
}
total := printCheckSummary(sysCheck, flatCheck, custCheck, *only)
if total == 0 {
fmt.Println("\nAll packages already installed.")
writeRunLog()
return
}
if !askYN(fmt.Sprintf("\n%d item(s) to install. Proceed? [y/N] ", total)) {
fmt.Fprintln(os.Stderr, "Aborted.")
os.Exit(1)
}
checkSudo()
if *only == "" || *only == "system" {
installSystemPackages(sysCheck.toInstallRegular, sysCheck.toInstallSpecial)
ensureZshDefault()
}
if doFlatpak {
installFlatpakPackages(flatCheck.toInstall)
}
var pyenvWG interface{ Wait() }
if *only == "" || *only == "custom" {
installCustomPackages(custCheck.toInstall)
ensureNodeLTS()
if wg := ensurePythonLatest(); wg != nil {
pyenvWG = wg
}
}
if *only == "" {
checkAndSetupSSH()
cloneNvimConfig()
if isMacOS && !*noVM {
setupFirecrackerVM()
}
}
if pyenvWG != nil {
fmt.Println("\n[pyenv] Waiting for background Python install to finish ...")
pyenvWG.Wait()
}
writeRunLog()
printNotices()
fmt.Println("\nDone.")
home, _ := os.UserHomeDir()
zshrc := filepath.Join(home, ".zshrc")
if hasCmd("zsh") {
if _, err := os.Stat(zshrc); err == nil {
fmt.Println("\nSourcing ~/.zshrc ...")
runShell(fmt.Sprintf("zsh -c 'source %s'", zshrc), CmdOpts{})
}
}
}
func checkSudo() {
if os.Geteuid() == 0 {
if isMacOS {
fmt.Fprintln(os.Stderr, "Do not run this with sudo on macOS — Homebrew refuses to run as root. "+
"Re-run as your regular user; the tool will request sudo for the operations that need it.")
os.Exit(1)
}
return
}
if !hasCmd("sudo") {
fmt.Fprintln(os.Stderr, "sudo is required but not installed.")
os.Exit(1)
}
fmt.Println("Validating sudo access ...")
r := runCmd([]string{"sudo", "-v"}, CmdOpts{Timeout: 2 * time.Minute})
if r.ExitCode != 0 {
fmt.Fprintln(os.Stderr, "sudo authentication failed.")
os.Exit(1)
}
}
+116
View File
@@ -0,0 +1,116 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
const httpClientTimeout = 30 * time.Minute
var httpClient = &http.Client{Timeout: httpClientTimeout}
// download streams url -> dest. Returns true on success.
func download(url, dest string) bool {
fmt.Printf(" Downloading %s ...\n", filepath.Base(url))
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
errLog(fmt.Sprintf("Download failed for %s: %v", url, err))
return false
}
resp, err := httpClient.Do(req)
if err != nil {
errLog(fmt.Sprintf("Download failed for %s: %v", url, err))
return false
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
errLog(fmt.Sprintf("Download failed for %s: HTTP %d", url, resp.StatusCode))
return false
}
f, err := os.Create(dest)
if err != nil {
errLog(fmt.Sprintf("Download failed for %s: %v", url, err))
return false
}
defer f.Close()
if _, err := io.Copy(f, resp.Body); err != nil {
errLog(fmt.Sprintf("Download failed for %s: %v", url, err))
return false
}
return true
}
// fetchJSON GETs url with the GitHub API Accept header and decodes the body
// into v. Returns true on success.
func fetchJSON(url string, v any) bool {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
errLog(fmt.Sprintf("API request failed for %s: %v", url, err))
return false
}
req.Header.Set("Accept", "application/vnd.github+json")
if tok := os.Getenv("GITHUB_TOKEN"); tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
resp, err := httpClient.Do(req)
if err != nil {
errLog(fmt.Sprintf("API request failed for %s: %v", url, err))
return false
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
errLog(fmt.Sprintf("API request failed for %s: HTTP %d", url, resp.StatusCode))
return false
}
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
errLog(fmt.Sprintf("API request failed for %s: %v", url, err))
return false
}
return true
}
// fetchText returns the trimmed body of url. Returns empty string on failure.
func fetchText(url string) string {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
errLog(fmt.Sprintf("Fetch failed for %s: %v", url, err))
return ""
}
resp, err := httpClient.Do(req)
if err != nil {
errLog(fmt.Sprintf("Fetch failed for %s: %v", url, err))
return ""
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
errLog(fmt.Sprintf("Fetch failed for %s: HTTP %d", url, resp.StatusCode))
return ""
}
b, err := io.ReadAll(resp.Body)
if err != nil {
errLog(fmt.Sprintf("Fetch failed for %s: %v", url, err))
return ""
}
return strings.TrimSpace(string(b))
}
func sha256Of(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
+140
View File
@@ -0,0 +1,140 @@
package main
// Package definitions consumed by the bootstrap entry point.
//
// SystemPackages and FlatpakPackages are flat name lists. CustomPackages
// declare a URL template plus an optional FetchLatest hint; at install time
// the resolver attempts to look up the most recent release and falls back to
// the pinned (Version, sha256) tuple on failure.
//
// URL templates use the substitutions described in formatURL.
var SystemPackages = []string{
"ansible",
"ansible-core",
"aria2",
"bashtop",
"build-essential",
"buildah",
"containerd.io",
"docker-buildx-plugin",
"docker-ce-cli",
"docker-ce-rootless-extras",
"docker-ce",
"docker-compose-plugin",
"dotnet-sdk-10.0",
"ffmpeg-free",
"gcc",
"gh",
"git",
"github-desktop",
"google-chrome-stable",
"lazygit",
"lua",
"minisign",
"minikube",
"obs-studio",
"obsidian",
"pipx",
"poetry",
"pulumi",
"podman",
"qemu",
"restic",
"rg",
"shutter",
"temurin-25-jdk",
"vagrant",
"virt-manager",
"vivaldi-stable",
"webcamoid",
"wireshark",
"yt-dlp",
"zoom",
"zsh",
"bzip2",
"bzip2-devel",
"curl",
"gdbm-libs",
"libffi-devel",
"libnsl2",
"libuuid-devel",
"libxml2-devel",
"libzstd-devel",
"make",
"ncurses-devel",
"openssl-devel",
"patch",
"readline-devel",
"sqlite",
"sqlite-devel",
"tk-devel",
"xmlsec1-devel",
"xz",
"xz-devel",
"zlib-devel",
}
var FlatpakPackages = []string{
"com.obsproject.Studio",
"fr.handbrake.ghb",
"io.github.webcamoid.Webcamoid",
"one.ablaze.floorp",
"com.vivaldi.Vivaldi",
"org.darktable.Darktable",
}
// CustomPackage describes a third-party tarball/binary we fetch directly
// (i.e. not via the host package manager).
type CustomPackage struct {
Name string
Version string // pinned fallback version
URLTemplate string // see formatURL for substitutions
SHA256 string // single-arch hex digest (set by resolveLatest)
SHA256Map map[string]string // per-platform pinned digests: {"os-arch": hex}
SHA256URLTemplate string // template for a .minisig URL
MinisignKey string // base64 public key for minisign verification
FetchLatest string // latest-version resolver hint ("go", "firecracker", "zig")
InstallPath string // override the default install-check path
}
func customPackages() []CustomPackage {
return []CustomPackage{
{
Name: "go",
Version: "1.26.3",
URLTemplate: "https://go.dev/dl/go{version}.{os_go}-{arch_go}.tar.gz",
SHA256Map: map[string]string{
"linux-x86_64": "2b2cfc7148493da5e73981bffbf3353af381d5f93e789c82c79aff64962eb556",
"linux-aarch64": "9d89a3ea57d141c2b22d70083f2c8459ba3890f2d9e818e7e933b75614936565",
"macos-x86_64": "278d580b32e299fe4a9c990fcf2d02acfe538c7e551a6ee18f9c7164573d2c63",
"macos-aarch64": "875cf54a15311eee2c99b9dd67c68c4a49351d489ab622bf2cfd28c8f2078d3c",
},
FetchLatest: "go",
},
{Name: "neovim"},
{
Name: "firecracker",
Version: "1.15.1",
URLTemplate: "https://github.com/firecracker-microvm/firecracker/releases/download/" +
"v{version}/firecracker-v{version}-{arch}.tgz",
SHA256Map: map[string]string{
"linux-x86_64": "d4a32ab2322d887ca1bc4a4e7afa9cc35393e6362dfc2b3becb389d362e4275a",
"linux-aarch64": "00654ac1e702a22744121ea9f10a4f792ebd7c3a744cba587dfac9fcb79b41a5",
},
FetchLatest: "firecracker",
},
{
Name: "zig",
Version: "0.16.0",
URLTemplate: "https://ziglang.org/download/{version}/zig-{arch}-{os_zig}-{version}.tar.xz",
SHA256URLTemplate: "https://ziglang.org/download/{version}/zig-{arch}-{os_zig}-{version}.tar.xz.minisig",
MinisignKey: "RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U",
FetchLatest: "zig",
},
{Name: "nvm"},
{Name: "pyenv"},
{Name: "pip"},
{Name: "oh-my-zsh"},
}
}
+233
View File
@@ -0,0 +1,233 @@
package main
import (
"fmt"
"os"
"strings"
"time"
)
var pkgMgr string // "dnf", "apt-get", "pacman", "brew"
func initPkgMgr() {
pkgMgr = detectPkgMgr()
isRHELFamily = detectRHELFamily()
isArchFamily = detectArchFamily()
}
func detectPkgMgr() string {
if isMacOS {
// brew may not be installed yet — ensureHomebrew runs before any
// call that actually invokes brew.
return "brew"
}
for _, mgr := range []string{"dnf", "apt-get", "pacman"} {
if hasCmd(mgr) {
return mgr
}
}
fmt.Fprintln(os.Stderr, "No supported package manager found (expected dnf, apt-get, pacman, or brew on macOS).")
os.Exit(1)
return ""
}
// pkgOverrides maps a (PKG_MGR, generic_name) pair to a distro-specific
// replacement. An empty []string{} means "skip with a warning".
//
// Use overrideEntry to distinguish "skip" (Skip=true) from "replace with
// these packages" (Replacement=[...]).
type overrideEntry struct {
Skip bool
Replacement []string
}
func skipOverride() overrideEntry { return overrideEntry{Skip: true} }
func replace(names ...string) overrideEntry {
return overrideEntry{Replacement: names}
}
var packageOverrides = map[string]map[string]overrideEntry{
"dnf": {
"build-essential": replace("gcc", "gcc-c++", "make"),
"rg": replace("ripgrep"),
"docker-compose": skipOverride(),
"webcamoid": skipOverride(),
},
"apt-get": {
"ffmpeg-free": replace("ffmpeg"),
"lua": replace("lua5.4"),
"qemu": replace("qemu-system"),
"rg": replace("ripgrep"),
"bzip2-devel": replace("libbz2-dev"),
"gdbm-libs": replace("libgdbm-dev"),
"libffi-devel": replace("libffi-dev"),
"libnsl2": replace("libnsl-dev"),
"libuuid-devel": replace("uuid-dev"),
"libxml2-devel": replace("libxml2-dev"),
"libzstd-devel": replace("libzstd-dev"),
"ncurses-devel": replace("libncursesw5-dev"),
"openssl-devel": replace("libssl-dev"),
"readline-devel": replace("libreadline-dev"),
"sqlite": replace("sqlite3"),
"sqlite-devel": replace("libsqlite3-dev"),
"tk-devel": replace("tk-dev"),
"xmlsec1-devel": replace("libxmlsec1-dev"),
"xz": replace("xz-utils"),
"xz-devel": replace("liblzma-dev"),
"zlib-devel": replace("zlib1g-dev"),
},
"pacman": {
"build-essential": replace("base-devel"),
"ansible-core": skipOverride(), // bundled with ansible
"containerd.io": replace("containerd"),
"docker-ce": replace("docker"),
"docker-ce-cli": skipOverride(), // covered by docker
"docker-ce-rootless-extras": replace("docker-rootless-extras"),
"docker-buildx-plugin": replace("docker-buildx"),
"docker-compose-plugin": replace("docker-compose"),
"dotnet-sdk-10.0": replace("dotnet-sdk"),
"ffmpeg-free": replace("ffmpeg"),
"gh": replace("github-cli"),
"github-desktop": skipOverride(), // AUR-only
"google-chrome-stable": skipOverride(), // AUR-only
"lua": replace("lua"),
"obs-studio": replace("obs-studio"),
"obsidian": skipOverride(), // AUR-only; provided via Flatpak when --gui
"pulumi": skipOverride(), // AUR-only; installed via custom path
"qemu": replace("qemu-full"),
"rg": replace("ripgrep"),
"shutter": skipOverride(), // AUR-only
"temurin-25-jdk": replace("jdk-openjdk"),
"vagrant": replace("vagrant"),
"vivaldi-stable": replace("vivaldi"),
"webcamoid": skipOverride(), // AUR-only; provided via Flatpak when --gui
"wireshark": replace("wireshark-qt"),
"yt-dlp": replace("yt-dlp"),
"zoom": skipOverride(), // AUR-only
"bzip2-devel": skipOverride(),
"gdbm-libs": replace("gdbm"),
"libffi-devel": replace("libffi"),
"libnsl2": replace("libnsl"),
"libuuid-devel": replace("util-linux-libs"),
"libxml2-devel": replace("libxml2"),
"libzstd-devel": replace("zstd"),
"ncurses-devel": replace("ncurses"),
"openssl-devel": replace("openssl"),
"readline-devel": replace("readline"),
"sqlite-devel": skipOverride(),
"tk-devel": replace("tk"),
"xmlsec1-devel": replace("xmlsec"),
"xz-devel": skipOverride(),
"zlib-devel": replace("zlib"),
},
"brew": {
"build-essential": skipOverride(),
"gcc": skipOverride(),
"make": skipOverride(),
"patch": skipOverride(),
"zsh": skipOverride(),
"ansible-core": skipOverride(),
"containerd.io": skipOverride(),
"docker-buildx-plugin": skipOverride(),
"docker-ce-cli": skipOverride(),
"docker-ce-rootless-extras": skipOverride(),
"docker-ce": replace("docker"),
"docker-compose-plugin": replace("docker-compose"),
"dotnet-sdk-10.0": replace("dotnet"),
"ffmpeg-free": replace("ffmpeg"),
"github-desktop": replace("github"),
"google-chrome-stable": replace("google-chrome"),
"obs-studio": replace("obs"),
"rg": replace("ripgrep"),
"temurin-25-jdk": replace("temurin"),
"vivaldi-stable": replace("vivaldi"),
"shutter": skipOverride(),
"virt-manager": skipOverride(),
"webcamoid": skipOverride(),
"bzip2-devel": skipOverride(),
"curl": skipOverride(),
"gdbm-libs": replace("gdbm"),
"libffi-devel": replace("libffi"),
"libnsl2": skipOverride(),
"libuuid-devel": skipOverride(),
"libxml2-devel": replace("libxml2"),
"libzstd-devel": replace("zstd"),
"ncurses-devel": skipOverride(),
"openssl-devel": replace("openssl@3"),
"readline-devel": replace("readline"),
"sqlite-devel": skipOverride(),
"tk-devel": replace("tcl-tk"),
"xmlsec1-devel": replace("libxmlsec1"),
"xz": replace("xz"),
"xz-devel": skipOverride(),
"zlib-devel": skipOverride(),
},
}
// brewCasks: brew packages that must be installed with `brew install --cask`.
// Names are post-override.
var brewCasks = map[string]bool{
"docker": true,
"github": true,
"google-chrome": true,
"obs": true,
"obsidian": true,
"temurin": true,
"vagrant": true,
"vivaldi": true,
"zoom": true,
}
// resolveSystemPkgs applies distro overrides. Returns (resolved, skipped).
func resolveSystemPkgs(names []string) ([]string, []string) {
overrides := packageOverrides[pkgMgr]
var resolved, skipped []string
for _, pkg := range names {
ov, ok := overrides[pkg]
if !ok {
resolved = append(resolved, pkg)
continue
}
if ov.Skip {
skipped = append(skipped, pkg)
continue
}
resolved = append(resolved, ov.Replacement...)
}
return resolved, skipped
}
// isSystemPkgInstalled queries the host package manager.
func isSystemPkgInstalled(pkg string) bool {
switch pkgMgr {
case "dnf":
r, ok := probe([]string{"rpm", "-q", pkg}, 0)
return ok && r.ExitCode == 0
case "apt-get":
r, ok := probe([]string{"dpkg-query", "-W", "-f=${Status}", pkg}, 0)
return ok && strings.Contains(string(r.Stdout), "install ok installed")
case "pacman":
r, ok := probe([]string{"pacman", "-Qi", pkg}, 0)
return ok && r.ExitCode == 0
case "brew":
if !hasCmd("brew") {
return false
}
for _, kind := range []string{"--formula", "--cask"} {
r, ok := probe([]string{"brew", "list", kind, pkg}, 60*time.Second)
if ok && r.ExitCode == 0 {
return true
}
}
return false
}
return false
}
func isFlatpakInstalled(appID string) bool {
if !hasCmd("flatpak") {
return false
}
r, ok := probe([]string{"flatpak", "info", appID}, 0)
return ok && r.ExitCode == 0
}
+457
View File
@@ -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"
}
+234
View File
@@ -0,0 +1,234 @@
package main
import (
"fmt"
"os"
"strings"
)
// repoFileExists returns true if any of the given paths exists.
func repoFileExists(paths ...string) bool {
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
return true
}
}
return false
}
func writeDNFRepo(name, displayName, baseurl, gpgkey string) {
content := fmt.Sprintf(
"[%s]\nname=%s\nbaseurl=%s\nenabled=1\ngpgcheck=1\ngpgkey=%s\n",
name, displayName, baseurl, gpgkey,
)
path := "/etc/yum.repos.d/" + name + ".repo"
runCmd([]string{"tee", path}, CmdOpts{AsSudo: true, Input: []byte(content), Capture: true})
}
func setupDockerRepo() {
switch pkgMgr {
case "dnf":
if repoFileExists("/etc/yum.repos.d/docker-ce.repo") {
return
}
runCmd([]string{"dnf", "config-manager", "addrepo", "--from-repofile",
"https://download.docker.com/linux/fedora/docker-ce.repo"}, CmdOpts{AsSudo: true})
case "apt-get":
if repoFileExists("/etc/apt/sources.list.d/docker.list") {
return
}
runCmd([]string{"apt-get", "update"}, CmdOpts{AsSudo: true})
runCmd([]string{"apt-get", "install", "-y", "ca-certificates", "curl", "gnupg"}, CmdOpts{AsSudo: true})
distroID := osReleaseField("ID")
dockerDistro := "ubuntu"
if distroID == "debian" || distroID == "ubuntu" {
dockerDistro = distroID
}
runShell(
"install -m 0755 -d /etc/apt/keyrings && "+
"curl -fsSL https://download.docker.com/linux/"+dockerDistro+"/gpg | "+
"sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg && "+
"sudo chmod a+r /etc/apt/keyrings/docker.gpg",
CmdOpts{},
)
codenameRes := runShell(". /etc/os-release && echo $VERSION_CODENAME",
CmdOpts{Capture: true})
codename := strings.TrimSpace(string(codenameRes.Stdout))
debArch := archDeb[archName]
runCmd(
[]string{"tee", "/etc/apt/sources.list.d/docker.list"},
CmdOpts{
AsSudo: true,
Input: []byte(fmt.Sprintf("deb [arch=%s signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/%s %s stable\n", debArch, dockerDistro, codename)),
Capture: true,
},
)
runCmd([]string{"apt-get", "update"}, CmdOpts{AsSudo: true})
}
// pacman: docker is in official repos — no extra repo needed.
}
func setupGHRepo() {
switch pkgMgr {
case "dnf":
if repoFileExists("/etc/yum.repos.d/gh-cli.repo") {
return
}
runCmd([]string{"dnf", "config-manager", "addrepo", "--from-repofile",
"https://cli.github.com/packages/rpm/gh-cli.repo"}, CmdOpts{AsSudo: true})
case "apt-get":
if repoFileExists("/etc/apt/sources.list.d/github-cli.list") {
return
}
debArch := archDeb[archName]
runShell(
"curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | "+
"sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && "+
"sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg && "+
fmt.Sprintf("echo 'deb [arch=%s signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main' | ", debArch)+
"sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null",
CmdOpts{},
)
runCmd([]string{"apt-get", "update"}, CmdOpts{AsSudo: true})
}
// pacman: github-cli is in community repo.
}
func setupChromeRepo() {
if archName != "x86_64" {
warn("Google Chrome has no Linux build for this arch — skipping repo")
return
}
switch pkgMgr {
case "dnf":
if repoFileExists("/etc/yum.repos.d/google-chrome.repo") {
return
}
writeDNFRepo(
"google-chrome", "Google Chrome",
"https://dl.google.com/linux/chrome/rpm/stable/x86_64",
"https://dl.google.com/linux/linux_signing_key.pub",
)
case "apt-get":
if repoFileExists("/etc/apt/sources.list.d/google-chrome.list") {
return
}
runShell(
"curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | "+
"sudo gpg --dearmor -o /etc/apt/keyrings/google-chrome.gpg && "+
"echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/google-chrome.gpg] "+
"https://dl.google.com/linux/chrome/deb/ stable main' | "+
"sudo tee /etc/apt/sources.list.d/google-chrome.list > /dev/null && "+
"sudo apt-get update",
CmdOpts{},
)
}
}
func setupVivaldiRepo() {
if archName != "x86_64" {
warn("Vivaldi repo on this arch is not supported by this script — skipping")
return
}
switch pkgMgr {
case "dnf":
if repoFileExists("/etc/yum.repos.d/vivaldi.repo") {
return
}
writeDNFRepo(
"vivaldi", "Vivaldi",
"https://repo.vivaldi.com/archive/rpm/x86_64",
"https://repo.vivaldi.com/archive/linux_signing_key.pub",
)
case "apt-get":
if repoFileExists("/etc/apt/sources.list.d/vivaldi.list") {
return
}
runShell(
"curl -fsSL https://repo.vivaldi.com/archive/linux_signing_key.pub | "+
"sudo gpg --dearmor -o /etc/apt/keyrings/vivaldi.gpg && "+
"echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/vivaldi.gpg] "+
"https://repo.vivaldi.com/archive/deb/ stable main' | "+
"sudo tee /etc/apt/sources.list.d/vivaldi.list > /dev/null && "+
"sudo apt-get update",
CmdOpts{},
)
}
}
func setupTemurinRepo() {
switch pkgMgr {
case "dnf":
if repoFileExists("/etc/yum.repos.d/adoptium.repo") {
return
}
writeDNFRepo(
"Adoptium", "Adoptium",
"https://packages.adoptium.net/artifactory/rpm/fedora/$releasever/$basearch",
"https://packages.adoptium.net/artifactory/api/gpg/key/public",
)
case "apt-get":
if repoFileExists("/etc/apt/sources.list.d/adoptium.list") {
return
}
runShell(
"wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public | "+
"sudo gpg --dearmor | sudo tee /etc/apt/keyrings/adoptium.gpg > /dev/null && "+
`echo "deb [signed-by=/etc/apt/keyrings/adoptium.gpg] `+
`https://packages.adoptium.net/artifactory/deb/ `+
`$(awk -F= '/^VERSION_CODENAME/{print$2}' /etc/os-release) main" | `+
"sudo tee /etc/apt/sources.list.d/adoptium.list > /dev/null && "+
"sudo apt-get update",
CmdOpts{},
)
}
}
func setupDotnetRepo() {
// .NET is in Fedora repos directly — no extra repo needed.
if pkgMgr != "apt-get" {
return
}
if repoFileExists(
"/etc/apt/sources.list.d/microsoft-prod.list",
"/etc/apt/sources.list.d/dotnet.list",
) {
return
}
distroID := strings.Trim(osReleaseField("ID"), `"`)
versionID := strings.Trim(osReleaseField("VERSION_ID"), `"`)
debURL := fmt.Sprintf(
"https://packages.microsoft.com/config/%s/%s/packages-microsoft-prod.deb",
distroID, versionID,
)
runShell(
fmt.Sprintf("curl -fsSL %s -o /tmp/packages-microsoft-prod.deb && "+
"sudo dpkg -i /tmp/packages-microsoft-prod.deb && "+
"sudo apt-get update", debURL),
CmdOpts{},
)
}
type repoGroup struct {
members map[string]bool
setup func()
}
func repoGroups() []repoGroup {
mk := func(names ...string) map[string]bool {
m := make(map[string]bool, len(names))
for _, n := range names {
m[n] = true
}
return m
}
return []repoGroup{
{mk("containerd.io", "docker-buildx-plugin", "docker-ce-cli",
"docker-ce-rootless-extras", "docker-ce", "docker-compose-plugin"), setupDockerRepo},
{mk("gh"), setupGHRepo},
{mk("google-chrome-stable"), setupChromeRepo},
{mk("vivaldi-stable"), setupVivaldiRepo},
{mk("temurin-25-jdk"), setupTemurinRepo},
{mk("dotnet-sdk-10.0"), setupDotnetRepo},
}
}
+444
View File
@@ -0,0 +1,444 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// 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, "bashtop": true, "pipx": true,
"poetry": true, "pulumi": 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 {
home, _ := os.UserHomeDir()
exists := func(p string) bool { _, err := os.Stat(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 "bashtop":
return exists("/usr/local/bin/bashtop") || exists(filepath.Join(home, "bashtop"))
case "pulumi":
return exists("/opt/pulumi/pulumi") || hasCmd("pulumi")
case "pipx":
return hasCmd("pipx")
case "poetry":
return hasCmd("poetry")
}
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()]
matches := func(name string) bool {
n := strings.ToLower(name)
if !strings.HasSuffix(n, ".appimage") {
return false
}
matched := false
for _, t := range hostTokens {
if strings.Contains(n, t) {
matched = true
break
}
}
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 installBashtop(_ string) {
home, _ := os.UserHomeDir()
cloneDir := filepath.Join(home, "bashtop")
if _, err := os.Stat(cloneDir); err == nil {
fmt.Printf(" Updating existing clone at %s ...\n", cloneDir)
if !runCmd([]string{"git", "-C", cloneDir, "pull"}, CmdOpts{}).OK() {
errLog("bashtop git pull failed")
return
}
} else {
fmt.Printf(" Cloning bashtop to %s ...\n", cloneDir)
if !runCmd([]string{"git", "clone", "https://github.com/aristocratos/bashtop.git", cloneDir}, CmdOpts{}).OK() {
errLog("bashtop git clone failed")
return
}
}
if !runCmd([]string{"make", "install"}, CmdOpts{AsSudo: true, Cwd: cloneDir}).OK() {
errLog("bashtop 'make install' failed")
return
}
appendProfileLine("bashtop", "export PATH=$PATH:"+cloneDir)
fmt.Printf(" bashtop installed. Clone at %s, binary at /usr/local/bin/bashtop\n", cloneDir)
}
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 installSpecialPkg(pkg, tmp string) {
switch pkg {
case "github-desktop":
installGitHubDesktop(tmp)
case "zoom":
installZoom(tmp)
case "obsidian":
installObsidian(tmp)
case "minikube":
installMinikube(tmp)
case "bashtop":
installBashtop(tmp)
case "pulumi":
installPulumi(tmp)
case "pipx":
installPipx(tmp)
case "poetry":
installPoetry(tmp)
}
}
// 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})
}
}
// installSystemPackages installs the regular + special package lists.
func installSystemPackages(regular, special []string) {
fmt.Println("\n=== System Packages ===")
if pkgMgr == "brew" {
for _, pkg := range regular {
res := pkgInstall(pkg)
if !res.OK() {
errLog(fmt.Sprintf("System package failed to install: %s", pkg))
}
}
// 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
}
}
}
for _, pkg := range regular {
res := pkgInstall(pkg)
if !res.OK() {
errLog(fmt.Sprintf("System package failed to install: %s", pkg))
}
}
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 os.RemoveAll(tmp)
for _, pkg := range special {
fmt.Printf("\n [SPECIAL] Installing %s ...\n", pkg)
installSpecialPkg(pkg, tmp)
}
}
}
// 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})
}