port: rewrite bootstrap tool in Go
Replace the Python bootstrap script with a Go implementation that cross-compiles to native binaries for Linux and macOS on x86_64 and aarch64. The Go port preserves all sections of the original (system packages, optional Flatpak GUI apps, custom downloads, and the macOS firecracker VM bridge) and adds first-class pacman support so the tool works on Arch-family distros alongside Debian, RHEL, and macOS. A Makefile produces a host binary via `make build` and the full four-target matrix under dist/ via `make build-all`.
This commit is contained in:
+4
-1
@@ -1 +1,4 @@
|
||||
__pycache__/
|
||||
dist/
|
||||
bootstrap_dev_env
|
||||
bootstrap_environment
|
||||
bootstrap_run.log
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"},
|
||||
]
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user