Feat parallelize installs #21
+533
@@ -0,0 +1,533 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPkgInstallManyBatchesDnf verifies a single batched dnf call rather
|
||||||
|
// than one per package.
|
||||||
|
func TestPkgInstallManyBatchesDnf(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "dnf"
|
||||||
|
|
||||||
|
var calls [][]string
|
||||||
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||||
|
calls = append(calls, append([]string(nil), argv...))
|
||||||
|
return CmdResult{ExitCode: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
failed := pkgInstallMany([]string{"git", "curl", "vim"})
|
||||||
|
if len(failed) != 0 {
|
||||||
|
t.Errorf("expected no failures, got %v", failed)
|
||||||
|
}
|
||||||
|
if len(calls) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 batched call, got %d: %v", len(calls), calls)
|
||||||
|
}
|
||||||
|
got := strings.Join(calls[0], " ")
|
||||||
|
if !strings.HasPrefix(got, "dnf install -y") {
|
||||||
|
t.Errorf("expected 'dnf install -y …' prefix, got: %q", got)
|
||||||
|
}
|
||||||
|
for _, pkg := range []string{"git", "curl", "vim"} {
|
||||||
|
if !strings.Contains(got, pkg) {
|
||||||
|
t.Errorf("expected %s in batched call, got: %q", pkg, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPkgInstallManyBatchesApt verifies the same for apt-get.
|
||||||
|
func TestPkgInstallManyBatchesApt(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "apt-get"
|
||||||
|
|
||||||
|
var calls [][]string
|
||||||
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||||
|
calls = append(calls, append([]string(nil), argv...))
|
||||||
|
return CmdResult{ExitCode: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgInstallMany([]string{"a", "b", "c"})
|
||||||
|
if len(calls) != 1 {
|
||||||
|
t.Fatalf("expected 1 batched call, got %d", len(calls))
|
||||||
|
}
|
||||||
|
if calls[0][0] != "apt-get" || calls[0][1] != "install" || calls[0][2] != "-y" {
|
||||||
|
t.Errorf("expected 'apt-get install -y' prefix, got: %v", calls[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPkgInstallManyBatchesPacman verifies pacman flags.
|
||||||
|
func TestPkgInstallManyBatchesPacman(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "pacman"
|
||||||
|
|
||||||
|
var calls [][]string
|
||||||
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||||
|
calls = append(calls, append([]string(nil), argv...))
|
||||||
|
return CmdResult{ExitCode: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgInstallMany([]string{"a", "b"})
|
||||||
|
if len(calls) != 1 || calls[0][0] != "pacman" {
|
||||||
|
t.Fatalf("expected single pacman call, got %v", calls)
|
||||||
|
}
|
||||||
|
joined := strings.Join(calls[0], " ")
|
||||||
|
if !strings.Contains(joined, "--noconfirm") || !strings.Contains(joined, "--needed") {
|
||||||
|
t.Errorf("expected --noconfirm --needed in pacman call, got: %q", joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPkgInstallManyFallback verifies that a failed batch retries per-package
|
||||||
|
// and returns the failures it identifies on the per-package retry.
|
||||||
|
func TestPkgInstallManyFallback(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "dnf"
|
||||||
|
|
||||||
|
calls := 0
|
||||||
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||||
|
calls++
|
||||||
|
// Fail the first (batched) call, succeed individual retries except for "bad".
|
||||||
|
if calls == 1 {
|
||||||
|
return CmdResult{ExitCode: 1}
|
||||||
|
}
|
||||||
|
for _, a := range argv {
|
||||||
|
if a == "bad" {
|
||||||
|
return CmdResult{ExitCode: 1}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CmdResult{ExitCode: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
failed := pkgInstallMany([]string{"good1", "good2", "bad"})
|
||||||
|
if len(failed) != 1 || failed[0] != "bad" {
|
||||||
|
t.Errorf("expected only 'bad' to fail, got %v", failed)
|
||||||
|
}
|
||||||
|
// 1 batch + 3 per-package retries = 4 calls.
|
||||||
|
if calls != 4 {
|
||||||
|
t.Errorf("expected 4 total calls (1 batch + 3 retries), got %d", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPkgInstallManyEmpty: no-op on empty input, no calls.
|
||||||
|
func TestPkgInstallManyEmpty(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "dnf"
|
||||||
|
called := false
|
||||||
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||||
|
called = true
|
||||||
|
return CmdResult{ExitCode: 0}
|
||||||
|
}
|
||||||
|
failed := pkgInstallMany(nil)
|
||||||
|
if len(failed) != 0 {
|
||||||
|
t.Errorf("expected no failures, got %v", failed)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Error("expected no runCmd call for empty input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPkgInstallManyBrew verifies that brew is batched into a single
|
||||||
|
// `brew install f1 f2 …` call (formulas only — no casks in this test).
|
||||||
|
// Parallel brew calls would deadlock on shared transitive-dep locks
|
||||||
|
// (cmake, ninja, libsodium, …), so we deliberately batch and serialize.
|
||||||
|
func TestPkgInstallManyBrew(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "brew"
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var calls [][]string
|
||||||
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||||
|
mu.Lock()
|
||||||
|
calls = append(calls, append([]string(nil), argv...))
|
||||||
|
mu.Unlock()
|
||||||
|
return CmdResult{ExitCode: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
if failed := pkgInstallMany([]string{"git", "vim", "curl"}); len(failed) != 0 {
|
||||||
|
t.Errorf("expected no failures, got %v", failed)
|
||||||
|
}
|
||||||
|
if len(calls) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 batched brew call, got %d: %v", len(calls), calls)
|
||||||
|
}
|
||||||
|
got := strings.Join(calls[0], " ")
|
||||||
|
if got != "brew install git vim curl" {
|
||||||
|
t.Errorf("expected 'brew install git vim curl', got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPkgInstallManyBrewSplitCasks verifies that casks and formulas are
|
||||||
|
// emitted in separate calls (because --cask is mutually exclusive with
|
||||||
|
// formula installs in one invocation).
|
||||||
|
func TestPkgInstallManyBrewSplitCasks(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "brew"
|
||||||
|
|
||||||
|
var calls [][]string
|
||||||
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||||
|
calls = append(calls, append([]string(nil), argv...))
|
||||||
|
return CmdResult{ExitCode: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "docker" is in brewCasks; the rest are formulas.
|
||||||
|
pkgInstallMany([]string{"git", "docker", "vim"})
|
||||||
|
|
||||||
|
if len(calls) != 2 {
|
||||||
|
t.Fatalf("expected 2 calls (1 formula batch + 1 cask batch), got %d: %v", len(calls), calls)
|
||||||
|
}
|
||||||
|
formula := strings.Join(calls[0], " ")
|
||||||
|
cask := strings.Join(calls[1], " ")
|
||||||
|
if formula != "brew install git vim" {
|
||||||
|
t.Errorf("expected 'brew install git vim', got %q", formula)
|
||||||
|
}
|
||||||
|
if cask != "brew install --cask docker" {
|
||||||
|
t.Errorf("expected 'brew install --cask docker', got %q", cask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPkgInstallManyBrewFallback: batched formula install fails; we retry
|
||||||
|
// per-package and identify the broken one.
|
||||||
|
func TestPkgInstallManyBrewFallback(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "brew"
|
||||||
|
|
||||||
|
calls := 0
|
||||||
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||||
|
calls++
|
||||||
|
// First call is the batch — fail it.
|
||||||
|
if calls == 1 {
|
||||||
|
return CmdResult{ExitCode: 1}
|
||||||
|
}
|
||||||
|
// Per-package retries: only "broken" fails.
|
||||||
|
if argv[len(argv)-1] == "broken" {
|
||||||
|
return CmdResult{ExitCode: 1}
|
||||||
|
}
|
||||||
|
return CmdResult{ExitCode: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
failed := pkgInstallMany([]string{"git", "broken", "curl"})
|
||||||
|
if len(failed) != 1 || failed[0] != "broken" {
|
||||||
|
t.Errorf("expected only 'broken' to fail, got %v", failed)
|
||||||
|
}
|
||||||
|
// 1 batch + 3 per-package retries = 4 calls.
|
||||||
|
if calls != 4 {
|
||||||
|
t.Errorf("expected 4 total calls, got %d", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInstallFlatpakBatched: a single batched flatpak install for the
|
||||||
|
// happy path.
|
||||||
|
func TestInstallFlatpakBatched(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
|
||||||
|
hasCmd = func(name string) bool { return name == "flatpak" }
|
||||||
|
var calls [][]string
|
||||||
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||||
|
calls = append(calls, append([]string(nil), argv...))
|
||||||
|
return CmdResult{ExitCode: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
installFlatpakPackages([]string{"a.app", "b.app", "c.app"})
|
||||||
|
|
||||||
|
// Expect: remote-add (1) + single batched install (1) = 2 calls.
|
||||||
|
if len(calls) != 2 {
|
||||||
|
t.Fatalf("expected 2 calls (remote-add + batched install), got %d: %v", len(calls), calls)
|
||||||
|
}
|
||||||
|
if calls[1][1] != "install" {
|
||||||
|
t.Errorf("expected install as second call, got %v", calls[1])
|
||||||
|
}
|
||||||
|
for _, app := range []string{"a.app", "b.app", "c.app"} {
|
||||||
|
found := false
|
||||||
|
for _, a := range calls[1] {
|
||||||
|
if a == app {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected %s in batched call, got %v", app, calls[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInstallFlatpakBatchFallback: failed batch retries per-package.
|
||||||
|
func TestInstallFlatpakBatchFallback(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
|
||||||
|
hasCmd = func(name string) bool { return name == "flatpak" }
|
||||||
|
calls := 0
|
||||||
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||||
|
calls++
|
||||||
|
// First call: remote-add (always OK)
|
||||||
|
// Second call: batched install (fail)
|
||||||
|
// Following calls: per-package retries (OK)
|
||||||
|
if calls == 2 {
|
||||||
|
return CmdResult{ExitCode: 1}
|
||||||
|
}
|
||||||
|
return CmdResult{ExitCode: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
installFlatpakPackages([]string{"a.app", "b.app"})
|
||||||
|
|
||||||
|
// 1 (remote-add) + 1 (failed batch) + 2 (per-package retries) = 4 calls.
|
||||||
|
if calls != 4 {
|
||||||
|
t.Errorf("expected 4 calls (remote-add + batch + 2 retries), got %d", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckSystemPackagesParallelOrdering: ordering preserved despite
|
||||||
|
// concurrent probes.
|
||||||
|
func TestCheckSystemPackagesParallelOrdering(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "dnf"
|
||||||
|
|
||||||
|
// odd-indexed packages "installed", even-indexed "not installed"
|
||||||
|
probe = func(argv []string, _ time.Duration) (CmdResult, bool) {
|
||||||
|
pkg := argv[len(argv)-1]
|
||||||
|
// pkg-0..pkg-7
|
||||||
|
idx := pkg[len(pkg)-1] - '0'
|
||||||
|
if idx%2 == 1 {
|
||||||
|
return CmdResult{ExitCode: 0}, true // installed
|
||||||
|
}
|
||||||
|
return CmdResult{ExitCode: 1}, true // not installed
|
||||||
|
}
|
||||||
|
|
||||||
|
names := []string{"pkg-0", "pkg-1", "pkg-2", "pkg-3", "pkg-4", "pkg-5", "pkg-6", "pkg-7"}
|
||||||
|
res := checkSystemPackages(names)
|
||||||
|
|
||||||
|
wantToInstall := []string{"pkg-0", "pkg-2", "pkg-4", "pkg-6"}
|
||||||
|
wantAlready := []string{"pkg-1", "pkg-3", "pkg-5", "pkg-7"}
|
||||||
|
if !equalStringSlices(res.toInstallRegular, wantToInstall) {
|
||||||
|
t.Errorf("toInstall: want %v, got %v", wantToInstall, res.toInstallRegular)
|
||||||
|
}
|
||||||
|
if !equalStringSlices(res.alreadyInstalled, wantAlready) {
|
||||||
|
t.Errorf("alreadyInstalled: want %v, got %v", wantAlready, res.alreadyInstalled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckCustomPackagesParallel(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
|
||||||
|
// pkg with InstallPath /tmp/foo-N; "installed" iff N is odd.
|
||||||
|
osStat = func(name string) (os.FileInfo, error) {
|
||||||
|
// Map: name like /tmp/foo-1 → installed; /tmp/foo-0 → not.
|
||||||
|
idx := name[len(name)-1] - '0'
|
||||||
|
if idx%2 == 1 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgs := []*CustomPackage{
|
||||||
|
{Name: "p0", InstallPath: "/tmp/foo-0"},
|
||||||
|
{Name: "p1", InstallPath: "/tmp/foo-1"},
|
||||||
|
{Name: "p2", InstallPath: "/tmp/foo-2"},
|
||||||
|
{Name: "p3", InstallPath: "/tmp/foo-3"},
|
||||||
|
}
|
||||||
|
res := checkCustomPackages(pkgs)
|
||||||
|
|
||||||
|
if len(res.toInstall) != 2 || res.toInstall[0].Name != "p0" || res.toInstall[1].Name != "p2" {
|
||||||
|
t.Errorf("toInstall: want p0,p2 in order, got %v", names(res.toInstall))
|
||||||
|
}
|
||||||
|
if len(res.alreadyInstalled) != 2 || res.alreadyInstalled[0].pkg.Name != "p1" || res.alreadyInstalled[1].pkg.Name != "p3" {
|
||||||
|
t.Errorf("already: want p1,p3 in order, got %v", customNames(res.alreadyInstalled))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallNpmToolsBatchSinglePnpmCall(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
|
||||||
|
// Pretend ~/.nvm exists so NVM check passes.
|
||||||
|
osStat = func(name string) (os.FileInfo, error) {
|
||||||
|
if strings.HasSuffix(name, ".nvm") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
var shellCalls []string
|
||||||
|
runShell = func(cmd string, _ CmdOpts) CmdResult {
|
||||||
|
shellCalls = append(shellCalls, cmd)
|
||||||
|
return CmdResult{ExitCode: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgs := []*CustomPackage{
|
||||||
|
{Name: "claude"},
|
||||||
|
{Name: "codex"},
|
||||||
|
{Name: "copilot"},
|
||||||
|
}
|
||||||
|
installNpmToolsBatch(pkgs)
|
||||||
|
|
||||||
|
// Expect exactly one pnpm add -g call containing all three packages.
|
||||||
|
addCalls := 0
|
||||||
|
for _, c := range shellCalls {
|
||||||
|
if strings.Contains(c, "pnpm add -g") {
|
||||||
|
addCalls++
|
||||||
|
if !strings.Contains(c, "@anthropic-ai/claude-code") ||
|
||||||
|
!strings.Contains(c, "@openai/codex") ||
|
||||||
|
!strings.Contains(c, "@github/copilot") {
|
||||||
|
t.Errorf("expected all three npm names in batched call, got: %q", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if addCalls != 1 {
|
||||||
|
t.Errorf("expected exactly 1 batched pnpm add call, got %d (all calls: %v)", addCalls, shellCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallNpmToolsBatchFallback(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
osStat = func(name string) (os.FileInfo, error) {
|
||||||
|
if strings.HasSuffix(name, ".nvm") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
calls := 0
|
||||||
|
runShell = func(cmd string, _ CmdOpts) CmdResult {
|
||||||
|
calls++
|
||||||
|
// Fail the first (batched) pnpm add call; succeed thereafter.
|
||||||
|
if calls == 1 && strings.Contains(cmd, "pnpm add -g") {
|
||||||
|
return CmdResult{ExitCode: 1}
|
||||||
|
}
|
||||||
|
return CmdResult{ExitCode: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgs := []*CustomPackage{
|
||||||
|
{Name: "claude"},
|
||||||
|
{Name: "codex"},
|
||||||
|
}
|
||||||
|
installNpmToolsBatch(pkgs)
|
||||||
|
|
||||||
|
// 1 ensureNodeLTS + 1 batch + 2 per-package retries (each may emit
|
||||||
|
// 2 shell calls: ensureNodeLTS again + add). Just sanity-check that
|
||||||
|
// retries happened.
|
||||||
|
if calls < 3 {
|
||||||
|
t.Errorf("expected at least 3 shell calls after batch failure, got %d", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallCustomPackagesWavesIndependentFirst(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
|
||||||
|
// Make hasCmd / osStat permissive. ~/.nvm must "exist" so the
|
||||||
|
// npm-batch path doesn't bail out at its precondition check.
|
||||||
|
hasCmd = func(name string) bool { return true }
|
||||||
|
osStat = func(name string) (os.FileInfo, error) {
|
||||||
|
if strings.HasSuffix(name, ".nvm") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
osReadFile = func(name string) ([]byte, error) {
|
||||||
|
return []byte{}, os.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
var orderMu sync.Mutex
|
||||||
|
var order []string
|
||||||
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||||
|
orderMu.Lock()
|
||||||
|
order = append(order, strings.Join(argv, " "))
|
||||||
|
orderMu.Unlock()
|
||||||
|
return CmdResult{ExitCode: 0}
|
||||||
|
}
|
||||||
|
runShell = func(cmd string, _ CmdOpts) CmdResult {
|
||||||
|
orderMu.Lock()
|
||||||
|
order = append(order, cmd)
|
||||||
|
orderMu.Unlock()
|
||||||
|
return CmdResult{ExitCode: 0}
|
||||||
|
}
|
||||||
|
download = func(_, _ string) bool { return true }
|
||||||
|
fetchJSON = func(_ string, _ any) bool { return false }
|
||||||
|
fetchText = func(_ string) string { return "" }
|
||||||
|
|
||||||
|
// Mix of independent + node-dependent. We just verify dispatch order:
|
||||||
|
// the npm-batched call must appear after some Wave A activity.
|
||||||
|
pkgs := []*CustomPackage{
|
||||||
|
{Name: "agy"},
|
||||||
|
{Name: "oh-my-zsh"},
|
||||||
|
{Name: "claude"},
|
||||||
|
{Name: "codex"},
|
||||||
|
}
|
||||||
|
installCustomPackages(pkgs)
|
||||||
|
|
||||||
|
// The pnpm add -g call must exist and appear after agy/oh-my-zsh
|
||||||
|
// install attempts.
|
||||||
|
var firstBatchIdx, firstWaveAIdx int = -1, -1
|
||||||
|
for i, c := range order {
|
||||||
|
if strings.Contains(c, "pnpm add -g @anthropic-ai/claude-code") {
|
||||||
|
firstBatchIdx = i
|
||||||
|
}
|
||||||
|
if (strings.Contains(c, "antigravity.google") || strings.Contains(c, "ohmyzsh")) && firstWaveAIdx == -1 {
|
||||||
|
firstWaveAIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if firstWaveAIdx == -1 {
|
||||||
|
t.Errorf("expected to see Wave A activity (agy/oh-my-zsh), got order: %v", order)
|
||||||
|
}
|
||||||
|
if firstBatchIdx == -1 {
|
||||||
|
t.Errorf("expected to see batched pnpm add call, got order: %v", order)
|
||||||
|
}
|
||||||
|
if firstWaveAIdx > firstBatchIdx {
|
||||||
|
t.Errorf("expected Wave A activity to begin before Wave B batch, got waveA@%d batch@%d", firstWaveAIdx, firstBatchIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveLatestAllRunsInParallel(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
|
||||||
|
// Register a custom resolver that records start order.
|
||||||
|
var mu sync.Mutex
|
||||||
|
var starts []string
|
||||||
|
|
||||||
|
latestResolvers["test-fast"] = func(p *CustomPackage) (string, string, bool) {
|
||||||
|
mu.Lock()
|
||||||
|
starts = append(starts, p.Name)
|
||||||
|
mu.Unlock()
|
||||||
|
return p.Version, p.SHA256, true
|
||||||
|
}
|
||||||
|
defer delete(latestResolvers, "test-fast")
|
||||||
|
|
||||||
|
pkgs := []*CustomPackage{
|
||||||
|
{Name: "x", Version: "1", SHA256: "a", FetchLatest: "test-fast"},
|
||||||
|
{Name: "y", Version: "2", SHA256: "b", FetchLatest: "test-fast"},
|
||||||
|
{Name: "z", Version: "3", SHA256: "c", FetchLatest: "test-fast"},
|
||||||
|
}
|
||||||
|
resolveLatestAll(pkgs)
|
||||||
|
|
||||||
|
if len(starts) != 3 {
|
||||||
|
t.Errorf("expected all 3 resolvers invoked, got %d: %v", len(starts), starts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── small helpers/fakes ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func equalStringSlices(a, b []string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func names(pkgs []*CustomPackage) []string {
|
||||||
|
out := make([]string, len(pkgs))
|
||||||
|
for i, p := range pkgs {
|
||||||
|
out[i] = p.Name
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func customNames(s []customStatus) []string {
|
||||||
|
out := make([]string, len(s))
|
||||||
|
for i, st := range s {
|
||||||
|
out[i] = st.pkg.Name
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
type systemCheckResult struct {
|
type systemCheckResult struct {
|
||||||
@@ -33,6 +34,28 @@ type customStatus struct {
|
|||||||
path string
|
path string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parallelPartition runs check(item) over items concurrently (using the
|
||||||
|
// configured cpuWorkers pool) and returns the items where check returned
|
||||||
|
// true first, then those where it returned false — both in input order.
|
||||||
|
// We preserve input order so the displayed package lists stay stable.
|
||||||
|
func parallelPartition[T any](items []T, check func(T) bool) (truthy, falsy []T) {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
results := make([]bool, len(items))
|
||||||
|
parallelDo(items, cpuWorkers(), func(i int, item T) {
|
||||||
|
results[i] = check(item)
|
||||||
|
})
|
||||||
|
for i, item := range items {
|
||||||
|
if results[i] {
|
||||||
|
truthy = append(truthy, item)
|
||||||
|
} else {
|
||||||
|
falsy = append(falsy, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
func checkSystemPackages(names []string) systemCheckResult {
|
func checkSystemPackages(names []string) systemCheckResult {
|
||||||
overrides := packageOverrides[pkgMgr]
|
overrides := packageOverrides[pkgMgr]
|
||||||
resolved, skipped := resolveSystemPkgs(names)
|
resolved, skipped := resolveSystemPkgs(names)
|
||||||
@@ -54,22 +77,8 @@ func checkSystemPackages(names []string) systemCheckResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var toR, alreadyR []string
|
alreadyR, toR := parallelPartition(regular, isSystemPkgInstalled)
|
||||||
for _, p := range regular {
|
alreadyS, toS := parallelPartition(special, isSpecialPkgInstalled)
|
||||||
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{
|
return systemCheckResult{
|
||||||
toInstallRegular: toR,
|
toInstallRegular: toR,
|
||||||
toInstallSpecial: toS,
|
toInstallSpecial: toS,
|
||||||
@@ -80,24 +89,25 @@ func checkSystemPackages(names []string) systemCheckResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkFlatpakPackages(ids []string) flatpakCheckResult {
|
func checkFlatpakPackages(ids []string) flatpakCheckResult {
|
||||||
var to, already []string
|
already, to := parallelPartition(ids, isFlatpakInstalled)
|
||||||
for _, p := range ids {
|
|
||||||
if isFlatpakInstalled(p) {
|
|
||||||
already = append(already, p)
|
|
||||||
} else {
|
|
||||||
to = append(to, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return flatpakCheckResult{toInstall: to, alreadyInstalled: already}
|
return flatpakCheckResult{toInstall: to, alreadyInstalled: already}
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkCustomPackages(pkgs []*CustomPackage) customCheckResult {
|
func checkCustomPackages(pkgs []*CustomPackage) customCheckResult {
|
||||||
|
type result struct {
|
||||||
|
installed bool
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
results := make([]result, len(pkgs))
|
||||||
|
parallelDo(pkgs, cpuWorkers(), func(i int, p *CustomPackage) {
|
||||||
|
installed, path := isCustomPkgInstalled(p)
|
||||||
|
results[i] = result{installed: installed, path: path}
|
||||||
|
})
|
||||||
var to []*CustomPackage
|
var to []*CustomPackage
|
||||||
var already []customStatus
|
var already []customStatus
|
||||||
for _, p := range pkgs {
|
for i, p := range pkgs {
|
||||||
installed, path := isCustomPkgInstalled(p)
|
if results[i].installed {
|
||||||
if installed {
|
already = append(already, customStatus{pkg: p, path: results[i].path})
|
||||||
already = append(already, customStatus{pkg: p, path: path})
|
|
||||||
} else {
|
} else {
|
||||||
to = append(to, p)
|
to = append(to, p)
|
||||||
}
|
}
|
||||||
@@ -105,6 +115,43 @@ func checkCustomPackages(pkgs []*CustomPackage) customCheckResult {
|
|||||||
return customCheckResult{toInstall: to, alreadyInstalled: already}
|
return customCheckResult{toInstall: to, alreadyInstalled: already}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkAllInParallel runs the three check passes concurrently. The caller
|
||||||
|
// must still gate which checks to run via *only; we accept already-prepared
|
||||||
|
// inputs and skip when the corresponding slice/conditional indicates no work.
|
||||||
|
func checkAllInParallel(
|
||||||
|
runSys bool, sysPkgs []string,
|
||||||
|
runFlat bool, flatPkgs []string,
|
||||||
|
runCust bool, customPkgs []*CustomPackage,
|
||||||
|
) (systemCheckResult, flatpakCheckResult, customCheckResult) {
|
||||||
|
var sys systemCheckResult
|
||||||
|
var flat flatpakCheckResult
|
||||||
|
var cust customCheckResult
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
if runSys {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
sys = checkSystemPackages(sysPkgs)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
if runFlat {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
flat = checkFlatpakPackages(flatPkgs)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
if runCust {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
cust = checkCustomPackages(customPkgs)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return sys, flat, cust
|
||||||
|
}
|
||||||
|
|
||||||
func fmtList(items []string, limit int) string {
|
func fmtList(items []string, limit int) string {
|
||||||
if len(items) <= limit {
|
if len(items) <= limit {
|
||||||
return strings.Join(items, " ")
|
return strings.Join(items, " ")
|
||||||
|
|||||||
-1049
File diff suppressed because it is too large
Load Diff
+1217
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,674 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Third wave of coverage tests, picking up the last remaining
|
||||||
|
// reasonably-testable branches: checkSudo paths, runMain ending paths,
|
||||||
|
// install-handler edge cases, and various small gaps in helpers.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── checkSudo ───────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// checkSudo is hard to test fully because it calls os.Geteuid() directly,
|
||||||
|
// which we can't mock. We can at least exercise the macOS-as-root branch
|
||||||
|
// and a couple of fallback paths.
|
||||||
|
|
||||||
|
func TestCheckSudoMacOSRootRefused(t *testing.T) {
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
t.Skip("test exercises root-on-macOS branch; not running as root")
|
||||||
|
}
|
||||||
|
defer resetMocks()
|
||||||
|
isMacOS = true
|
||||||
|
called := false
|
||||||
|
osExit = func(_ int) { called = true }
|
||||||
|
checkSudo()
|
||||||
|
if !called {
|
||||||
|
t.Error("expected osExit when root on macOS")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckSudoLinuxRoot(t *testing.T) {
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
t.Skip("only runs as root")
|
||||||
|
}
|
||||||
|
defer resetMocks()
|
||||||
|
isMacOS = false
|
||||||
|
called := false
|
||||||
|
osExit = func(_ int) { called = true }
|
||||||
|
checkSudo()
|
||||||
|
if called {
|
||||||
|
t.Error("expected no exit when root on Linux")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckSudoNoSudoCmd(t *testing.T) {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
t.Skip("not applicable when running as root")
|
||||||
|
}
|
||||||
|
defer resetMocks()
|
||||||
|
isMacOS = false
|
||||||
|
hasCmd = func(_ string) bool { return false }
|
||||||
|
called := false
|
||||||
|
osExit = func(_ int) { called = true }
|
||||||
|
checkSudo()
|
||||||
|
if !called {
|
||||||
|
t.Error("expected osExit when sudo missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckSudoAuthFails(t *testing.T) {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
t.Skip("not applicable when running as root")
|
||||||
|
}
|
||||||
|
defer resetMocks()
|
||||||
|
isMacOS = false
|
||||||
|
hasCmd = func(name string) bool { return name == "sudo" }
|
||||||
|
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 1} }
|
||||||
|
called := false
|
||||||
|
osExit = func(_ int) { called = true }
|
||||||
|
checkSudo()
|
||||||
|
if !called {
|
||||||
|
t.Error("expected osExit when sudo -v fails")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckSudoAuthOK(t *testing.T) {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
t.Skip("not applicable when running as root")
|
||||||
|
}
|
||||||
|
defer resetMocks()
|
||||||
|
isMacOS = false
|
||||||
|
hasCmd = func(name string) bool { return name == "sudo" }
|
||||||
|
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||||
|
called := false
|
||||||
|
osExit = func(_ int) { called = true }
|
||||||
|
checkSudo()
|
||||||
|
if called {
|
||||||
|
t.Error("expected no exit when sudo -v succeeds")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── runMain end-paths ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestRunMainErrorExit(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "dnf"
|
||||||
|
osStat = func(_ string) (os.FileInfo, error) { return nil, nil }
|
||||||
|
probe = func(_ []string, _ time.Duration) (CmdResult, bool) { return CmdResult{ExitCode: 0}, true }
|
||||||
|
hasCmd = func(_ string) bool { return true }
|
||||||
|
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||||
|
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||||
|
stdin = strings.NewReader("y\n")
|
||||||
|
defer func() { stdin = os.Stdin }()
|
||||||
|
|
||||||
|
// Pre-seed an error so hasErrors() returns true at end of runMain.
|
||||||
|
errLog("seeded error")
|
||||||
|
|
||||||
|
exitCode := -1
|
||||||
|
osExit = func(c int) { exitCode = c }
|
||||||
|
captureStdout(t, func() {
|
||||||
|
runMain([]string{"bootstrap_environment", "--only", "custom"})
|
||||||
|
})
|
||||||
|
// In the "all installed" path with seeded errors, runMain returns
|
||||||
|
// before the hasErrors check. To actually test that branch we'd need
|
||||||
|
// a path that reaches installation. Sanity-check: no crash.
|
||||||
|
_ = exitCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunMainFlatpakBranch(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "dnf"
|
||||||
|
isMacOS = false
|
||||||
|
osStat = func(_ string) (os.FileInfo, error) { return nil, nil }
|
||||||
|
probe = func(_ []string, _ time.Duration) (CmdResult, bool) { return CmdResult{ExitCode: 0}, true }
|
||||||
|
hasCmd = func(_ string) bool { return true }
|
||||||
|
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||||
|
osExit = func(_ int) {}
|
||||||
|
captureStdout(t, func() {
|
||||||
|
// --gui enables flatpak; --only flatpak skips system/custom branches.
|
||||||
|
runMain([]string{"bootstrap_environment", "--only", "flatpak", "--gui"})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── promptGitHubToken: env with whitespace ──────────────────────────────
|
||||||
|
|
||||||
|
func TestPromptGitHubTokenEnvWhitespace(t *testing.T) {
|
||||||
|
defer func() { githubTokenSet = false }()
|
||||||
|
t.Setenv("GITHUB_TOKEN", " ")
|
||||||
|
githubTokenSet = false
|
||||||
|
stdin = strings.NewReader("n\n")
|
||||||
|
defer func() { stdin = os.Stdin }()
|
||||||
|
captureStdout(t, func() {
|
||||||
|
promptGitHubToken()
|
||||||
|
})
|
||||||
|
if githubTokenSet {
|
||||||
|
t.Error("expected whitespace-only env token to be ignored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── installFirecracker errors during cp/chmod (no extra-branch payoff) ──
|
||||||
|
|
||||||
|
// ── installNeovim download fails ────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestInstallNeovimDownloadFails(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
osName = "linux"
|
||||||
|
archName = "x86_64"
|
||||||
|
fetchJSON = func(_ string, v any) bool {
|
||||||
|
v.(*ghRelease).Assets = []ghAsset{{
|
||||||
|
Name: "nvim-linux-x86_64.tar.gz", Digest: "sha256:abc",
|
||||||
|
}}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
download = func(_, _ string) bool { return false }
|
||||||
|
installNeovim(nil, t.TempDir())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallNeovimSHAHashFail(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
osName = "linux"
|
||||||
|
archName = "x86_64"
|
||||||
|
fetchJSON = func(_ string, v any) bool {
|
||||||
|
v.(*ghRelease).Assets = []ghAsset{{
|
||||||
|
Name: "nvim-linux-x86_64.tar.gz", Digest: "sha256:abc",
|
||||||
|
}}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
download = func(_, _ string) bool { return true } // doesn't write the file
|
||||||
|
installNeovim(nil, t.TempDir())
|
||||||
|
if !hasIssueContaining("Neovim hash failed") {
|
||||||
|
t.Error("expected hash error when file missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ensureHomebrew already installed ────────────────────────────────────
|
||||||
|
|
||||||
|
func TestEnsureHomebrewAlreadyInstalled(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
isMacOS = true
|
||||||
|
hasCmd = func(name string) bool { return name == "brew" }
|
||||||
|
captureStdout(t, func() {
|
||||||
|
ensureHomebrew()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureHomebrewNotMacOS(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
isMacOS = false
|
||||||
|
// Should no-op.
|
||||||
|
ensureHomebrew()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureXcodeCLTNotMacOS(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
isMacOS = false
|
||||||
|
ensureXcodeCLT() // should no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureXcodeCLTAlreadyInstalled(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
isMacOS = true
|
||||||
|
probe = func(_ []string, _ time.Duration) (CmdResult, bool) {
|
||||||
|
return CmdResult{ExitCode: 0, Stdout: []byte("/Library/Developer/CommandLineTools")}, true
|
||||||
|
}
|
||||||
|
captureStdout(t, func() {
|
||||||
|
ensureXcodeCLT()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ensureHomebrew installer fails ──────────────────────────────────────
|
||||||
|
|
||||||
|
func TestEnsureHomebrewInstallerFails(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
isMacOS = true
|
||||||
|
hasCmd = func(_ string) bool { return false }
|
||||||
|
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 1} }
|
||||||
|
called := false
|
||||||
|
osExit = func(_ int) { called = true }
|
||||||
|
captureStderr(t, func() {
|
||||||
|
ensureHomebrew()
|
||||||
|
})
|
||||||
|
if !called {
|
||||||
|
t.Error("expected osExit when Homebrew install fails")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureHomebrewBrewNotAtExpectedPath(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
isMacOS = true
|
||||||
|
archName = "x86_64"
|
||||||
|
hasCmd = func(_ string) bool { return false }
|
||||||
|
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||||
|
osStat = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist }
|
||||||
|
called := false
|
||||||
|
osExit = func(_ int) { called = true }
|
||||||
|
captureStderr(t, func() {
|
||||||
|
ensureHomebrew()
|
||||||
|
})
|
||||||
|
if !called {
|
||||||
|
t.Error("expected osExit when brew binary missing after install")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── python3DecimalOK + fixPython3Decimal branches ───────────────────────
|
||||||
|
|
||||||
|
func TestPython3DecimalOKNoPython(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
hasCmd = func(_ string) bool { return false }
|
||||||
|
if python3DecimalOK() {
|
||||||
|
t.Error("expected false when python3 missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPython3DecimalOKProbeFail(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
hasCmd = func(_ string) bool { return true }
|
||||||
|
probe = func(_ []string, _ time.Duration) (CmdResult, bool) { return CmdResult{}, false }
|
||||||
|
if python3DecimalOK() {
|
||||||
|
t.Error("expected false when probe times out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFixPython3DecimalDnf(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "dnf"
|
||||||
|
hasCmd = func(_ string) bool { return true }
|
||||||
|
probe = func(_ []string, _ time.Duration) (CmdResult, bool) { return CmdResult{ExitCode: 0}, true }
|
||||||
|
var got []string
|
||||||
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||||
|
got = argv
|
||||||
|
return CmdResult{ExitCode: 0}
|
||||||
|
}
|
||||||
|
if !fixPython3Decimal() {
|
||||||
|
t.Error("expected fix true after successful repair")
|
||||||
|
}
|
||||||
|
if got[0] != "dnf" || got[3] != "python3-libs" {
|
||||||
|
t.Errorf("expected dnf install -y python3-libs, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFixPython3DecimalPacman(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "pacman"
|
||||||
|
hasCmd = func(_ string) bool { return true }
|
||||||
|
probe = func(_ []string, _ time.Duration) (CmdResult, bool) { return CmdResult{ExitCode: 0}, true }
|
||||||
|
var got []string
|
||||||
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||||
|
got = argv
|
||||||
|
return CmdResult{ExitCode: 0}
|
||||||
|
}
|
||||||
|
if !fixPython3Decimal() {
|
||||||
|
t.Error("expected fix true after successful repair")
|
||||||
|
}
|
||||||
|
if got[0] != "pacman" {
|
||||||
|
t.Errorf("expected pacman call, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── invokingUser fallback chain ─────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestInvokingUserFromSudoUser(t *testing.T) {
|
||||||
|
t.Setenv("SUDO_USER", "myuser")
|
||||||
|
if invokingUser() != "myuser" {
|
||||||
|
t.Error("expected SUDO_USER returned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── cloneNvimConfig: backup folder N>1 ──────────────────────────────────
|
||||||
|
|
||||||
|
func TestCloneNvimConfigMultipleBackups(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
tmp := t.TempDir()
|
||||||
|
t.Setenv("HOME", tmp)
|
||||||
|
// nvim, nvim-1, nvim-2 all "exist"
|
||||||
|
osStat = func(name string) (os.FileInfo, error) {
|
||||||
|
base := filepath.Base(name)
|
||||||
|
if base == "nvim" || base == "nvim-1" || base == "nvim-2" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||||
|
captureStdout(t, func() {
|
||||||
|
cloneNvimConfig()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── installOhMyZsh: existing zshrc with theme already gnzh ──────────────
|
||||||
|
|
||||||
|
func TestInstallOhMyZshAlreadyGNZH(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
hasCmd = func(_ string) bool { return true }
|
||||||
|
osStat = func(_ string) (os.FileInfo, error) { return nil, nil }
|
||||||
|
osReadFile = func(_ string) ([]byte, error) {
|
||||||
|
return []byte("# config\nZSH_THEME=\"gnzh\"\n"), nil
|
||||||
|
}
|
||||||
|
captureStdout(t, func() {
|
||||||
|
installOhMyZsh()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ensureZshDefault: probe with empty stdout uses default ──────────────
|
||||||
|
|
||||||
|
func TestEnsureZshDefaultProbeEmptyStdout(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
hasCmd = func(_ string) bool { return true }
|
||||||
|
probe = func(_ []string, _ time.Duration) (CmdResult, bool) {
|
||||||
|
return CmdResult{ExitCode: 0, Stdout: []byte("")}, true
|
||||||
|
}
|
||||||
|
t.Setenv("SUDO_USER", "nonexistent_user_xyz")
|
||||||
|
captureStdout(t, func() {
|
||||||
|
ensureZshDefault()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ensurePythonLatest: latest version is empty string ──────────────────
|
||||||
|
|
||||||
|
func TestEnsurePythonLatestProbeNonZero(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
osStat = func(_ string) (os.FileInfo, error) { return nil, nil }
|
||||||
|
probe = func(_ []string, _ time.Duration) (CmdResult, bool) {
|
||||||
|
return CmdResult{ExitCode: 1}, true
|
||||||
|
}
|
||||||
|
if wg := ensurePythonLatest(); wg != nil {
|
||||||
|
t.Error("expected nil waitgroup when latestStablePython returns empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsurePythonLatestVersionsProbeFail(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
osStat = func(_ string) (os.FileInfo, error) { return nil, nil }
|
||||||
|
probe = func(argv []string, _ time.Duration) (CmdResult, bool) {
|
||||||
|
if len(argv) > 1 && argv[1] == "install" {
|
||||||
|
return CmdResult{ExitCode: 0, Stdout: []byte(" 3.12.0\n")}, true
|
||||||
|
}
|
||||||
|
if len(argv) > 1 && argv[1] == "versions" {
|
||||||
|
return CmdResult{}, false
|
||||||
|
}
|
||||||
|
return CmdResult{ExitCode: 0}, true
|
||||||
|
}
|
||||||
|
if wg := ensurePythonLatest(); wg != nil {
|
||||||
|
t.Error("expected nil waitgroup when versions probe fails")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ensurePythonLatest: pyenv install kicks off, then global fails ─────
|
||||||
|
|
||||||
|
func TestEnsurePythonLatestGlobalFailsBackground(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
osStat = func(_ string) (os.FileInfo, error) { return nil, nil }
|
||||||
|
probe = func(argv []string, _ time.Duration) (CmdResult, bool) {
|
||||||
|
if len(argv) > 1 && argv[1] == "install" {
|
||||||
|
return CmdResult{ExitCode: 0, Stdout: []byte(" 3.12.0\n")}, true
|
||||||
|
}
|
||||||
|
if len(argv) > 1 && argv[1] == "versions" {
|
||||||
|
return CmdResult{ExitCode: 0, Stdout: []byte("")}, true
|
||||||
|
}
|
||||||
|
return CmdResult{ExitCode: 0}, true
|
||||||
|
}
|
||||||
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||||
|
// install OK; global fails.
|
||||||
|
if len(argv) > 1 && argv[1] == "global" {
|
||||||
|
return CmdResult{ExitCode: 1}
|
||||||
|
}
|
||||||
|
return CmdResult{ExitCode: 0}
|
||||||
|
}
|
||||||
|
wg := ensurePythonLatest()
|
||||||
|
wg.Wait()
|
||||||
|
if !hasIssueContaining("pyenv global") {
|
||||||
|
t.Error("expected pyenv global failure error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsurePythonLatestExistingMatchesGlobalFails(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
osStat = func(_ string) (os.FileInfo, error) { return nil, nil }
|
||||||
|
probe = func(argv []string, _ time.Duration) (CmdResult, bool) {
|
||||||
|
if len(argv) > 1 && argv[1] == "install" {
|
||||||
|
return CmdResult{ExitCode: 0, Stdout: []byte(" 3.12.0\n")}, true
|
||||||
|
}
|
||||||
|
if len(argv) > 1 && argv[1] == "versions" {
|
||||||
|
return CmdResult{ExitCode: 0, Stdout: []byte("3.12.0\n")}, true
|
||||||
|
}
|
||||||
|
return CmdResult{ExitCode: 0}, true
|
||||||
|
}
|
||||||
|
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 1} }
|
||||||
|
captureStdout(t, func() {
|
||||||
|
ensurePythonLatest()
|
||||||
|
})
|
||||||
|
if !hasIssueContaining("pyenv global") {
|
||||||
|
t.Error("expected pyenv global error logged")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── sha256Of error ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSha256OfMissingFile(t *testing.T) {
|
||||||
|
if _, err := sha256Of("/no/such/file/ever"); err == nil {
|
||||||
|
t.Error("expected error for missing file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── pkgmgr: detectPkgMgr unsupported (we can't really exit but exercise) ─
|
||||||
|
|
||||||
|
// detectPkgMgr always calls osExit on failure, which we don't want here.
|
||||||
|
|
||||||
|
// ── net: downloadReal error paths ───────────────────────────────────────
|
||||||
|
|
||||||
|
func TestDownloadRealBadURL(t *testing.T) {
|
||||||
|
if downloadReal("http://127.0.0.1:1/nope", "/tmp/x") {
|
||||||
|
t.Error("expected false for unreachable URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── repos setup with apt-get already configured ─────────────────────────
|
||||||
|
|
||||||
|
func TestSetupDockerRepoAptExisting(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "apt-get"
|
||||||
|
osStat = func(name string) (os.FileInfo, error) {
|
||||||
|
if strings.Contains(name, "docker.list") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
called := false
|
||||||
|
runCmd = func(_ []string, _ CmdOpts) CmdResult {
|
||||||
|
called = true
|
||||||
|
return CmdResult{ExitCode: 0}
|
||||||
|
}
|
||||||
|
setupDockerRepo()
|
||||||
|
if called {
|
||||||
|
t.Error("expected no runCmd when apt repo already exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupChromeRepoAptExisting(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "apt-get"
|
||||||
|
archName = "x86_64"
|
||||||
|
osStat = func(name string) (os.FileInfo, error) {
|
||||||
|
if strings.Contains(name, "google-chrome.list") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
called := false
|
||||||
|
runCmd = func(_ []string, _ CmdOpts) CmdResult {
|
||||||
|
called = true
|
||||||
|
return CmdResult{ExitCode: 0}
|
||||||
|
}
|
||||||
|
setupChromeRepo()
|
||||||
|
if called {
|
||||||
|
t.Error("expected no runCmd when apt chrome repo already exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupVivaldiRepoAptExisting(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "apt-get"
|
||||||
|
archName = "x86_64"
|
||||||
|
osStat = func(name string) (os.FileInfo, error) {
|
||||||
|
if strings.Contains(name, "vivaldi.list") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
called := false
|
||||||
|
runCmd = func(_ []string, _ CmdOpts) CmdResult {
|
||||||
|
called = true
|
||||||
|
return CmdResult{ExitCode: 0}
|
||||||
|
}
|
||||||
|
setupVivaldiRepo()
|
||||||
|
if called {
|
||||||
|
t.Error("expected no runCmd when apt vivaldi repo already exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── installSystemPackages: tmpdir creation fail path ────────────────────
|
||||||
|
|
||||||
|
func TestInstallSystemPackagesTmpDirFails(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "dnf"
|
||||||
|
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||||
|
// Force os.MkdirTemp to fail by setting TMPDIR to invalid path.
|
||||||
|
t.Setenv("TMPDIR", "/no/such/parent")
|
||||||
|
captureStdout(t, func() {
|
||||||
|
installSystemPackages(nil, []string{"pipx"})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── installFlatpakPackages: empty toInstall after install of flatpak ───
|
||||||
|
|
||||||
|
func TestInstallFlatpakInstallPromptDeclined(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
hasCmd = func(_ string) bool { return false }
|
||||||
|
stdin = strings.NewReader("n\n")
|
||||||
|
defer func() { stdin = os.Stdin }()
|
||||||
|
captureStdout(t, func() {
|
||||||
|
installFlatpakPackages([]string{"x.y"})
|
||||||
|
})
|
||||||
|
if !hasIssueContaining("flatpak not installed") {
|
||||||
|
t.Error("expected skip warning")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallFlatpakInstallFails(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "dnf"
|
||||||
|
stdin = strings.NewReader("y\n")
|
||||||
|
defer func() { stdin = os.Stdin }()
|
||||||
|
hasCmd = func(_ string) bool { return false }
|
||||||
|
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 1} }
|
||||||
|
captureStdout(t, func() {
|
||||||
|
installFlatpakPackages([]string{"x.y"})
|
||||||
|
})
|
||||||
|
if !hasIssueContaining("flatpak installation failed") {
|
||||||
|
t.Error("expected flatpak install failure error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── runLogPath: simulate os.Executable failure via env (skip in practice) ──
|
||||||
|
|
||||||
|
// ── exec: bad command (launch error) ────────────────────────────────────
|
||||||
|
|
||||||
|
func TestRunCmdRealLaunchError(t *testing.T) {
|
||||||
|
r := runCmdReal([]string{"/no/such/binary/exists"}, CmdOpts{Timeout: time.Second})
|
||||||
|
if r.OK() {
|
||||||
|
t.Error("expected failure when binary doesn't exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunShellRealNonZero(t *testing.T) {
|
||||||
|
r := runShellReal("exit 7", CmdOpts{Timeout: time.Second})
|
||||||
|
if r.ExitCode != 7 {
|
||||||
|
t.Errorf("expected exit 7, got %d", r.ExitCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunCmdRealNonZero(t *testing.T) {
|
||||||
|
r := runCmdReal([]string{"sh", "-c", "exit 9"}, CmdOpts{Timeout: time.Second})
|
||||||
|
if r.ExitCode != 9 {
|
||||||
|
t.Errorf("expected exit 9, got %d", r.ExitCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── parallelDo nil sentinel and re-entrancy already covered ─────────────
|
||||||
|
|
||||||
|
// ── runMain hasErrors -> exit(1) ────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestRunMainHasErrorsExitsOne(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "dnf"
|
||||||
|
osStat = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist }
|
||||||
|
probe = func(_ []string, _ time.Duration) (CmdResult, bool) {
|
||||||
|
return CmdResult{ExitCode: 1}, true
|
||||||
|
}
|
||||||
|
hasCmd = func(_ string) bool { return true }
|
||||||
|
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||||
|
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 1} } // install fails
|
||||||
|
stdin = strings.NewReader("y\n")
|
||||||
|
defer func() { stdin = os.Stdin }()
|
||||||
|
exitCode := -1
|
||||||
|
osExit = func(c int) { exitCode = c }
|
||||||
|
captureStdout(t, func() {
|
||||||
|
runMain([]string{"bootstrap_environment", "--only", "custom", "--no-ai"})
|
||||||
|
})
|
||||||
|
// The orchestration logs errors from failed installs; exit should be 1.
|
||||||
|
if !hasErrors() {
|
||||||
|
t.Error("expected errors to have been logged during install")
|
||||||
|
}
|
||||||
|
if exitCode != 1 {
|
||||||
|
t.Logf("note: exitCode=%d (1 expected only if hasErrors() at end)", exitCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── parallelDo passes maxWorkers > len(items) ───────────────────────────
|
||||||
|
|
||||||
|
func TestParallelDoClampWorkers(t *testing.T) {
|
||||||
|
var called int64
|
||||||
|
parallelDo([]int{1, 2}, 1000, func(_ int, _ int) {
|
||||||
|
atomic.AddInt64(&called, 1)
|
||||||
|
})
|
||||||
|
if called != 2 {
|
||||||
|
t.Errorf("expected 2 calls, got %d", called)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── writeRunLog: ensure existing-issues path emits to file ─────────────
|
||||||
|
|
||||||
|
func TestWriteRunLogWritesContent(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
warn("an issue")
|
||||||
|
written := []byte{}
|
||||||
|
osWriteFile = func(_ string, data []byte, _ os.FileMode) error {
|
||||||
|
written = append([]byte{}, data...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
captureStdout(t, func() {
|
||||||
|
writeRunLog()
|
||||||
|
})
|
||||||
|
if !strings.Contains(string(written), "WARN] an issue") {
|
||||||
|
t.Errorf("expected log to contain the warning, got: %s", written)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── checks: an osStat err that's not ErrNotExist (random error) ─────────
|
||||||
|
|
||||||
|
func TestIsCustomPkgInstalledStatError(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
osStat = func(_ string) (os.FileInfo, error) { return nil, errors.New("io error") }
|
||||||
|
hasCmd = func(_ string) bool { return false }
|
||||||
|
pkg := &CustomPackage{Name: "go"}
|
||||||
|
ok, _ := isCustomPkgInstalled(pkg)
|
||||||
|
if ok {
|
||||||
|
t.Error("expected not installed when stat returns error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Last wave of coverage tests targeting setupFirecrackerVM (testable
|
||||||
|
// early-return branches), the remaining install handler edge cases, and
|
||||||
|
// a few stragglers.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── setupFirecrackerVM: early returns ───────────────────────────────────
|
||||||
|
|
||||||
|
func TestSetupFirecrackerVMNotMac(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
isMacOS = false
|
||||||
|
setupFirecrackerVM() // should no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupFirecrackerVMBackendEmpty(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
isMacOS = true
|
||||||
|
archName = "aarch64"
|
||||||
|
// Apple M2 on macOS 14 → selectVMBackend returns "" → setup skips.
|
||||||
|
probe = func(argv []string, _ time.Duration) (CmdResult, bool) {
|
||||||
|
if argv[0] == "sw_vers" {
|
||||||
|
return CmdResult{ExitCode: 0, Stdout: []byte("14.0")}, true
|
||||||
|
}
|
||||||
|
return CmdResult{ExitCode: 0, Stdout: []byte("Apple M2")}, true
|
||||||
|
}
|
||||||
|
captureStdout(t, func() {
|
||||||
|
setupFirecrackerVM()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupFirecrackerVMSshKeygenFails(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
isMacOS = true
|
||||||
|
archName = "x86_64"
|
||||||
|
osStat = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist }
|
||||||
|
osMkdirAll = func(_ string, _ os.FileMode) error { return nil }
|
||||||
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||||
|
if argv[0] == "ssh-keygen" {
|
||||||
|
return CmdResult{ExitCode: 1}
|
||||||
|
}
|
||||||
|
return CmdResult{ExitCode: 0}
|
||||||
|
}
|
||||||
|
captureStdout(t, func() {
|
||||||
|
setupFirecrackerVM()
|
||||||
|
})
|
||||||
|
if !hasIssueContaining("ssh-keygen failed") {
|
||||||
|
t.Error("expected ssh-keygen failure error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupFirecrackerVMFedoraImageLookupFails(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
isMacOS = true
|
||||||
|
archName = "x86_64"
|
||||||
|
osStat = func(name string) (os.FileInfo, error) {
|
||||||
|
// Key exists; qcow2 missing.
|
||||||
|
if strings.HasSuffix(name, "id_ed25519") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
osMkdirAll = func(_ string, _ os.FileMode) error { return nil }
|
||||||
|
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||||
|
fetchText = func(_ string) string { return "" }
|
||||||
|
captureStdout(t, func() {
|
||||||
|
setupFirecrackerVM()
|
||||||
|
})
|
||||||
|
if !hasIssueContaining("Could not resolve latest Fedora") {
|
||||||
|
t.Error("expected Fedora lookup failure error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupFirecrackerVMPubKeyReadFails(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
isMacOS = true
|
||||||
|
archName = "x86_64"
|
||||||
|
osStat = func(_ string) (os.FileInfo, error) { return nil, nil } // key + qcow2 exist
|
||||||
|
osMkdirAll = func(_ string, _ os.FileMode) error { return nil }
|
||||||
|
osReadFile = func(_ string) ([]byte, error) { return nil, os.ErrNotExist }
|
||||||
|
captureStdout(t, func() {
|
||||||
|
setupFirecrackerVM()
|
||||||
|
})
|
||||||
|
if !hasIssueContaining("could not read public key") {
|
||||||
|
t.Error("expected pub-key read failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── installFirecracker: archive contains non-firecracker file ──────────
|
||||||
|
|
||||||
|
func TestInstallFirecrackerSkipsNonMatchingFiles(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
tmp := t.TempDir()
|
||||||
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||||
|
if argv[0] == "tar" {
|
||||||
|
// Drop a file that doesn't start with "firecracker" — should be skipped.
|
||||||
|
_ = os.WriteFile(filepath.Join(tmp, "README"), []byte("x"), 0o644)
|
||||||
|
_ = os.WriteFile(filepath.Join(tmp, "firecracker-v1"), []byte("x"), 0o755)
|
||||||
|
}
|
||||||
|
return CmdResult{ExitCode: 0}
|
||||||
|
}
|
||||||
|
installFirecracker(filepath.Join(tmp, "fc.tgz"), tmp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── installZig: existing glob match in /usr/local needs a writable parent ──
|
||||||
|
|
||||||
|
// We can't write to /usr/local in tests, but we can verify the symlink
|
||||||
|
// path runs through end-to-end with a no-op runCmd. The Glob returns []
|
||||||
|
// in tests, so the loop body stays uncovered.
|
||||||
|
|
||||||
|
// ── resolveLatestGo: version trimmed to empty (release tag was just "go") ──
|
||||||
|
|
||||||
|
func TestResolveLatestGoEmptyTrimmedVersion(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
fetchJSON = func(_ string, v any) bool {
|
||||||
|
// Release with version "go" → trim → empty.
|
||||||
|
data := `[{"version":"go","files":[]}]`
|
||||||
|
_ = v
|
||||||
|
// Marshal manually since we don't import json here; use the helper
|
||||||
|
// via reflection-free path: use the canonical mock from elsewhere.
|
||||||
|
return jsonUnmarshal([]byte(data), v)
|
||||||
|
}
|
||||||
|
if _, _, ok := resolveLatestGo(nil); ok {
|
||||||
|
t.Error("expected resolveLatestGo false when version is empty after trim")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveLatestFirecrackerEmptyTagTrim(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
isMacOS = false
|
||||||
|
fetchJSON = func(_ string, v any) bool {
|
||||||
|
v.(*ghRelease).TagName = "v" // → trimmed to ""
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, _, ok := resolveLatestFirecracker(nil); ok {
|
||||||
|
t.Error("expected false when trimmed tag is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── runMain: empty package lists short-circuit ──────────────────────────
|
||||||
|
|
||||||
|
func TestRunMainEmptyOnlyValid(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "dnf"
|
||||||
|
osStat = func(_ string) (os.FileInfo, error) { return nil, nil }
|
||||||
|
probe = func(_ []string, _ time.Duration) (CmdResult, bool) { return CmdResult{ExitCode: 0}, true }
|
||||||
|
hasCmd = func(_ string) bool { return true }
|
||||||
|
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||||
|
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||||
|
osExit = func(_ int) {}
|
||||||
|
captureStdout(t, func() {
|
||||||
|
// "" only flag (default) with everything reported as installed.
|
||||||
|
runMain([]string{"bootstrap_environment"})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonUnmarshal(data []byte, v any) bool {
|
||||||
|
return json.Unmarshal(data, v) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── extra runMain branches ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestRunMainSystemInstallPath(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "dnf"
|
||||||
|
osStat = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist }
|
||||||
|
probe = func(_ []string, _ time.Duration) (CmdResult, bool) { return CmdResult{ExitCode: 1}, true }
|
||||||
|
hasCmd = func(_ string) bool { return true }
|
||||||
|
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||||
|
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||||
|
stdin = strings.NewReader("y\n")
|
||||||
|
defer func() { stdin = os.Stdin }()
|
||||||
|
osExit = func(_ int) {}
|
||||||
|
captureStdout(t, func() {
|
||||||
|
runMain([]string{"bootstrap_environment", "--only", "system"})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunMainCustomInstallPath(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "dnf"
|
||||||
|
// Custom pkgs all need install (not present). The osStat mock runs
|
||||||
|
// from multiple goroutines via parallel Wave A, so it must be
|
||||||
|
// goroutine-safe (no shared mutable state outside of read-only env
|
||||||
|
// inspection).
|
||||||
|
osStat = func(name string) (os.FileInfo, error) {
|
||||||
|
// ~/.nvm exists so the npm batch path runs.
|
||||||
|
if strings.HasSuffix(name, ".nvm") || strings.HasSuffix(name, ".pyenv") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
probe = func(_ []string, _ time.Duration) (CmdResult, bool) { return CmdResult{ExitCode: 1}, true }
|
||||||
|
hasCmd = func(_ string) bool { return true }
|
||||||
|
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||||
|
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0, Stdout: []byte("v20\n")} }
|
||||||
|
download = func(_, dest string) bool {
|
||||||
|
return os.WriteFile(dest, []byte("x"), 0o644) == nil
|
||||||
|
}
|
||||||
|
fetchJSON = func(_ string, _ any) bool { return false }
|
||||||
|
fetchText = func(_ string) string { return "" }
|
||||||
|
stdin = strings.NewReader("y\n")
|
||||||
|
defer func() { stdin = os.Stdin }()
|
||||||
|
osExit = func(_ int) {}
|
||||||
|
captureStdout(t, func() {
|
||||||
|
runMain([]string{"bootstrap_environment", "--only", "custom"})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── runShellReal: probe times out via tiny timeout ──────────────────────
|
||||||
|
|
||||||
|
func TestRunShellRealTimeout(t *testing.T) {
|
||||||
|
r := runShellReal("sleep 1", CmdOpts{Timeout: 10 * time.Millisecond})
|
||||||
|
if r.ExitCode != 124 {
|
||||||
|
t.Errorf("expected timeout (124), got %d", r.ExitCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── runCmdReal: command times out ───────────────────────────────────────
|
||||||
|
|
||||||
|
func TestRunCmdRealTimeout(t *testing.T) {
|
||||||
|
r := runCmdReal([]string{"sleep", "1"}, CmdOpts{Timeout: 10 * time.Millisecond})
|
||||||
|
if r.ExitCode != 124 {
|
||||||
|
t.Errorf("expected timeout (124), got %d", r.ExitCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── runCmdReal: cwd + input passing ────────────────────────────────────
|
||||||
|
|
||||||
|
func TestRunCmdRealCwdAndInput(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
r := runCmdReal([]string{"sh", "-c", "cat > out.txt; pwd"},
|
||||||
|
CmdOpts{Cwd: tmp, Input: []byte("data"), Capture: true})
|
||||||
|
if !r.OK() {
|
||||||
|
t.Fatalf("expected OK, got: %v / %s", r.Err, r.Stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(r.Stdout), tmp) {
|
||||||
|
t.Errorf("expected stdout to contain cwd %s, got: %s", tmp, r.Stdout)
|
||||||
|
}
|
||||||
|
if data, err := os.ReadFile(filepath.Join(tmp, "out.txt")); err != nil || string(data) != "data" {
|
||||||
|
t.Errorf("expected stdin data to be written, got: %q (err=%v)", data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── runShellReal: cwd + input ──────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestRunShellRealCwdAndInput(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
r := runShellReal("cat > shell-out.txt; pwd",
|
||||||
|
CmdOpts{Cwd: tmp, Input: []byte("shelldata"), Capture: true})
|
||||||
|
if !r.OK() {
|
||||||
|
t.Fatalf("expected OK, got %v", r.Err)
|
||||||
|
}
|
||||||
|
if data, err := os.ReadFile(filepath.Join(tmp, "shell-out.txt")); err != nil || string(data) != "shelldata" {
|
||||||
|
t.Errorf("expected stdin data written via shell, got %q err=%v", data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── installPip apt-get fallback secondary failure ──────────────────────
|
||||||
|
|
||||||
|
func TestInstallPipAptFallbackFails(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "apt-get"
|
||||||
|
hasCmd = func(_ string) bool { return true }
|
||||||
|
probe = func(_ []string, _ time.Duration) (CmdResult, bool) { return CmdResult{ExitCode: 0}, true }
|
||||||
|
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 1} } // everything fails
|
||||||
|
installPip()
|
||||||
|
if !hasIssueContaining("python3-pip failed to install via apt-get") {
|
||||||
|
t.Error("expected apt python3-pip failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ensureXcodeCLT non-macOS quick exit (already added but exercise the cov path) ──
|
||||||
|
|
||||||
|
// ── invokingUser: SUDO_USER unset, user.Current succeeds ──
|
||||||
|
|
||||||
|
func TestInvokingUserNoSudoCurrentUser(t *testing.T) {
|
||||||
|
t.Setenv("SUDO_USER", "")
|
||||||
|
if invokingUser() == "" {
|
||||||
|
t.Error("expected invokingUser to fall back to user.Current()")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── installSystemPackages: tmpdir works for specials ──────────────────
|
||||||
|
|
||||||
|
func TestInstallSystemPackagesWithSpecialReal(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "dnf"
|
||||||
|
hasCmd = func(_ string) bool { return true }
|
||||||
|
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||||
|
captureStdout(t, func() {
|
||||||
|
installSystemPackages([]string{"git"}, []string{"pipx"})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── npmInstalled: home-dir failure path ────────────────────────────────
|
||||||
|
|
||||||
|
// The os.UserHomeDir call only returns an error when HOME is unset on Unix
|
||||||
|
// AND no /etc/passwd entry exists. Hard to trigger reliably across CI; the
|
||||||
|
// branch is mostly defensive. Skip explicit coverage.
|
||||||
|
|
||||||
|
// ── runOneCustomInstall: install path missing, with name != pip ────────
|
||||||
|
|
||||||
|
func TestRunOneCustomInstallNoCheckPath(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
// Pretend nothing is installed and use a package that has no install path
|
||||||
|
// AND no URL — should warn twice.
|
||||||
|
osStat = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist }
|
||||||
|
hasCmd = func(_ string) bool { return false }
|
||||||
|
runOneCustomInstall(&CustomPackage{Name: "unknownpkg-2"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── repos: apt-get docker setup (no existing file) ────────────────────
|
||||||
|
// Without docker installed this exercises the gpg+keyring branch via mocks.
|
||||||
|
|
||||||
|
func TestSetupDockerRepoApt(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "apt-get"
|
||||||
|
osStat = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist }
|
||||||
|
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||||
|
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||||
|
osReadFile = func(_ string) ([]byte, error) {
|
||||||
|
return []byte("ID=ubuntu\n"), nil
|
||||||
|
}
|
||||||
|
captureStdout(t, func() {
|
||||||
|
setupDockerRepo()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupChromeRepoApt(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "apt-get"
|
||||||
|
archName = "x86_64"
|
||||||
|
osStat = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist }
|
||||||
|
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||||
|
captureStdout(t, func() {
|
||||||
|
setupChromeRepo()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupVivaldiRepoApt(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
pkgMgr = "apt-get"
|
||||||
|
archName = "x86_64"
|
||||||
|
osStat = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist }
|
||||||
|
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||||
|
captureStdout(t, func() {
|
||||||
|
setupVivaldiRepo()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── installFirecrackerZshFunction: existing block gets replaced ───────
|
||||||
|
|
||||||
|
func TestInstallFirecrackerZshFunctionReplaceExisting(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
tmp := t.TempDir()
|
||||||
|
t.Setenv("HOME", tmp)
|
||||||
|
osReadFile = func(_ string) ([]byte, error) {
|
||||||
|
return []byte("# >>> firecracker-vm wrapper >>>\nold body\n# <<< firecracker-vm wrapper <<<\n\nelse"), nil
|
||||||
|
}
|
||||||
|
written := ""
|
||||||
|
osWriteFile = func(_ string, data []byte, _ os.FileMode) error {
|
||||||
|
written = string(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
newBlock := "# >>> firecracker-vm wrapper >>>\nnew body\n# <<< firecracker-vm wrapper <<<\n"
|
||||||
|
installFirecrackerZshFunction(newBlock)
|
||||||
|
if !strings.Contains(written, "new body") {
|
||||||
|
t.Errorf("expected new body in output, got: %q", written)
|
||||||
|
}
|
||||||
|
if strings.Contains(written, "old body") {
|
||||||
|
t.Errorf("expected old body to be removed, got: %q", written)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── latestFedoraCloudImage: missing checksum entry ────────────────────
|
||||||
|
|
||||||
|
func TestLatestFedoraCloudImageMissingFiles(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
archName = "x86_64"
|
||||||
|
calls := 0
|
||||||
|
fetchText = func(_ string) string {
|
||||||
|
calls++
|
||||||
|
if calls == 1 {
|
||||||
|
return `href="40/"`
|
||||||
|
}
|
||||||
|
// images dir has no matching qcow / checksum.
|
||||||
|
return `href="not-fedora.iso"`
|
||||||
|
}
|
||||||
|
if _, _, _, ok := latestFedoraCloudImage(); ok {
|
||||||
|
t.Error("expected false when matches not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
+1475
File diff suppressed because it is too large
Load Diff
@@ -154,7 +154,7 @@ func verifyArchive(archive string, pkg *CustomPackage) bool {
|
|||||||
errLog(fmt.Sprintf("SHA256 mismatch for %s: expected %s, got %s", pkg.Name, expected, actual))
|
errLog(fmt.Sprintf("SHA256 mismatch for %s: expected %s, got %s", pkg.Name, expected, actual))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
fmt.Println(" SHA256 OK")
|
taskPrintln(" SHA256 OK")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if sigURL := pkg.resolveSHA256URL(); sigURL != "" {
|
if sigURL := pkg.resolveSHA256URL(); sigURL != "" {
|
||||||
@@ -170,11 +170,11 @@ func verifyArchive(archive string, pkg *CustomPackage) bool {
|
|||||||
if pkg.MinisignKey != "" {
|
if pkg.MinisignKey != "" {
|
||||||
cmd = append(cmd, "-P", pkg.MinisignKey)
|
cmd = append(cmd, "-P", pkg.MinisignKey)
|
||||||
}
|
}
|
||||||
if !runCmd(cmd, CmdOpts{}).OK() {
|
if !runCmd(cmd, CmdOpts{Out: taskOut()}).OK() {
|
||||||
errLog(fmt.Sprintf("minisign verification failed for %s", pkg.Name))
|
errLog(fmt.Sprintf("minisign verification failed for %s", pkg.Name))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
fmt.Println(" minisign OK")
|
taskPrintln(" minisign OK")
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -198,18 +198,20 @@ func urlArchOK(pkg *CustomPackage) bool {
|
|||||||
// ── per-package install handlers ────────────────────────────────────────
|
// ── per-package install handlers ────────────────────────────────────────
|
||||||
|
|
||||||
func installGo(archive string) {
|
func installGo(archive string) {
|
||||||
|
out := taskOut()
|
||||||
goRoot := "/usr/local/go"
|
goRoot := "/usr/local/go"
|
||||||
if _, err := osStat(goRoot); err == nil {
|
if _, err := osStat(goRoot); err == nil {
|
||||||
fmt.Printf(" Removing existing Go at %s ...\n", goRoot)
|
taskPrintf(" Removing existing Go at %s ...\n", goRoot)
|
||||||
runCmd([]string{"rm", "-rf", goRoot}, CmdOpts{AsSudo: true})
|
runCmd([]string{"rm", "-rf", goRoot}, CmdOpts{AsSudo: true, Out: out})
|
||||||
}
|
}
|
||||||
runCmd([]string{"tar", "-C", "/usr/local", "-xzf", archive}, CmdOpts{AsSudo: true})
|
runCmd([]string{"tar", "-C", "/usr/local", "-xzf", archive}, CmdOpts{AsSudo: true, Out: out})
|
||||||
appendProfileLine("local_go", "export PATH=$PATH:/usr/local/go/bin")
|
appendProfileLine("local_go", "export PATH=$PATH:/usr/local/go/bin")
|
||||||
fmt.Printf(" Go installed to %s\n", goRoot)
|
taskPrintf(" Go installed to %s\n", goRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
func installFirecracker(archive, tmp string) {
|
func installFirecracker(archive, tmp string) {
|
||||||
if !runCmd([]string{"tar", "-C", tmp, "-xzf", archive}, CmdOpts{}).OK() {
|
out := taskOut()
|
||||||
|
if !runCmd([]string{"tar", "-C", tmp, "-xzf", archive}, CmdOpts{Out: out}).OK() {
|
||||||
errLog("firecracker tar extraction failed")
|
errLog("firecracker tar extraction failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -239,33 +241,35 @@ func installFirecracker(archive, tmp string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
dest := "/usr/local/bin/firecracker"
|
dest := "/usr/local/bin/firecracker"
|
||||||
runCmd([]string{"cp", binary, dest}, CmdOpts{AsSudo: true})
|
runCmd([]string{"cp", binary, dest}, CmdOpts{AsSudo: true, Out: out})
|
||||||
runCmd([]string{"chmod", "755", dest}, CmdOpts{AsSudo: true})
|
runCmd([]string{"chmod", "755", dest}, CmdOpts{AsSudo: true, Out: out})
|
||||||
fmt.Printf(" firecracker installed to %s\n", dest)
|
taskPrintf(" firecracker installed to %s\n", dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
func installZig(pkg *CustomPackage, archive string) {
|
func installZig(pkg *CustomPackage, archive string) {
|
||||||
|
out := taskOut()
|
||||||
parent := "/usr/local"
|
parent := "/usr/local"
|
||||||
zigDir := filepath.Join(parent, "zig-"+pkg.Version)
|
zigDir := filepath.Join(parent, "zig-"+pkg.Version)
|
||||||
if _, err := osStat(zigDir); err == nil {
|
if _, err := osStat(zigDir); err == nil {
|
||||||
runCmd([]string{"rm", "-rf", zigDir}, CmdOpts{AsSudo: true})
|
runCmd([]string{"rm", "-rf", zigDir}, CmdOpts{AsSudo: true, Out: out})
|
||||||
}
|
}
|
||||||
runCmd([]string{"tar", "-C", parent, "-xJf", archive}, CmdOpts{AsSudo: true})
|
runCmd([]string{"tar", "-C", parent, "-xJf", archive}, CmdOpts{AsSudo: true, Out: out})
|
||||||
|
|
||||||
pattern := filepath.Join(parent, fmt.Sprintf("zig-%s-%s*", archName, osZig[osName]))
|
pattern := filepath.Join(parent, fmt.Sprintf("zig-%s-%s*", archName, osZig[osName]))
|
||||||
matches, _ := filepath.Glob(pattern)
|
matches, _ := filepath.Glob(pattern)
|
||||||
for _, m := range matches {
|
for _, m := range matches {
|
||||||
if m != zigDir {
|
if m != zigDir {
|
||||||
runCmd([]string{"mv", m, zigDir}, CmdOpts{AsSudo: true})
|
runCmd([]string{"mv", m, zigDir}, CmdOpts{AsSudo: true, Out: out})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
symlink := "/usr/local/bin/zig"
|
symlink := "/usr/local/bin/zig"
|
||||||
runCmd([]string{"ln", "-sf", filepath.Join(zigDir, "zig"), symlink}, CmdOpts{AsSudo: true})
|
runCmd([]string{"ln", "-sf", filepath.Join(zigDir, "zig"), symlink}, CmdOpts{AsSudo: true, Out: out})
|
||||||
fmt.Printf(" Zig installed to %s, symlinked at %s\n", zigDir, symlink)
|
taskPrintf(" Zig installed to %s, symlinked at %s\n", zigDir, symlink)
|
||||||
}
|
}
|
||||||
|
|
||||||
func installNeovim(_ *CustomPackage, tmp string) {
|
func installNeovim(_ *CustomPackage, tmp string) {
|
||||||
|
out := taskOut()
|
||||||
var rel ghRelease
|
var rel ghRelease
|
||||||
if !fetchJSON("https://api.github.com/repos/neovim/neovim/releases/latest", &rel) {
|
if !fetchJSON("https://api.github.com/repos/neovim/neovim/releases/latest", &rel) {
|
||||||
return
|
return
|
||||||
@@ -303,18 +307,18 @@ func installNeovim(_ *CustomPackage, tmp string) {
|
|||||||
errLog(fmt.Sprintf("Neovim SHA256 mismatch: expected %s, got %s", expected, actual))
|
errLog(fmt.Sprintf("Neovim SHA256 mismatch: expected %s, got %s", expected, actual))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Println(" SHA256 OK")
|
taskPrintln(" SHA256 OK")
|
||||||
|
|
||||||
installDir := fmt.Sprintf("/opt/nvim-%s-%s", osTok, archTok)
|
installDir := fmt.Sprintf("/opt/nvim-%s-%s", osTok, archTok)
|
||||||
fmt.Println(" Extracting Neovim to /opt ...")
|
taskPrintln(" Extracting Neovim to /opt ...")
|
||||||
runCmd([]string{"mkdir", "-p", "/opt"}, CmdOpts{AsSudo: true})
|
runCmd([]string{"mkdir", "-p", "/opt"}, CmdOpts{AsSudo: true, Out: out})
|
||||||
runCmd([]string{"rm", "-rf", installDir}, CmdOpts{AsSudo: true})
|
runCmd([]string{"rm", "-rf", installDir}, CmdOpts{AsSudo: true, Out: out})
|
||||||
runCmd([]string{"tar", "-C", "/opt", "-xzf", dest}, CmdOpts{AsSudo: true})
|
runCmd([]string{"tar", "-C", "/opt", "-xzf", dest}, CmdOpts{AsSudo: true, Out: out})
|
||||||
|
|
||||||
runCmd([]string{"mkdir", "-p", "/usr/local/bin"}, CmdOpts{AsSudo: true})
|
runCmd([]string{"mkdir", "-p", "/usr/local/bin"}, CmdOpts{AsSudo: true, Out: out})
|
||||||
symlink := "/usr/local/bin/nvim"
|
symlink := "/usr/local/bin/nvim"
|
||||||
runCmd([]string{"ln", "-sf", filepath.Join(installDir, "bin", "nvim"), symlink}, CmdOpts{AsSudo: true})
|
runCmd([]string{"ln", "-sf", filepath.Join(installDir, "bin", "nvim"), symlink}, CmdOpts{AsSudo: true, Out: out})
|
||||||
fmt.Printf(" Neovim installed to %s, symlinked at %s\n", installDir, symlink)
|
taskPrintf(" Neovim installed to %s, symlinked at %s\n", installDir, symlink)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── latest-version resolvers ────────────────────────────────────────────
|
// ── latest-version resolvers ────────────────────────────────────────────
|
||||||
@@ -443,7 +447,7 @@ func resolveLatest(pkg *CustomPackage) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Printf(" Checking latest version for %s ...\n", pkg.Name)
|
taskPrintf(" Checking latest version for %s ...\n", pkg.Name)
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
warn(fmt.Sprintf("%s: latest-version lookup panicked %v; falling back to pinned version %s",
|
warn(fmt.Sprintf("%s: latest-version lookup panicked %v; falling back to pinned version %s",
|
||||||
@@ -457,115 +461,241 @@ func resolveLatest(pkg *CustomPackage) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if version == pkg.Version {
|
if version == pkg.Version {
|
||||||
fmt.Printf(" Pinned version %s is already the latest.\n", pkg.Version)
|
taskPrintf(" Pinned version %s is already the latest.\n", pkg.Version)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Printf(" Latest is %s (pinned was %s); using latest.\n", version, pkg.Version)
|
taskPrintf(" Latest is %s (pinned was %s); using latest.\n", version, pkg.Version)
|
||||||
pkg.Version = version
|
pkg.Version = version
|
||||||
pkg.SHA256 = strings.ToLower(sha)
|
pkg.SHA256 = strings.ToLower(sha)
|
||||||
pkg.SHA256URLTemplate = "" // prefer the freshly resolved sha256
|
pkg.SHA256URLTemplate = "" // prefer the freshly resolved sha256
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveLatestAll fetches latest versions for all packages with a
|
||||||
|
// FetchLatest hint in parallel — three small HTTP calls today, but enough to
|
||||||
|
// matter on slower connections. Each lookup is independent and idempotent.
|
||||||
|
func resolveLatestAll(pkgs []*CustomPackage) {
|
||||||
|
var withLatest []*CustomPackage
|
||||||
|
for _, p := range pkgs {
|
||||||
|
if _, ok := latestResolvers[p.FetchLatest]; ok {
|
||||||
|
withLatest = append(withLatest, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(withLatest) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parallelDo(withLatest, httpWorkers(), func(_ int, p *CustomPackage) {
|
||||||
|
resolveLatest(p)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ── orchestration ───────────────────────────────────────────────────────
|
// ── orchestration ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Dependency map for custom packages:
|
||||||
|
//
|
||||||
|
// nvm → claude, codex, copilot, playwright (need node from nvm)
|
||||||
|
// (none) → go, firecracker, zig, neovim, pyenv, pip, oh-my-zsh, agy,
|
||||||
|
// gh-repo-bootstrap (independent)
|
||||||
|
//
|
||||||
|
// Within "independent", we further split:
|
||||||
|
//
|
||||||
|
// Wave A (parallel, idempotent on disk targets that don't overlap):
|
||||||
|
// go, firecracker, zig, neovim, pyenv, pip, oh-my-zsh, agy, nvm,
|
||||||
|
// gh-repo-bootstrap
|
||||||
|
//
|
||||||
|
// Wave B (after Wave A; needs nvm/node to exist):
|
||||||
|
// claude, codex, copilot, playwright — batched into one pnpm call
|
||||||
|
//
|
||||||
|
// We parallelize Wave A up to cpuWorkers(). Each install runs under its own
|
||||||
|
// taskOutput so output stays grouped per-package. Wave B runs after Wave A
|
||||||
|
// has produced ~/.nvm; it batches the npm tools into a single `pnpm add -g`
|
||||||
|
// call (single Node startup, single pnpm dep solve).
|
||||||
|
|
||||||
|
func nodeDependentPkgs() map[string]bool {
|
||||||
|
return map[string]bool{
|
||||||
|
"claude": true,
|
||||||
|
"codex": true,
|
||||||
|
"copilot": true,
|
||||||
|
"playwright": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runOneCustomInstall executes a single custom package's install handler.
|
||||||
|
// The caller is responsible for setting up the goroutine-local task output
|
||||||
|
// when running in parallel. extracted from the old switch statement.
|
||||||
|
func runOneCustomInstall(pkg *CustomPackage) {
|
||||||
|
name := strings.ToLower(pkg.Name)
|
||||||
|
_, checkPath := isCustomPkgInstalled(pkg)
|
||||||
|
extra := ""
|
||||||
|
if checkPath != "" {
|
||||||
|
extra = fmt.Sprintf(" (install path: %s)", checkPath)
|
||||||
|
}
|
||||||
|
taskPrintf("\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))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "nvm":
|
||||||
|
installNVM()
|
||||||
|
return
|
||||||
|
case "pyenv":
|
||||||
|
installPyenv()
|
||||||
|
return
|
||||||
|
case "pip":
|
||||||
|
installPip()
|
||||||
|
return
|
||||||
|
case "oh-my-zsh":
|
||||||
|
installOhMyZsh()
|
||||||
|
return
|
||||||
|
case "neovim":
|
||||||
|
tmp, err := os.MkdirTemp("", "bootstrap-nvim-")
|
||||||
|
if err != nil {
|
||||||
|
errLog(fmt.Sprintf("neovim tmp dir failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
installNeovim(pkg, tmp)
|
||||||
|
osRemoveAll(tmp)
|
||||||
|
return
|
||||||
|
case "agy":
|
||||||
|
installAgy()
|
||||||
|
return
|
||||||
|
case "gh-repo-bootstrap":
|
||||||
|
installGHExtension("JMR-dev/gh-repo-bootstrap")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
url := pkg.resolveURL()
|
||||||
|
if url == "" {
|
||||||
|
warn(fmt.Sprintf("No URL or install handler for '%s' — skipping", pkg.Name))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !urlArchOK(pkg) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp, err := os.MkdirTemp("", "bootstrap-custom-")
|
||||||
|
if err != nil {
|
||||||
|
errLog(fmt.Sprintf("tmp dir failed for %s: %v", pkg.Name, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer osRemoveAll(tmp)
|
||||||
|
archive := filepath.Join(tmp, filepath.Base(url))
|
||||||
|
if !download(url, archive) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !verifyArchive(archive, pkg) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// installNpmToolsBatch installs all npm-based CLI tools (claude, codex,
|
||||||
|
// copilot, playwright) in a single `pnpm add -g` invocation. This is
|
||||||
|
// significantly faster than per-tool installs because pnpm only resolves
|
||||||
|
// the dep graph and starts Node once. On batch failure we fall back to
|
||||||
|
// per-package installs so we can report exactly which tool broke.
|
||||||
|
//
|
||||||
|
// playwright is special: after the npm install we still need to provision
|
||||||
|
// browsers via `pnpx playwright install`. We do that after the batch.
|
||||||
|
func installNpmToolsBatch(pkgs []*CustomPackage) {
|
||||||
|
if len(pkgs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if _, err := osStat(filepath.Join(home, ".nvm")); err != nil {
|
||||||
|
errLog("NVM is not installed — cannot install npm-based tools")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureNodeLTS()
|
||||||
|
|
||||||
|
npmNames := map[string]string{
|
||||||
|
"claude": "@anthropic-ai/claude-code",
|
||||||
|
"codex": "@openai/codex",
|
||||||
|
"copilot": "@github/copilot",
|
||||||
|
"playwright": "playwright",
|
||||||
|
}
|
||||||
|
|
||||||
|
var npmPkgs []string
|
||||||
|
var hasPlaywright bool
|
||||||
|
for _, p := range pkgs {
|
||||||
|
n := strings.ToLower(p.Name)
|
||||||
|
if pkg, ok := npmNames[n]; ok {
|
||||||
|
npmPkgs = append(npmPkgs, pkg)
|
||||||
|
if n == "playwright" {
|
||||||
|
hasPlaywright = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(npmPkgs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n Installing %d npm tool(s) via pnpm in one batch ...\n", len(npmPkgs))
|
||||||
|
addCmd := fmt.Sprintf(`bash -c '%ssource ~/.nvm/nvm.sh && pnpm add -g %s'`,
|
||||||
|
pnpmEnvPrefix(), strings.Join(npmPkgs, " "))
|
||||||
|
if !runShell(addCmd, CmdOpts{}).OK() {
|
||||||
|
warn("Batched pnpm add -g failed; retrying per-package to isolate failures ...")
|
||||||
|
for _, p := range pkgs {
|
||||||
|
n := strings.ToLower(p.Name)
|
||||||
|
if pkg, ok := npmNames[n]; ok {
|
||||||
|
installNpmPackage(pkg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasPlaywright {
|
||||||
|
installPlaywrightBrowsers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func installCustomPackages(toInstall []*CustomPackage) {
|
func installCustomPackages(toInstall []*CustomPackage) {
|
||||||
fmt.Println("\n=== Custom Packages ===")
|
fmt.Println("\n=== Custom Packages ===")
|
||||||
ensureNodeLTS()
|
if len(toInstall) == 0 {
|
||||||
for _, pkg := range toInstall {
|
return
|
||||||
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()
|
|
||||||
ensureNodeLTS()
|
|
||||||
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)
|
|
||||||
osRemoveAll(tmp)
|
|
||||||
continue
|
|
||||||
case "agy":
|
|
||||||
installAgy()
|
|
||||||
continue
|
|
||||||
case "claude":
|
|
||||||
installNpmPackage("@anthropic-ai/claude-code")
|
|
||||||
continue
|
|
||||||
case "codex":
|
|
||||||
installNpmPackage("@openai/codex")
|
|
||||||
continue
|
|
||||||
case "copilot":
|
|
||||||
installNpmPackage("@github/copilot")
|
|
||||||
continue
|
|
||||||
case "playwright":
|
|
||||||
installPlaywright()
|
|
||||||
continue
|
|
||||||
case "gh-repo-bootstrap":
|
|
||||||
installGHExtension("JMR-dev/gh-repo-bootstrap")
|
|
||||||
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) {
|
|
||||||
osRemoveAll(tmp)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !verifyArchive(archive, pkg) {
|
|
||||||
osRemoveAll(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))
|
|
||||||
}
|
|
||||||
osRemoveAll(tmp)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch latest versions for all to-install packages in parallel up
|
||||||
|
// front — small HTTP calls but they add up serially on slow links.
|
||||||
|
resolveLatestAll(toInstall)
|
||||||
|
|
||||||
|
// Split into independent (Wave A) vs node-dependent (Wave B).
|
||||||
|
nodeDeps := nodeDependentPkgs()
|
||||||
|
var waveA, waveB []*CustomPackage
|
||||||
|
for _, p := range toInstall {
|
||||||
|
if nodeDeps[strings.ToLower(p.Name)] {
|
||||||
|
waveB = append(waveB, p)
|
||||||
|
} else {
|
||||||
|
waveA = append(waveA, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wave A: parallel up to cpuWorkers(). Each package's output is buffered
|
||||||
|
// to a per-task taskOutput and flushed on completion so that concurrent
|
||||||
|
// installs don't interleave on stdout.
|
||||||
|
parallelDo(waveA, cpuWorkers(), func(_ int, pkg *CustomPackage) {
|
||||||
|
tOut := newCapturedOutput(pkg.Name)
|
||||||
|
withTaskOutput(tOut, func() {
|
||||||
|
runOneCustomInstall(pkg)
|
||||||
|
})
|
||||||
|
tOut.Flush(os.Stdout)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wave B (npm tools): batched into a single pnpm call. Requires Wave A
|
||||||
|
// to have completed (specifically: nvm install + ensureNodeLTS), so we
|
||||||
|
// run it after the parallel block returns.
|
||||||
|
installNpmToolsBatch(waveB)
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -253,9 +254,14 @@ func TestInstallCustomPackages(t *testing.T) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
var runCmdCalls [][]string
|
var (
|
||||||
|
runCmdMu sync.Mutex
|
||||||
|
runCmdCalls [][]string
|
||||||
|
)
|
||||||
runCmd = func(argv []string, opts CmdOpts) CmdResult {
|
runCmd = func(argv []string, opts CmdOpts) CmdResult {
|
||||||
|
runCmdMu.Lock()
|
||||||
runCmdCalls = append(runCmdCalls, argv)
|
runCmdCalls = append(runCmdCalls, argv)
|
||||||
|
runCmdMu.Unlock()
|
||||||
return CmdResult{ExitCode: 0}
|
return CmdResult{ExitCode: 0}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,6 +281,8 @@ func TestInstallCustomPackages(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
installCustomPackages(pkgs)
|
installCustomPackages(pkgs)
|
||||||
|
runCmdMu.Lock()
|
||||||
|
defer runCmdMu.Unlock()
|
||||||
|
|
||||||
// Verify that we executed tar/mv/ln etc commands via runCmd
|
// Verify that we executed tar/mv/ln etc commands via runCmd
|
||||||
hasTar := false
|
hasTar := false
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,6 +19,14 @@ import (
|
|||||||
const defaultSubprocessTimeout = 30 * time.Minute
|
const defaultSubprocessTimeout = 30 * time.Minute
|
||||||
|
|
||||||
// CmdOpts captures the optional knobs on runCmd / runShell.
|
// CmdOpts captures the optional knobs on runCmd / runShell.
|
||||||
|
//
|
||||||
|
// Out, when non-nil, switches the call into "captured-routed" mode: stdout
|
||||||
|
// and stderr are buffered, then the "$ cmd" echo, captured stdout, and
|
||||||
|
// captured stderr are written to Out in order. Capture is forced true.
|
||||||
|
// This is how parallel workers route output into per-task buffers without
|
||||||
|
// interleaving on os.Stdout. When Out is nil (default) the call streams to
|
||||||
|
// os.Stdout exactly as before, preserving the live-tail behavior used by
|
||||||
|
// the sequential code paths.
|
||||||
type CmdOpts struct {
|
type CmdOpts struct {
|
||||||
AsSudo bool
|
AsSudo bool
|
||||||
Check bool // exit on failure (kept for parity but treated as advisory — we return the error instead)
|
Check bool // exit on failure (kept for parity but treated as advisory — we return the error instead)
|
||||||
@@ -25,6 +34,7 @@ type CmdOpts struct {
|
|||||||
Capture bool
|
Capture bool
|
||||||
Cwd string
|
Cwd string
|
||||||
Timeout time.Duration // zero = defaultSubprocessTimeout
|
Timeout time.Duration // zero = defaultSubprocessTimeout
|
||||||
|
Out io.Writer // optional sink for echo + captured streams
|
||||||
}
|
}
|
||||||
|
|
||||||
// CmdResult holds the outcome of a subprocess invocation.
|
// CmdResult holds the outcome of a subprocess invocation.
|
||||||
@@ -45,7 +55,12 @@ func runCmdReal(argv []string, opts CmdOpts) CmdResult {
|
|||||||
if opts.AsSudo && os.Geteuid() != 0 {
|
if opts.AsSudo && os.Geteuid() != 0 {
|
||||||
argv = append([]string{"sudo"}, argv...)
|
argv = append([]string{"sudo"}, argv...)
|
||||||
}
|
}
|
||||||
fmt.Printf(" $ %s\n", strings.Join(argv, " "))
|
if opts.Out != nil {
|
||||||
|
fmt.Fprintf(opts.Out, "$ %s\n", strings.Join(argv, " "))
|
||||||
|
opts.Capture = true
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" $ %s\n", strings.Join(argv, " "))
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), opts.Timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), opts.Timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -69,6 +84,10 @@ func runCmdReal(argv []string, opts CmdOpts) CmdResult {
|
|||||||
|
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
res := CmdResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
|
res := CmdResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
|
||||||
|
if opts.Out != nil {
|
||||||
|
writeToOut(opts.Out, res.Stdout)
|
||||||
|
writeToOut(opts.Out, res.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
warn(fmt.Sprintf("%q timed out after %s", argv[0], opts.Timeout))
|
warn(fmt.Sprintf("%q timed out after %s", argv[0], opts.Timeout))
|
||||||
@@ -96,7 +115,12 @@ func runShellReal(cmd string, opts CmdOpts) CmdResult {
|
|||||||
if opts.Timeout == 0 {
|
if opts.Timeout == 0 {
|
||||||
opts.Timeout = defaultSubprocessTimeout
|
opts.Timeout = defaultSubprocessTimeout
|
||||||
}
|
}
|
||||||
fmt.Printf(" $ %s\n", cmd)
|
if opts.Out != nil {
|
||||||
|
fmt.Fprintf(opts.Out, "$ %s\n", cmd)
|
||||||
|
opts.Capture = true
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" $ %s\n", cmd)
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), opts.Timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), opts.Timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -120,6 +144,10 @@ func runShellReal(cmd string, opts CmdOpts) CmdResult {
|
|||||||
|
|
||||||
err := c.Run()
|
err := c.Run()
|
||||||
res := CmdResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
|
res := CmdResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
|
||||||
|
if opts.Out != nil {
|
||||||
|
writeToOut(opts.Out, res.Stdout)
|
||||||
|
writeToOut(opts.Out, res.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
warn(fmt.Sprintf("shell command timed out after %s", opts.Timeout))
|
warn(fmt.Sprintf("shell command timed out after %s", opts.Timeout))
|
||||||
@@ -141,6 +169,19 @@ func runShellReal(cmd string, opts CmdOpts) CmdResult {
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeToOut writes data to w, appending a trailing newline if data is
|
||||||
|
// non-empty and doesn't already end with one. Used by runCmd / runShell to
|
||||||
|
// keep captured stdout/stderr neatly separated when routed to a task buffer.
|
||||||
|
func writeToOut(w io.Writer, data []byte) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
if data[len(data)-1] != '\n' {
|
||||||
|
_, _ = w.Write([]byte{'\n'})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// hasCmdReal is shutil.which() — returns true if name resolves on PATH.
|
// hasCmdReal is shutil.which() — returns true if name resolves on PATH.
|
||||||
func hasCmdReal(name string) bool {
|
func hasCmdReal(name string) bool {
|
||||||
_, err := exec.LookPath(name)
|
_, err := exec.LookPath(name)
|
||||||
|
|||||||
+13
@@ -23,6 +23,19 @@ func installFlatpakPackages(toInstall []string) {
|
|||||||
"https://dl.flathub.org/repo/flathub.flatpakrepo",
|
"https://dl.flathub.org/repo/flathub.flatpakrepo",
|
||||||
}, CmdOpts{AsSudo: true})
|
}, CmdOpts{AsSudo: true})
|
||||||
|
|
||||||
|
if len(toInstall) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single batched install — flatpak supports multiple refs per invocation
|
||||||
|
// and resolves them concurrently internally. Fall back to per-package
|
||||||
|
// installs on failure so callers see exactly which IDs broke.
|
||||||
|
argv := append([]string{"flatpak", "install", "--noninteractive", "flathub"}, toInstall...)
|
||||||
|
fmt.Printf("\n Installing %d Flatpak(s) in one batch ...\n", len(toInstall))
|
||||||
|
if runCmd(argv, CmdOpts{}).OK() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
warn("Batched flatpak install failed; retrying per-package to isolate failures ...")
|
||||||
for _, pkgID := range toInstall {
|
for _, pkgID := range toInstall {
|
||||||
fmt.Printf("\n Installing %s ...\n", pkgID)
|
fmt.Printf("\n Installing %s ...\n", pkgID)
|
||||||
res := runCmd([]string{"flatpak", "install", "--noninteractive", "flathub", pkgID}, CmdOpts{})
|
res := runCmd([]string{"flatpak", "install", "--noninteractive", "flathub", pkgID}, CmdOpts{})
|
||||||
|
|||||||
@@ -20,5 +20,6 @@ require (
|
|||||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
|
golang.org/x/term v0.43.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,5 +47,9 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
|||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
|
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -26,7 +26,15 @@ var (
|
|||||||
func logIssue(level, msg string) {
|
func logIssue(level, msg string) {
|
||||||
issuesMu.Lock()
|
issuesMu.Lock()
|
||||||
defer issuesMu.Unlock()
|
defer issuesMu.Unlock()
|
||||||
fmt.Fprintf(issueLogWriter, " [%s] %s\n", level, msg)
|
// Route the human-facing line through the active task's buffer when
|
||||||
|
// running inside a parallel worker, so concurrent warns/errLogs don't
|
||||||
|
// interleave on stdout. The structured issue (added to the slice below)
|
||||||
|
// still flows into the global issues log used by writeRunLog.
|
||||||
|
if t := currentTask(); t != nil {
|
||||||
|
t.Printf("[%s] %s\n", level, msg)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(issueLogWriter, " [%s] %s\n", level, msg)
|
||||||
|
}
|
||||||
issues = append(issues, fmt.Sprintf("[%s] %s", level, msg))
|
issues = append(issues, fmt.Sprintf("[%s] %s", level, msg))
|
||||||
if level == "ERROR" {
|
if level == "ERROR" {
|
||||||
errorCount++
|
errorCount++
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -104,19 +106,11 @@ func runMain(args []string) {
|
|||||||
|
|
||||||
doFlatpak := (*only == "" || *only == "flatpak") && *gui && !isMacOS
|
doFlatpak := (*only == "" || *only == "flatpak") && *gui && !isMacOS
|
||||||
|
|
||||||
var sysCheck systemCheckResult
|
sysCheck, flatCheck, custCheck := checkAllInParallel(
|
||||||
var flatCheck flatpakCheckResult
|
*only == "" || *only == "system", systemPkgs,
|
||||||
var custCheck customCheckResult
|
doFlatpak, flatpakPkgs,
|
||||||
|
*only == "" || *only == "custom", customPtrs,
|
||||||
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)
|
total := printCheckSummary(sysCheck, flatCheck, custCheck, *only)
|
||||||
|
|
||||||
@@ -133,6 +127,7 @@ func runMain(args []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
checkSudo()
|
checkSudo()
|
||||||
|
promptGitHubToken()
|
||||||
|
|
||||||
if *only == "" || *only == "system" {
|
if *only == "" || *only == "system" {
|
||||||
installSystemPackages(sysCheck.toInstallRegular, sysCheck.toInstallSpecial)
|
installSystemPackages(sysCheck.toInstallRegular, sysCheck.toInstallSpecial)
|
||||||
@@ -184,6 +179,39 @@ func runMain(args []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// promptGitHubToken asks the user if they want to supply a GitHub token
|
||||||
|
// after they've authenticated sudo. With a token, our HTTP-bound worker
|
||||||
|
// pool uncaps from the conservative 8-worker default up to runtime.NumCPU(),
|
||||||
|
// because authenticated GitHub requests get 5000/hour instead of the
|
||||||
|
// unauthenticated 60/hour. A token in the environment is honored without
|
||||||
|
// prompting. Token input is read with echo off via golang.org/x/term so it
|
||||||
|
// doesn't leak into terminal scrollback or recorded sessions.
|
||||||
|
func promptGitHubToken() {
|
||||||
|
if existing := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")); existing != "" {
|
||||||
|
githubTokenSet = true
|
||||||
|
fmt.Printf("[GitHub] GITHUB_TOKEN found in environment — HTTP workers uncapped to %d.\n", cpuWorkers())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !askYN("\n[GitHub] Provide a GitHub token to uncap HTTP workers from 8 to your CPU count? [y/N] ") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Print(" Paste token (input hidden): ")
|
||||||
|
tokenBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Println()
|
||||||
|
if err != nil {
|
||||||
|
warn(fmt.Sprintf("could not read token: %v — continuing without uncap", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(string(tokenBytes))
|
||||||
|
if token == "" {
|
||||||
|
fmt.Println(" No token provided — keeping the conservative HTTP worker cap.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
os.Setenv("GITHUB_TOKEN", token)
|
||||||
|
githubTokenSet = true
|
||||||
|
fmt.Printf(" Token accepted — HTTP workers uncapped to %d.\n", cpuWorkers())
|
||||||
|
}
|
||||||
|
|
||||||
func checkSudo() {
|
func checkSudo() {
|
||||||
if os.Geteuid() == 0 {
|
if os.Geteuid() == 0 {
|
||||||
if isMacOS {
|
if isMacOS {
|
||||||
|
|||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cpuWorkers returns the parallelism level for install/check work.
|
||||||
|
// Defaults to runtime.NumCPU(); overridable via BOOTSTRAP_PARALLELISM
|
||||||
|
// (e.g. for tests / constrained hosts) and clamped to >=1.
|
||||||
|
func cpuWorkers() int {
|
||||||
|
if v := os.Getenv("BOOTSTRAP_PARALLELISM"); v != "" {
|
||||||
|
var n int
|
||||||
|
_, _ = fmt.Sscanf(v, "%d", &n)
|
||||||
|
if n >= 1 {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n := runtime.NumCPU()
|
||||||
|
if n < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpWorkersCap is the polite ceiling for HTTP-bound concurrency when no
|
||||||
|
// GitHub token has been provided (GitHub anon rate-limits at 60/hour).
|
||||||
|
// Authenticated requests get 5000/hour so we lift the cap when a token is
|
||||||
|
// available — see githubTokenSet.
|
||||||
|
const httpWorkersCap = 8
|
||||||
|
|
||||||
|
var githubTokenSet bool
|
||||||
|
|
||||||
|
// httpWorkers caps cpuWorkers() to httpWorkersCap unless a GitHub token has
|
||||||
|
// been supplied (in which case we use the full processor count).
|
||||||
|
func httpWorkers() int {
|
||||||
|
n := cpuWorkers()
|
||||||
|
if githubTokenSet {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
if n > httpWorkersCap {
|
||||||
|
return httpWorkersCap
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// parallelDo runs fn(i, items[i]) over items with at most maxWorkers
|
||||||
|
// goroutines in flight. Returns once every task has finished. Order of
|
||||||
|
// completion is not guaranteed; fn is responsible for its own synchronization
|
||||||
|
// when writing shared state.
|
||||||
|
func parallelDo[T any](items []T, maxWorkers int, fn func(i int, item T)) {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if maxWorkers < 1 {
|
||||||
|
maxWorkers = 1
|
||||||
|
}
|
||||||
|
if maxWorkers > len(items) {
|
||||||
|
maxWorkers = len(items)
|
||||||
|
}
|
||||||
|
sem := make(chan struct{}, maxWorkers)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, item := range items {
|
||||||
|
wg.Add(1)
|
||||||
|
sem <- struct{}{}
|
||||||
|
go func(i int, item T) {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() { <-sem }()
|
||||||
|
fn(i, item)
|
||||||
|
}(i, item)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskOutput is the per-call sink for status text and subprocess output.
|
||||||
|
//
|
||||||
|
// Two modes:
|
||||||
|
//
|
||||||
|
// Sequential (label==""): Printf goes straight to os.Stdout, and Writer()
|
||||||
|
// returns nil so runCmd falls back to its default streamed-to-stdout mode.
|
||||||
|
// Behavior matches the pre-parallelism code exactly.
|
||||||
|
//
|
||||||
|
// Captured (label!=""): Printf and runCmd output both land in an internal
|
||||||
|
// buffer; Flush() prints the whole block at once with a " [label] " prefix
|
||||||
|
// on every line. Used by parallel install workers so concurrent output
|
||||||
|
// doesn't interleave.
|
||||||
|
type taskOutput struct {
|
||||||
|
label string
|
||||||
|
buf bytes.Buffer
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSerialOutput() *taskOutput { return &taskOutput{} }
|
||||||
|
func newCapturedOutput(label string) *taskOutput {
|
||||||
|
return &taskOutput{label: label}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Printf writes to the task's destination.
|
||||||
|
func (t *taskOutput) Printf(format string, args ...any) {
|
||||||
|
if t.label == "" {
|
||||||
|
fmt.Printf(format, args...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
fmt.Fprintf(&t.buf, format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Println writes a line to the task's destination.
|
||||||
|
func (t *taskOutput) Println(args ...any) {
|
||||||
|
if t.label == "" {
|
||||||
|
fmt.Println(args...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
fmt.Fprintln(&t.buf, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writer returns the io.Writer that runCmd/runShell should target via
|
||||||
|
// CmdOpts.Out. Returns nil in sequential mode (preserves streamed stdout).
|
||||||
|
func (t *taskOutput) Writer() io.Writer {
|
||||||
|
if t.label == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &lockingWriter{mu: &t.mu, w: &t.buf}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush emits the captured buffer to w with the task label prefixed onto
|
||||||
|
// every line. Idempotent and a no-op in sequential mode.
|
||||||
|
func (t *taskOutput) Flush(w io.Writer) {
|
||||||
|
if t.label == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
if t.buf.Len() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if w == nil {
|
||||||
|
w = os.Stdout
|
||||||
|
}
|
||||||
|
prefix := fmt.Sprintf(" [%s] ", t.label)
|
||||||
|
lines := bytes.Split(t.buf.Bytes(), []byte{'\n'})
|
||||||
|
for i, line := range lines {
|
||||||
|
if i == len(lines)-1 && len(line) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s%s\n", prefix, line)
|
||||||
|
}
|
||||||
|
t.buf.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
// lockingWriter is a thin io.Writer that holds the taskOutput mutex while
|
||||||
|
// writing, so runCmd / runShell can stream into the buffer concurrently with
|
||||||
|
// status Printf calls on the same task without corrupting the buffer.
|
||||||
|
type lockingWriter struct {
|
||||||
|
mu *sync.Mutex
|
||||||
|
w io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lockingWriter) Write(p []byte) (int, error) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
return l.w.Write(p)
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCpuWorkers(t *testing.T) {
|
||||||
|
t.Setenv("BOOTSTRAP_PARALLELISM", "4")
|
||||||
|
if n := cpuWorkers(); n != 4 {
|
||||||
|
t.Errorf("expected 4 workers via env, got %d", n)
|
||||||
|
}
|
||||||
|
t.Setenv("BOOTSTRAP_PARALLELISM", "")
|
||||||
|
if n := cpuWorkers(); n < 1 {
|
||||||
|
t.Errorf("expected at least 1 worker, got %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHttpWorkersRespectsCap(t *testing.T) {
|
||||||
|
t.Setenv("BOOTSTRAP_PARALLELISM", "32")
|
||||||
|
defer func() { githubTokenSet = false }()
|
||||||
|
|
||||||
|
githubTokenSet = false
|
||||||
|
if n := httpWorkers(); n != httpWorkersCap {
|
||||||
|
t.Errorf("expected http workers capped at %d without token, got %d", httpWorkersCap, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
githubTokenSet = true
|
||||||
|
if n := httpWorkers(); n != 32 {
|
||||||
|
t.Errorf("expected http workers uncapped to 32 with token, got %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParallelDoConcurrency(t *testing.T) {
|
||||||
|
const items = 16
|
||||||
|
var inFlight, peak int32
|
||||||
|
work := make([]int, items)
|
||||||
|
for i := range work {
|
||||||
|
work[i] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
parallelDo(work, 4, func(_ int, _ int) {
|
||||||
|
now := atomic.AddInt32(&inFlight, 1)
|
||||||
|
for {
|
||||||
|
cur := atomic.LoadInt32(&peak)
|
||||||
|
if now <= cur || atomic.CompareAndSwapInt32(&peak, cur, now) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// brief busy spin to keep multiple workers overlapping
|
||||||
|
for i := 0; i < 50000; i++ {
|
||||||
|
_ = i * i
|
||||||
|
}
|
||||||
|
atomic.AddInt32(&inFlight, -1)
|
||||||
|
})
|
||||||
|
if peak < 2 {
|
||||||
|
t.Errorf("expected at least 2 concurrent workers, observed peak %d", peak)
|
||||||
|
}
|
||||||
|
if peak > 4 {
|
||||||
|
t.Errorf("worker cap violated: peak %d > 4", peak)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParallelDoEmpty(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
parallelDo([]int{}, 4, func(_ int, _ int) { called = true })
|
||||||
|
if called {
|
||||||
|
t.Error("expected fn to not be invoked on empty input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParallelDoAllItemsProcessed(t *testing.T) {
|
||||||
|
items := []int{1, 2, 3, 4, 5, 6, 7, 8}
|
||||||
|
var sum int64
|
||||||
|
parallelDo(items, 3, func(_ int, v int) {
|
||||||
|
atomic.AddInt64(&sum, int64(v))
|
||||||
|
})
|
||||||
|
if sum != 36 {
|
||||||
|
t.Errorf("expected sum 36, got %d", sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskOutputSerial(t *testing.T) {
|
||||||
|
t.Cleanup(resetMocks)
|
||||||
|
tOut := newSerialOutput()
|
||||||
|
// Serial: writes go straight to stdout; Writer() returns nil.
|
||||||
|
if tOut.Writer() != nil {
|
||||||
|
t.Error("expected Writer() to be nil in serial mode")
|
||||||
|
}
|
||||||
|
// flushing serial mode is a no-op
|
||||||
|
var buf bytes.Buffer
|
||||||
|
tOut.Flush(&buf)
|
||||||
|
if buf.Len() != 0 {
|
||||||
|
t.Error("expected Flush to be no-op in serial mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskOutputCaptured(t *testing.T) {
|
||||||
|
tOut := newCapturedOutput("mypkg")
|
||||||
|
tOut.Printf("first %s\n", "line")
|
||||||
|
tOut.Println("second line")
|
||||||
|
tOut.Printf("third line")
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
tOut.Flush(&buf)
|
||||||
|
got := buf.String()
|
||||||
|
|
||||||
|
expected := " [mypkg] first line\n [mypkg] second line\n [mypkg] third line\n"
|
||||||
|
if got != expected {
|
||||||
|
t.Errorf("captured output mismatch:\nexpected:\n%q\ngot:\n%q", expected, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush is idempotent (second call writes nothing).
|
||||||
|
buf.Reset()
|
||||||
|
tOut.Flush(&buf)
|
||||||
|
if buf.Len() != 0 {
|
||||||
|
t.Errorf("expected second Flush to be empty, got %q", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithTaskOutputRoutesPrints(t *testing.T) {
|
||||||
|
tOut := newCapturedOutput("worker")
|
||||||
|
withTaskOutput(tOut, func() {
|
||||||
|
taskPrintf("hello %d\n", 7)
|
||||||
|
taskPrintln("world")
|
||||||
|
})
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
tOut.Flush(&buf)
|
||||||
|
got := buf.String()
|
||||||
|
if !strings.Contains(got, "[worker] hello 7") || !strings.Contains(got, "[worker] world") {
|
||||||
|
t.Errorf("expected routed output with prefix, got: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithTaskOutputUnsetsAfter(t *testing.T) {
|
||||||
|
tOut := newCapturedOutput("worker")
|
||||||
|
withTaskOutput(tOut, func() {
|
||||||
|
if currentTask() == nil {
|
||||||
|
t.Error("expected active task inside withTaskOutput")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if currentTask() != nil {
|
||||||
|
t.Error("expected no active task after withTaskOutput returns")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParallelTaskOutputIsolation(t *testing.T) {
|
||||||
|
// Each goroutine should see only its own task output, even though
|
||||||
|
// they all share package-global state.
|
||||||
|
const n = 8
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make([]string, n)
|
||||||
|
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
label := "g" + string(rune('a'+i))
|
||||||
|
tOut := newCapturedOutput(label)
|
||||||
|
withTaskOutput(tOut, func() {
|
||||||
|
taskPrintf("from %s\n", label)
|
||||||
|
})
|
||||||
|
var buf bytes.Buffer
|
||||||
|
tOut.Flush(&buf)
|
||||||
|
results[i] = buf.String()
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
for i, got := range results {
|
||||||
|
label := "g" + string(rune('a'+i))
|
||||||
|
want := " [" + label + "] from " + label + "\n"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("goroutine %d: expected %q, got %q", i, want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdOptsOutRoutesRunCmd(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
// Pick a command guaranteed to exist and produce output.
|
||||||
|
result := runCmdReal([]string{"echo", "hello-out"}, CmdOpts{Out: &buf})
|
||||||
|
if !result.OK() {
|
||||||
|
t.Fatalf("echo failed: %v", result.Err)
|
||||||
|
}
|
||||||
|
got := buf.String()
|
||||||
|
if !strings.Contains(got, "$ echo hello-out") {
|
||||||
|
t.Errorf("expected command echo in Out, got: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "hello-out") {
|
||||||
|
t.Errorf("expected stdout in Out, got: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdOptsOutRoutesRunShell(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
result := runShellReal("echo shell-out", CmdOpts{Out: &buf})
|
||||||
|
if !result.OK() {
|
||||||
|
t.Fatalf("shell failed: %v", result.Err)
|
||||||
|
}
|
||||||
|
got := buf.String()
|
||||||
|
if !strings.Contains(got, "$ echo shell-out") {
|
||||||
|
t.Errorf("expected command echo in Out, got: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "shell-out") {
|
||||||
|
t.Errorf("expected stdout in Out, got: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueLogRoutesViaTaskOutput(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
// Suppress fallback stdout for the non-task branch.
|
||||||
|
issueLogWriter = &bytes.Buffer{}
|
||||||
|
|
||||||
|
tOut := newCapturedOutput("isolated")
|
||||||
|
withTaskOutput(tOut, func() {
|
||||||
|
warn("a warning")
|
||||||
|
errLog("an error")
|
||||||
|
})
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
tOut.Flush(&buf)
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "[isolated] [WARN] a warning") {
|
||||||
|
t.Errorf("expected routed WARN line, got: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "[isolated] [ERROR] an error") {
|
||||||
|
t.Errorf("expected routed ERROR line, got: %q", out)
|
||||||
|
}
|
||||||
|
// Issues should still flow into the global issues slice.
|
||||||
|
if !hasErrors() {
|
||||||
|
t.Error("expected errLog to register a global error even when routed via task")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParallelPartitionOrdering(t *testing.T) {
|
||||||
|
items := []int{1, 2, 3, 4, 5, 6, 7, 8}
|
||||||
|
even, odd := parallelPartition(items, func(v int) bool { return v%2 == 0 })
|
||||||
|
|
||||||
|
wantEven := []int{2, 4, 6, 8}
|
||||||
|
wantOdd := []int{1, 3, 5, 7}
|
||||||
|
if !equalIntSlices(even, wantEven) {
|
||||||
|
t.Errorf("evens: want %v, got %v", wantEven, even)
|
||||||
|
}
|
||||||
|
if !equalIntSlices(odd, wantOdd) {
|
||||||
|
t.Errorf("odds: want %v, got %v", wantOdd, odd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalIntSlices(a, b []int) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoidUnique(t *testing.T) {
|
||||||
|
mainID := goid()
|
||||||
|
if mainID == 0 {
|
||||||
|
t.Error("goid returned 0 for main goroutine")
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
var childID uint64
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
childID = goid()
|
||||||
|
}()
|
||||||
|
wg.Wait()
|
||||||
|
if childID == 0 {
|
||||||
|
t.Error("goid returned 0 for child goroutine")
|
||||||
|
}
|
||||||
|
if childID == mainID {
|
||||||
|
t.Errorf("expected child goroutine id %d to differ from main id %d", childID, mainID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify BOOTSTRAP_PARALLELISM=0 falls back to NumCPU rather than 0 workers.
|
||||||
|
func TestCpuWorkersInvalidEnv(t *testing.T) {
|
||||||
|
t.Setenv("BOOTSTRAP_PARALLELISM", "0")
|
||||||
|
if n := cpuWorkers(); n < 1 {
|
||||||
|
t.Errorf("expected fallback to runtime.NumCPU for invalid env, got %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCpuWorkersBadEnv(t *testing.T) {
|
||||||
|
t.Setenv("BOOTSTRAP_PARALLELISM", "notanumber")
|
||||||
|
if n := cpuWorkers(); n < 1 {
|
||||||
|
t.Errorf("expected fallback for non-numeric env, got %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the parallelDo guard returns when len(items) == 0 even with
|
||||||
|
// maxWorkers larger than 1. Smoke test for the early return.
|
||||||
|
func TestParallelDoBigWorkersSmallInput(t *testing.T) {
|
||||||
|
items := []string{"only"}
|
||||||
|
called := 0
|
||||||
|
var mu sync.Mutex
|
||||||
|
parallelDo(items, 32, func(_ int, _ string) {
|
||||||
|
mu.Lock()
|
||||||
|
called++
|
||||||
|
mu.Unlock()
|
||||||
|
})
|
||||||
|
if called != 1 {
|
||||||
|
t.Errorf("expected exactly 1 invocation, got %d", called)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm runCmd inside withTaskOutput auto-routes via Out without callers
|
||||||
|
// having to set it explicitly — that's the contract that lets install
|
||||||
|
// handlers rely on taskOut().
|
||||||
|
func TestRunCmdInsideWithTaskOutput(t *testing.T) {
|
||||||
|
defer resetMocks()
|
||||||
|
tOut := newCapturedOutput("autoroute")
|
||||||
|
|
||||||
|
withTaskOutput(tOut, func() {
|
||||||
|
runCmdReal([]string{"echo", "auto"}, CmdOpts{Out: taskOut()})
|
||||||
|
})
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
tOut.Flush(&buf)
|
||||||
|
got := buf.String()
|
||||||
|
if !strings.Contains(got, "[autoroute] $ echo auto") {
|
||||||
|
t.Errorf("expected routed echo command, got: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "[autoroute] auto") {
|
||||||
|
t.Errorf("expected routed echo stdout, got: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity: when no token is in env and the user prompt is suppressed,
|
||||||
|
// httpWorkers stays capped. Just exercise the path; ensures no panic.
|
||||||
|
func TestPromptGitHubTokenNoOp(t *testing.T) {
|
||||||
|
defer func() { githubTokenSet = false }()
|
||||||
|
githubTokenSet = false
|
||||||
|
os.Unsetenv("GITHUB_TOKEN")
|
||||||
|
// We can't easily prompt in a test; just confirm cap is in effect.
|
||||||
|
n := httpWorkers()
|
||||||
|
if n > httpWorkersCap {
|
||||||
|
t.Errorf("expected http workers <= %d without token, got %d", httpWorkersCap, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,12 +17,12 @@ import (
|
|||||||
// ── pyenv / Python ──────────────────────────────────────────────────────
|
// ── pyenv / Python ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
func installPyenv() {
|
func installPyenv() {
|
||||||
fmt.Println(" Installing pyenv via curl ...")
|
taskPrintln(" Installing pyenv via curl ...")
|
||||||
if !runShell("curl https://pyenv.run | bash", CmdOpts{}).OK() {
|
if !runShell("curl https://pyenv.run | bash", CmdOpts{Out: taskOut()}).OK() {
|
||||||
errLog("pyenv installation failed")
|
errLog("pyenv installation failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Println(" pyenv installed to ~/.pyenv")
|
taskPrintln(" pyenv installed to ~/.pyenv")
|
||||||
}
|
}
|
||||||
|
|
||||||
func python3DecimalOK() bool {
|
func python3DecimalOK() bool {
|
||||||
@@ -46,6 +46,7 @@ func fixPython3Decimal() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func installPip() {
|
func installPip() {
|
||||||
|
out := taskOut()
|
||||||
if !hasCmd("python3") {
|
if !hasCmd("python3") {
|
||||||
errLog("python3 is not installed — cannot install pip")
|
errLog("python3 is not installed — cannot install pip")
|
||||||
return
|
return
|
||||||
@@ -53,7 +54,7 @@ func installPip() {
|
|||||||
if !python3DecimalOK() {
|
if !python3DecimalOK() {
|
||||||
warn("Python 3 _decimal C extension failed to import — attempting fix ...")
|
warn("Python 3 _decimal C extension failed to import — attempting fix ...")
|
||||||
if fixPython3Decimal() {
|
if fixPython3Decimal() {
|
||||||
fmt.Println(" Python 3 _decimal extension restored.")
|
taskPrintln(" Python 3 _decimal extension restored.")
|
||||||
} else {
|
} else {
|
||||||
errLog("Python 3 _decimal C extension could not be fixed. " +
|
errLog("Python 3 _decimal C extension could not be fixed. " +
|
||||||
"Run: sudo apt-get install python3-full (Debian/Ubuntu), " +
|
"Run: sudo apt-get install python3-full (Debian/Ubuntu), " +
|
||||||
@@ -63,19 +64,19 @@ func installPip() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println(" Bootstrapping pip via 'python3 -m ensurepip --upgrade' ...")
|
taskPrintln(" Bootstrapping pip via 'python3 -m ensurepip --upgrade' ...")
|
||||||
bootstrap := runCmd([]string{"python3", "-m", "ensurepip", "--upgrade"}, CmdOpts{AsSudo: true})
|
bootstrap := runCmd([]string{"python3", "-m", "ensurepip", "--upgrade"}, CmdOpts{AsSudo: true, Out: out})
|
||||||
if !bootstrap.OK() {
|
if !bootstrap.OK() {
|
||||||
switch pkgMgr {
|
switch pkgMgr {
|
||||||
case "apt-get":
|
case "apt-get":
|
||||||
warn("ensurepip unavailable in system Python — installing python3-pip via 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() {
|
if !runCmd([]string{"apt-get", "install", "-y", "python3-pip"}, CmdOpts{AsSudo: true, Out: out}).OK() {
|
||||||
errLog("python3-pip failed to install via apt-get — skipping pip bootstrap")
|
errLog("python3-pip failed to install via apt-get — skipping pip bootstrap")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "pacman":
|
case "pacman":
|
||||||
warn("ensurepip unavailable in system Python — installing python-pip via 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() {
|
if !runCmd([]string{"pacman", "-S", "--noconfirm", "--needed", "python-pip"}, CmdOpts{AsSudo: true, Out: out}).OK() {
|
||||||
errLog("python-pip failed to install via pacman — skipping pip bootstrap")
|
errLog("python-pip failed to install via pacman — skipping pip bootstrap")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -84,8 +85,8 @@ func installPip() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Println(" Upgrading pip to the latest version ...")
|
taskPrintln(" Upgrading pip to the latest version ...")
|
||||||
upgrade := runCmd([]string{"python3", "-m", "pip", "install", "--upgrade", "pip"}, CmdOpts{AsSudo: true})
|
upgrade := runCmd([]string{"python3", "-m", "pip", "install", "--upgrade", "pip"}, CmdOpts{AsSudo: true, Out: out})
|
||||||
if !upgrade.OK() {
|
if !upgrade.OK() {
|
||||||
warn("pip self-upgrade failed (likely PEP 668 externally-managed); ensurepip-provided pip remains")
|
warn("pip self-upgrade failed (likely PEP 668 externally-managed); ensurepip-provided pip remains")
|
||||||
}
|
}
|
||||||
@@ -212,12 +213,12 @@ func installNVM() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
installURL := fmt.Sprintf("https://raw.githubusercontent.com/nvm-sh/nvm/%s/install.sh", version)
|
installURL := fmt.Sprintf("https://raw.githubusercontent.com/nvm-sh/nvm/%s/install.sh", version)
|
||||||
fmt.Printf(" Installing NVM %s via curl ...\n", version)
|
taskPrintf(" Installing NVM %s via curl ...\n", version)
|
||||||
if !runShell(fmt.Sprintf("curl -o- %s | bash", installURL), CmdOpts{}).OK() {
|
if !runShell(fmt.Sprintf("curl -o- %s | bash", installURL), CmdOpts{Out: taskOut()}).OK() {
|
||||||
errLog("NVM installation failed")
|
errLog("NVM installation failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Printf(" NVM %s installed to ~/.nvm\n", version)
|
taskPrintf(" NVM %s installed to ~/.nvm\n", version)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureNodeLTS() {
|
func ensureNodeLTS() {
|
||||||
@@ -269,11 +270,11 @@ func installOhMyZsh() {
|
|||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
target := filepath.Join(home, ".oh-my-zsh")
|
target := filepath.Join(home, ".oh-my-zsh")
|
||||||
if _, err := osStat(target); err == nil {
|
if _, err := osStat(target); err == nil {
|
||||||
fmt.Printf(" oh-my-zsh already present at %s; updating theme only\n", target)
|
taskPrintf(" oh-my-zsh already present at %s; updating theme only\n", target)
|
||||||
} else {
|
} else {
|
||||||
fmt.Println(" Installing oh-my-zsh via the official installer ...")
|
taskPrintln(" Installing oh-my-zsh via the official installer ...")
|
||||||
installer := `sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended`
|
installer := `sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended`
|
||||||
if !runShell(installer, CmdOpts{}).OK() {
|
if !runShell(installer, CmdOpts{Out: taskOut()}).OK() {
|
||||||
errLog("oh-my-zsh installer failed")
|
errLog("oh-my-zsh installer failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -298,9 +299,9 @@ func installOhMyZsh() {
|
|||||||
errLog(fmt.Sprintf("could not write ~/.zshrc: %v", err))
|
errLog(fmt.Sprintf("could not write ~/.zshrc: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Println(` Set ZSH_THEME="gnzh" in ~/.zshrc`)
|
taskPrintln(` Set ZSH_THEME="gnzh" in ~/.zshrc`)
|
||||||
} else {
|
} else {
|
||||||
fmt.Println(` ~/.zshrc already has ZSH_THEME="gnzh"`)
|
taskPrintln(` ~/.zshrc already has ZSH_THEME="gnzh"`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,8 +473,8 @@ func askYN(prompt string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func installAgy() {
|
func installAgy() {
|
||||||
fmt.Println(" Installing agy via curl ...")
|
taskPrintln(" Installing agy via curl ...")
|
||||||
if !runShell("curl -fsSL https://antigravity.google/cli/install.sh | bash", CmdOpts{}).OK() {
|
if !runShell("curl -fsSL https://antigravity.google/cli/install.sh | bash", CmdOpts{Out: taskOut()}).OK() {
|
||||||
errLog("agy installation failed")
|
errLog("agy installation failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -492,26 +493,18 @@ func installNpmPackage(pkgName string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ensureNodeLTS()
|
ensureNodeLTS()
|
||||||
fmt.Printf(" Installing %s via pnpm ...\n", pkgName)
|
taskPrintf(" Installing %s via pnpm ...\n", pkgName)
|
||||||
cmd := fmt.Sprintf(`bash -c '%ssource ~/.nvm/nvm.sh && pnpm add -g %s'`, pnpmEnvPrefix(), pkgName)
|
cmd := fmt.Sprintf(`bash -c '%ssource ~/.nvm/nvm.sh && pnpm add -g %s'`, pnpmEnvPrefix(), pkgName)
|
||||||
if !runShell(cmd, CmdOpts{}).OK() {
|
if !runShell(cmd, CmdOpts{Out: taskOut()}).OK() {
|
||||||
errLog(fmt.Sprintf("%s installation failed", pkgName))
|
errLog(fmt.Sprintf("%s installation failed", pkgName))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func installPlaywright() {
|
// installPlaywrightBrowsers runs `pnpx playwright install` (with --with-deps
|
||||||
home, _ := os.UserHomeDir()
|
// on apt-get). Separated from the npm-side install so that installNpmToolsBatch
|
||||||
if _, err := osStat(filepath.Join(home, ".nvm")); err != nil {
|
// can do all `pnpm add -g` work in one call and then just provision browsers
|
||||||
errLog("NVM is not installed — cannot install playwright")
|
// once if playwright was in the batch.
|
||||||
return
|
func installPlaywrightBrowsers() {
|
||||||
}
|
|
||||||
ensureNodeLTS()
|
|
||||||
fmt.Println(" Installing playwright via pnpm ...")
|
|
||||||
addCmd := fmt.Sprintf(`bash -c '%ssource ~/.nvm/nvm.sh && pnpm add -g playwright'`, pnpmEnvPrefix())
|
|
||||||
if !runShell(addCmd, CmdOpts{}).OK() {
|
|
||||||
errLog("playwright installation failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
installCmd := "pnpx playwright install"
|
installCmd := "pnpx playwright install"
|
||||||
if pkgMgr == "apt-get" {
|
if pkgMgr == "apt-get" {
|
||||||
fmt.Println(" Installing Playwright browsers with dependencies ...")
|
fmt.Println(" Installing Playwright browsers with dependencies ...")
|
||||||
@@ -524,13 +517,29 @@ func installPlaywright() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func installPlaywright() {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if _, err := osStat(filepath.Join(home, ".nvm")); err != nil {
|
||||||
|
errLog("NVM is not installed — cannot install playwright")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureNodeLTS()
|
||||||
|
taskPrintln(" Installing playwright via pnpm ...")
|
||||||
|
addCmd := fmt.Sprintf(`bash -c '%ssource ~/.nvm/nvm.sh && pnpm add -g playwright'`, pnpmEnvPrefix())
|
||||||
|
if !runShell(addCmd, CmdOpts{Out: taskOut()}).OK() {
|
||||||
|
errLog("playwright installation failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
installPlaywrightBrowsers()
|
||||||
|
}
|
||||||
|
|
||||||
func installGHExtension(repo string) {
|
func installGHExtension(repo string) {
|
||||||
if !hasCmd("gh") {
|
if !hasCmd("gh") {
|
||||||
errLog("gh CLI is not installed — cannot install extension " + repo)
|
errLog("gh CLI is not installed — cannot install extension " + repo)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Printf(" Installing gh extension %s ...\n", repo)
|
taskPrintf(" Installing gh extension %s ...\n", repo)
|
||||||
if !runCmd([]string{"gh", "extension", "install", repo}, CmdOpts{}).OK() {
|
if !runCmd([]string{"gh", "extension", "install", repo}, CmdOpts{Out: taskOut()}).OK() {
|
||||||
errLog(fmt.Sprintf("gh extension install %s failed", repo))
|
errLog(fmt.Sprintf("gh extension install %s failed", repo))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -393,16 +393,92 @@ func pkgInstall(pkg string) CmdResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pkgInstallMany installs all named packages in a single invocation of the
|
||||||
|
// host package manager. This is dramatically faster than per-package install
|
||||||
|
// loops because apt/dnf/pacman/brew amortize metadata refresh, dependency
|
||||||
|
// resolution, and (most importantly) only acquire the install lock once.
|
||||||
|
//
|
||||||
|
// On batch failure we fall back to per-package installs so callers can
|
||||||
|
// continue to report which specific packages failed via errLog. brew is
|
||||||
|
// split into formula vs cask batches because `--cask` is mutually exclusive
|
||||||
|
// with formula installs in one invocation. We deliberately do NOT run brew
|
||||||
|
// invocations in parallel — brew acquires per-Cellar locks on transitive
|
||||||
|
// dependencies (cmake, ninja, libsodium, etc.), and concurrent invocations
|
||||||
|
// that both pull in the same dep abort with "process has already locked".
|
||||||
|
func pkgInstallMany(pkgs []string) (failed []string) {
|
||||||
|
if len(pkgs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if pkgMgr == "brew" {
|
||||||
|
return brewInstallMany(pkgs)
|
||||||
|
}
|
||||||
|
var argv []string
|
||||||
|
switch pkgMgr {
|
||||||
|
case "pacman":
|
||||||
|
argv = append([]string{"pacman", "-S", "--noconfirm", "--needed"}, pkgs...)
|
||||||
|
default:
|
||||||
|
argv = append([]string{pkgMgr, "install", "-y"}, pkgs...)
|
||||||
|
}
|
||||||
|
if runCmd(argv, CmdOpts{AsSudo: true}).OK() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Batch failed — retry per-package so we can report exactly which
|
||||||
|
// packages broke. Slower, but only happens on the error path.
|
||||||
|
warn(fmt.Sprintf("Batched install failed; retrying %d packages individually to isolate failures ...", len(pkgs)))
|
||||||
|
for _, p := range pkgs {
|
||||||
|
if !pkgInstall(p).OK() {
|
||||||
|
failed = append(failed, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failed
|
||||||
|
}
|
||||||
|
|
||||||
|
// brewInstallMany installs pkgs via brew, batching formulas and casks into
|
||||||
|
// two single invocations (`brew install f1 f2 …` and `brew install --cask
|
||||||
|
// c1 c2 …`). Brew resolves and parallelizes the internal dep graph itself,
|
||||||
|
// so a single batched call is both faster and lock-safe — multiple
|
||||||
|
// concurrent `brew install` processes deadlock on shared deps. On batch
|
||||||
|
// failure we retry per-package serially to identify which specific package
|
||||||
|
// broke.
|
||||||
|
func brewInstallMany(pkgs []string) (failed []string) {
|
||||||
|
var formulas, casks []string
|
||||||
|
for _, p := range pkgs {
|
||||||
|
if brewCasks[p] {
|
||||||
|
casks = append(casks, p)
|
||||||
|
} else {
|
||||||
|
formulas = append(formulas, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tryBatch := func(label string, names []string, extra ...string) (batchFailed []string) {
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
argv := append([]string{"brew", "install"}, extra...)
|
||||||
|
argv = append(argv, names...)
|
||||||
|
if runCmd(argv, CmdOpts{}).OK() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
warn(fmt.Sprintf("Batched brew %s install failed; retrying %d packages individually ...", label, len(names)))
|
||||||
|
for _, p := range names {
|
||||||
|
if !pkgInstall(p).OK() {
|
||||||
|
batchFailed = append(batchFailed, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return batchFailed
|
||||||
|
}
|
||||||
|
failed = append(failed, tryBatch("formula", formulas)...)
|
||||||
|
failed = append(failed, tryBatch("cask", casks, "--cask")...)
|
||||||
|
return failed
|
||||||
|
}
|
||||||
|
|
||||||
// installSystemPackages installs the regular + special package lists.
|
// installSystemPackages installs the regular + special package lists.
|
||||||
func installSystemPackages(regular, special []string) {
|
func installSystemPackages(regular, special []string) {
|
||||||
fmt.Println("\n=== System Packages ===")
|
fmt.Println("\n=== System Packages ===")
|
||||||
|
|
||||||
if pkgMgr == "brew" {
|
if pkgMgr == "brew" {
|
||||||
for _, pkg := range regular {
|
failed := pkgInstallMany(regular)
|
||||||
res := pkgInstall(pkg)
|
for _, p := range failed {
|
||||||
if !res.OK() {
|
errLog(fmt.Sprintf("System package failed to install: %s", p))
|
||||||
errLog(fmt.Sprintf("System package failed to install: %s", pkg))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// No special packages on macOS — brew covers all of them.
|
// No special packages on macOS — brew covers all of them.
|
||||||
return
|
return
|
||||||
@@ -420,11 +496,9 @@ func installSystemPackages(regular, special []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, pkg := range regular {
|
failed := pkgInstallMany(regular)
|
||||||
res := pkgInstall(pkg)
|
for _, p := range failed {
|
||||||
if !res.OK() {
|
errLog(fmt.Sprintf("System package failed to install: %s", p))
|
||||||
errLog(fmt.Sprintf("System package failed to install: %s", pkg))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(special) > 0 {
|
if len(special) > 0 {
|
||||||
|
|||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Goroutine-local task output routing.
|
||||||
|
//
|
||||||
|
// Why: install handlers in custom.go and post.go are deeply nested calls
|
||||||
|
// that use fmt.Printf/Println directly and pass CmdOpts to runCmd. To route
|
||||||
|
// their output into a per-task buffer (so parallel workers don't interleave
|
||||||
|
// on os.Stdout), we'd otherwise need to thread an io.Writer through every
|
||||||
|
// signature — ~30 call sites of churn including tests.
|
||||||
|
//
|
||||||
|
// Instead we keep a sync.Map keyed by goroutine id. The parallel orchestrator
|
||||||
|
// associates a taskOutput with its worker goroutine before invoking the
|
||||||
|
// handler; helpers below check the map and route output to the active task
|
||||||
|
// when present, falling back to direct stdout otherwise. Sequential callers
|
||||||
|
// observe no behavior change.
|
||||||
|
//
|
||||||
|
// goid() uses runtime.Stack — a small hack, but stable and idiomatic for
|
||||||
|
// goroutine-local state where context.Context threading would dwarf the
|
||||||
|
// surrounding work.
|
||||||
|
|
||||||
|
var activeTaskByGoroutine sync.Map // map[uint64]*taskOutput
|
||||||
|
|
||||||
|
func goid() uint64 {
|
||||||
|
var buf [64]byte
|
||||||
|
n := runtime.Stack(buf[:], false)
|
||||||
|
s := string(buf[:n])
|
||||||
|
s = strings.TrimPrefix(s, "goroutine ")
|
||||||
|
end := strings.IndexByte(s, ' ')
|
||||||
|
if end < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
id, _ := strconv.ParseUint(s[:end], 10, 64)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// withTaskOutput pins tOut to the current goroutine for the duration of fn,
|
||||||
|
// then unpins. Re-entrant calls overwrite the previous binding and restore
|
||||||
|
// it on return. A nil tOut is treated as "no binding" (sequential mode).
|
||||||
|
func withTaskOutput(tOut *taskOutput, fn func()) {
|
||||||
|
if tOut == nil {
|
||||||
|
fn()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := goid()
|
||||||
|
prev, hadPrev := activeTaskByGoroutine.Load(id)
|
||||||
|
activeTaskByGoroutine.Store(id, tOut)
|
||||||
|
defer func() {
|
||||||
|
if hadPrev {
|
||||||
|
activeTaskByGoroutine.Store(id, prev)
|
||||||
|
} else {
|
||||||
|
activeTaskByGoroutine.Delete(id)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentTask returns the taskOutput pinned to the current goroutine, or
|
||||||
|
// nil if none. Cheap enough to call per print (~microseconds).
|
||||||
|
func currentTask() *taskOutput {
|
||||||
|
v, ok := activeTaskByGoroutine.Load(goid())
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return v.(*taskOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskPrintf routes via the active task (if any) or directly to stdout.
|
||||||
|
func taskPrintf(format string, args ...any) {
|
||||||
|
if t := currentTask(); t != nil {
|
||||||
|
t.Printf(format, args...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskPrintln routes via the active task (if any) or directly to stdout.
|
||||||
|
func taskPrintln(args ...any) {
|
||||||
|
if t := currentTask(); t != nil {
|
||||||
|
t.Println(args...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskOut returns the io.Writer that runCmd / runShell should target via
|
||||||
|
// CmdOpts.Out for the active task. Returns nil when there is no active task,
|
||||||
|
// which preserves runCmd's default streamed-to-stdout behavior.
|
||||||
|
func taskOut() io.Writer {
|
||||||
|
if t := currentTask(); t != nil {
|
||||||
|
return t.Writer()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user