Compare commits

...
Author SHA1 Message Date
Jason Ross 5d8cd18317 Merge pull request #27 from JMR-dev/fix-install-behavior
Fix install behavior
2026-06-11 11:51:42 -05:00
JMR-dev d679049aca UID fix for tests 2026-06-11 11:40:11 -05:00
JMR-dev 1a3f4f2e44 unit test fix to mock successful package install return codes 2026-06-11 11:36:26 -05:00
JMR-dev 476607e108 add ubuntu to test matrix for local ai and test fixes 2026-06-11 11:27:14 -05:00
JMR-dev d19d7efefc fixed install behavior and added local ai configuration pathway 2026-06-11 11:17:56 -05:00
JMR-dev 79615dd529 ensure zsh is installed first 2026-06-11 08:19:31 -05:00
Jason Ross 5f64e2cae4 Merge pull request #26 from JMR-dev/feat-add-mdts
adding mdts
2026-06-04 18:04:11 -05:00
JMR-dev 24a50e73dd adding mdts 2026-06-04 18:02:43 -05:00
Jason Ross 07c212d962 Merge pull request #25 from JMR-dev/feat-add-aria2
adding aria2 for faster downloads
2026-06-04 17:42:05 -05:00
JMR-dev befcadd260 adding aria2 for faster downloads 2026-06-04 17:10:57 -05:00
Jason Ross 60cf195f45 Merge pull request #24 from JMR-dev/feat-add-libreoffice-extension
add auto save extension to LibreOffice
2026-06-01 11:40:00 -05:00
JMR-dev a5726a7a60 add auto save extension to LibreOffice 2026-06-01 11:09:38 -05:00
Jason Ross ac933f7c7d Merge pull request #23 from JMR-dev/feat-package-additions
package changes
2026-05-30 17:21:54 -05:00
JMR-dev 206b4cb999 fixed unit tests 2026-05-30 17:19:10 -05:00
JMR-dev 83c929c24b test and logic fixes 2026-05-30 17:08:14 -05:00
JMR-dev 430b018aa9 fixes for bashtop removal 2026-05-29 18:09:30 -05:00
JMR-dev c4f7f6d240 package changes 2026-05-29 17:59:11 -05:00
Jason Ross bc021018cd Merge pull request #22 from JMR-dev/fix-update-gha-versions
update GHA workflows
2026-05-27 18:09:25 -05:00
JMR-dev 031bc618ec update GHA workflows 2026-05-27 17:59:07 -05:00
Jason Ross a7047d5018 Merge pull request #21 from JMR-dev/feat-parallelize-installs
Feat parallelize installs
2026-05-27 17:50:45 -05:00
JMR-dev 8f7e592c72 fixed MacOS parallelism 2026-05-27 17:43:00 -05:00
JMR-dev de9d41494b fixing test coverage 2026-05-27 17:26:53 -05:00
JMR-dev 165f9dbe4f parallelized installs 2026-05-27 15:26:34 -05:00
Jason Ross 58bf0d4bbe Merge pull request #20 from JMR-dev/fix-README-update
updated readme
2026-05-26 15:29:26 -05:00
JMR-dev ad84585f72 updated readme 2026-05-26 15:28:01 -05:00
Jason Ross 95e905a8c5 Merge pull request #19 from JMR-dev/feat-add-repo-bootstrap-tool
added repo bootstrap tool
2026-05-26 15:20:28 -05:00
JMR-devandCopilot 60fd4846af Fix pnpm PATH: use /bin subdir and single-quote bash -c
Two bugs in pnpmEnvPrefix:
1. pnpm setup configures global-bin-dir as $PNPM_HOME/bin, not
   $PNPM_HOME. We were only prepending $PNPM_HOME to PATH, so pnpm
   still complained 'configured global bin directory ... is not in
   PATH' and refused to install.
2. The bash -c command was wrapped in double quotes, which caused the
   OUTER sh to expand $PNPM_HOME, $PATH, $HOME before bash ever
   saw them. PNPM_HOME was empty in the outer shell, so the resulting
   PATH was ':$PATH'.

Fix: wrap bash -c argument in single quotes so the inner bash does
all variable expansion; prepend both $PNPM_HOME/bin and $PNPM_HOME
to PATH for safety.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-26 15:09:04 -05:00
JMR-devandCopilot de7f71fea6 Suppress intentional errLog output during tests
Tests that exercise error paths in errLog/warn were causing
GitHub Actions to annotate test output with 'Error: R] ...'
(GHA eats '[ERRO' from '[ERROR]' lines and surfaces the rest as
workflow errors), making real failures hard to distinguish from
expected behavior.

- issues.go: route logIssue output through issueLogWriter (io.Writer,
  defaults to os.Stdout). errorCount/issues/notices recording is
  unchanged so tests still assert via errorCount.
- testmain_test.go: TestMain sets issueLogWriter = io.Discard for
  the whole test binary.
- issues_test.go: TestIssuesLogging temporarily redirects
  issueLogWriter to a bytes.Buffer (replacing os.Pipe stdout
  capture).

go test -v ./... now contains zero stray '[ERROR]' lines; real
test failures remain visible and 'go test' still exits non-zero
on failure, failing the workflow.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-26 14:55:51 -05:00
JMR-devandCopilot aab9db9545 Show per-test output in unit test workflow
Add -v flag to 'go test' so each TestXxx line is printed in CI logs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-26 14:47:47 -05:00
JMR-devandCopilot e14347fd31 Fix CI: pnpm PATH, Obsidian arch matcher, Arch pkg names, Fedora lazygit COPR
- post.go: export PNPM_HOME+PATH before pnpm add -g so global bins
  install correctly in the non-interactive shell. Applied to
  installNpmPackage and installPlaywright (incl. browser install).
- system.go: treat token-less Obsidian AppImage as x86_64 (upstream
  publishes "Obsidian-X.Y.Z.AppImage" with no arch suffix).
- pkgmgr.go: docker-ce-rootless-extras and vagrant are AUR-only on
  Arch → skipOverride; pipx → python-pipx on pacman.
- repos.go: new setupLazygitCoprRepo enables copr.fedorainfracloud.org/
  dejan/lazygit on Fedora; wired into repoGroups so lazygit installs.
- Tests: PNPM_HOME export (Linux+macOS), token-less Obsidian asset,
  pacman pkg overrides, COPR repo setup, updated repoGroups count.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-26 14:43:44 -05:00
JMR-dev 41a6f74ce1 add fixes to integration tests 2026-05-26 14:17:04 -05:00
JMR-dev abcb973208 add unit tests to ci 2026-05-26 13:53:09 -05:00
JMR-devandCopilot 76a44c0999 Add --gui flag to integration tests to exercise GUI package install
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-26 13:48:42 -05:00
JMR-devandCopilot 3e27e54318 Run full bootstrap in integration tests; fix pnpm setup shell detection
- Integration tests now run the full bootstrap (system + flatpak + custom +
  AI + VM packages) instead of '--only custom --no-vm --no-ai', so that
  custom-package prerequisites (zsh, gh, etc.) installed via SystemPackages
  are actually available.
- Fix 'pnpm setup' failing with ERR_PNPM_UNKNOWN_SHELL in CI containers
  by exporting SHELL=/bin/bash before invoking it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-26 13:47:32 -05:00
JMR-dev ad2976842f Fix integration tests 2026-05-26 13:38:52 -05:00
JMR-devandCopilot c3ddcb336a Forward GITHUB_TOKEN into Linux Dagger containers for API auth
Add env: GITHUB_TOKEN to the Linux workflow step, then read it in
ci/main.go and inject it into each test container via WithSecretVariable
(for both GITHUB_TOKEN and GH_TOKEN). This prevents 403 rate-limit
errors on GitHub API calls (neovim/nvm releases) and authenticates
gh CLI for gh extension install inside the containers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-26 13:37:48 -05:00
JMR-devandCopilot a2f36665a3 Pass GITHUB_TOKEN to macOS CI step to fix 403 rate-limit errors
Unauthenticated GitHub API calls share the runner IP (60 req/hour limit).
net.go already uses GITHUB_TOKEN as a Bearer token when present.
gh CLI also needs GH_TOKEN to authenticate for gh extension install.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-26 13:36:22 -05:00
JMR-devandCopilot 9093aedb66 Exit non-zero when any errors are logged during bootstrap run
Track error count in issues.go alongside the existing issues slice.
Add hasErrors() helper. In main.go, call osExit(1) after writeRunLog()
if any [ERROR] entries were recorded, so CI steps correctly fail when
errors occur (e.g. GitHub API 403 rate-limit hits in the macOS job).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-26 13:35:05 -05:00
JMR-dev 4d99bba06f added repo bootstrap tool 2026-05-26 13:21:31 -05:00
Jason Ross ee95457e7d Merge pull request #18 from JMR-dev/fix-add-unit-testing
added unit testing
2026-05-26 09:36:50 -05:00
JMR-dev 669565bb87 added unit testing 2026-05-26 09:36:05 -05:00
Jason Ross 39c23595ce Merge pull request #17 from JMR-dev/feat-add-AI-cli-packages
updates to add AI packages and an opt out of AI flag
2026-05-25 12:11:32 -05:00
JMR-dev 8f11c8d68b updates to add AI packages and an opt out of AI flag 2026-05-25 12:07:14 -05:00
Jason Ross d9e905ac20 Merge pull request #16 from JMR-dev/fix-gha-workflow
Workflow fix to git sha
2026-05-23 17:58:43 -05:00
JMR-dev 14bf9f3e56 Workflow fix to git sha 2026-05-23 17:57:50 -05:00
48 changed files with 11747 additions and 339 deletions
+82
View File
@@ -0,0 +1,82 @@
name: Integration Tests
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
BOOTSTRAP_CI: 'true'
on:
push:
branches:
- main
- dev
pull_request:
branches:
- main
- dev
jobs:
test-linux:
name: Linux Integration - ${{ matrix.os }}
runs-on: ubuntu-latest
strategy:
matrix:
os: [debian, arch, fedora, ubuntu]
fail-fast: false
steps:
- name: Checkout Code
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.25.x'
- name: Run Dagger Integration Pipeline
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
go run ci/main.go --os ${{ matrix.os }}
test-mac:
name: macOS Integration
runs-on: macos-latest
steps:
- name: Checkout Code
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.25.x'
- name: Build bootstrap binary
run: |
go build -o bootstrap_environment .
- name: Pre-create ~/.pyenv directory
run: |
mkdir -p ~/.pyenv
- name: Run bootstrap tool
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo y | ./bootstrap_environment --gui
conclusion:
name: Final Status Check
needs: [test-linux, test-mac]
runs-on: ubuntu-latest
if: always()
steps:
- name: Check dependency jobs
run: |
# Check the results of matrix and mac jobs
# GHA will fail the workflow if this step exits with error
if [ "${{ needs.test-linux.result }}" != "success" ] || [ "${{ needs.test-mac.result }}" != "success" ]; then
echo "One or more integration checks failed!"
exit 1
fi
echo "All integration checks passed!"
+65
View File
@@ -0,0 +1,65 @@
name: macOS Manual Integration
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
BOOTSTRAP_CI: 'true'
on:
workflow_dispatch:
inputs:
branch:
description: 'Branch to run against'
required: true
default: 'main'
type: choice
options:
- main
- dev
- fix-add-unit-testing
workflow_definition:
description: 'Workflow Run Configuration'
required: true
default: 'custom-only'
type: choice
options:
- 'custom-only'
- 'system-only'
- 'full-setup'
jobs:
test-mac-manual:
name: macOS Manual Run
runs-on: macos-latest
steps:
- name: Checkout Code
uses: actions/checkout@v6
with:
ref: ${{ inputs.branch }}
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.25.x'
- name: Build bootstrap binary
run: |
go build -o bootstrap_environment .
- name: Pre-create ~/.pyenv directory
run: |
mkdir -p ~/.pyenv
- name: Run bootstrap tool (custom-only)
if: ${{ inputs.workflow_definition == 'custom-only' }}
run: |
echo y | ./bootstrap_environment --only custom --no-vm --no-ai
- name: Run bootstrap tool (system-only)
if: ${{ inputs.workflow_definition == 'system-only' }}
run: |
echo y | ./bootstrap_environment --only system --no-vm --no-ai
- name: Run bootstrap tool (full-setup)
if: ${{ inputs.workflow_definition == 'full-setup' }}
run: |
echo y | ./bootstrap_environment --no-vm --no-ai
+78 -11
View File
@@ -1,5 +1,8 @@
name: Release
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
on:
push:
tags:
@@ -7,7 +10,7 @@ on:
workflow_dispatch:
inputs:
tag:
description: 'Tag to release (e.g. v0.1.0). The tag must already exist.'
description: 'Tag to release (e.g. v0.1.0).'
required: true
type: string
@@ -15,47 +18,111 @@ permissions:
contents: write
jobs:
release:
resolve:
runs-on: ubuntu-latest
outputs:
ref: ${{ steps.ref.outputs.ref }}
tag: ${{ steps.ref.outputs.tag }}
steps:
- name: Resolve ref
id: ref
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "ref=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
echo "ref=${{ github.ref }}" >> "$GITHUB_OUTPUT"
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
else
echo "ref=${{ github.ref }}" >> "$GITHUB_OUTPUT"
echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
fi
build:
name: Build ${{ matrix.suffix }}
needs: resolve
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
suffix: linux-amd64
- goos: linux
goarch: arm64
suffix: linux-arm64
- goos: linux
goarch: arm
goarm: '6'
suffix: linux-armv6
- goos: darwin
goarch: amd64
suffix: darwin-amd64
- goos: darwin
goarch: arm64
suffix: darwin-arm64
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: ${{ steps.ref.outputs.ref }}
ref: ${{ needs.resolve.outputs.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- name: Build all targets
run: make build-all
- name: Build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOARM: ${{ matrix.goarm }}
CGO_ENABLED: '0'
run: |
mkdir -p dist
out="dist/bootstrap_environment-${{ matrix.suffix }}"
echo "==> Building $out (GOOS=$GOOS GOARCH=$GOARCH GOARM=${GOARM:-n/a})"
go build -trimpath -ldflags="-s -w" -o "$out" .
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: bootstrap_environment-${{ matrix.suffix }}
path: dist/bootstrap_environment-${{ matrix.suffix }}
if-no-files-found: error
retention-days: 1
release:
needs: [resolve, build]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve.outputs.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Download build artifacts
uses: actions/download-artifact@v8
with:
path: dist
pattern: bootstrap_environment-*
merge-multiple: true
- name: Generate SHA256SUMS
working-directory: dist
run: |
ls -l
sha256sum bootstrap_environment-* > SHA256SUMS
cat SHA256SUMS
- name: Create release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ steps.ref.outputs.tag }}
name: ${{ steps.ref.outputs.tag }}
tag_name: ${{ needs.resolve.outputs.tag }}
name: ${{ needs.resolve.outputs.tag }}
target_commitish: ${{ github.sha }}
generate_release_notes: true
fail_on_unmatched_files: true
files: |
+30
View File
@@ -0,0 +1,30 @@
name: Unit Tests
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
on:
pull_request:
branches:
- main
- dev
jobs:
unit-test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.25.x'
- name: Run Unit Tests
run: go test -v ./...
- name: Vet
run: go vet ./...
+15 -5
View File
@@ -8,6 +8,7 @@ GOFLAGS := -trimpath -ldflags="-s -w"
TARGETS := \
linux/amd64 \
linux/arm64 \
linux/arm/6 \
darwin/amd64 \
darwin/arm64
@@ -18,13 +19,22 @@ all: build
build:
go build $(GOFLAGS) -o $(BINARY) $(PKG)
# Cross-compile native binaries for each supported (OS, arch) pair into dist/.
# Cross-compile native binaries for each supported (OS, arch[, GOARM]) triple
# into dist/. Targets formatted as "os/arch" produce "$(BINARY)-os-arch";
# "os/arm/N" produces "$(BINARY)-os-armvN" with GOARM=N.
build-all: $(DIST)
@for t in $(TARGETS); do \
os=$${t%/*}; arch=$${t#*/}; \
out=$(DIST)/$(BINARY)-$$os-$$arch; \
echo "==> $$os/$$arch -> $$out"; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch \
os=$$(echo $$t | cut -d/ -f1); \
arch=$$(echo $$t | cut -d/ -f2); \
goarm=$$(echo $$t | cut -s -d/ -f3); \
if [ -n "$$goarm" ]; then \
suffix=$$arch"v"$$goarm; \
else \
suffix=$$arch; \
fi; \
out=$(DIST)/$(BINARY)-$$os-$$suffix; \
echo "==> $$os/$$arch$${goarm:+ GOARM=$$goarm} -> $$out"; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch GOARM=$$goarm \
go build $(GOFLAGS) -o $$out $(PKG) || exit 1; \
done
+76 -17
View File
@@ -1,15 +1,37 @@
# bootstrap_dev_env
A Go-based bootstrap tool that installs a development environment across
A Go-based bootstrap tool that provisions a development environment across
macOS, Debian/Ubuntu, RHEL/Fedora, and Arch Linux on `x86_64` and `aarch64`.
It installs system packages, optional Flatpak GUI apps, and a set of custom
third-party tools (Go, Neovim, Zig, NVM, pyenv, oh-my-zsh, Firecracker on
Linux).
It installs:
- **System packages** via `dnf`, `apt-get`, `pacman`, or `brew` (macOS)
- **Flatpak GUI apps** from Flathub (Linux only, opt-in with `--gui`)
- **Custom third-party tools** fetched and verified directly:
Go, Neovim, Zig, Firecracker (Linux), NVM, pyenv, pip, oh-my-zsh,
agy, claude, codex, copilot, playwright, and the `gh-repo-bootstrap` extension
## Supported platforms
| OS | Architecture | Package manager |
|---|---|---|
| Linux (Debian/Ubuntu) | x86_64 | apt-get |
| Linux (RHEL/Fedora) | x86_64 | dnf |
| Linux (Arch) | x86_64 | pacman |
| macOS | arm64 | brew |
> **Note:** Linux arm64 (`aarch64`) binaries are cross-compiled but not yet tested in CI.
## CI
| Workflow | Trigger |
|---|---|
| **Unit Tests** — `go test -v ./...` + `go vet ./...` | Pull requests to `main` / `dev` |
| **Integration Tests** — full run on Debian, Arch, Fedora, Ubuntu (Dagger) and macOS (native) | Push + pull requests to `main` / `dev` |
## Install
Download the native binary for your platform from a release, or build from
source:
Build from source:
```shell
git clone https://github.com/JMR-dev/bootstrap_dev_env.git
@@ -17,28 +39,65 @@ cd bootstrap_dev_env
make build # builds ./bootstrap_environment for the host
```
To produce native binaries for all four supported targets at once:
Cross-compile all supported targets at once:
```shell
make build-all # writes dist/bootstrap_environment-{linux,darwin}-{amd64,arm64}
make build-all # writes dist/bootstrap_environment-{linux,darwin}-{amd64,arm64,...}
```
Or download a pre-built binary from the [Releases](https://github.com/JMR-dev/bootstrap_dev_env/releases) page.
## Usage
```shell
# Linux (do NOT use sudo on macOS — Homebrew refuses to run as root)
sudo ./bootstrap_environment [--only system|flatpak|custom] [--gui]
sudo ./bootstrap_environment [flags]
# macOS
./bootstrap_environment [--only system|custom] [--gui] [--no-vm]
./bootstrap_environment [flags]
```
Flags:
### Flags
- `--only` — restrict to one section (`system`, `flatpak`, or `custom`).
- `--gui` — include GUI applications and the Flatpak section. Default is
headless: both are skipped.
- `--no-vm` — macOS only: skip provisioning the Fedora-on-QEMU/VirtualBox VM
that backs the `firecracker()` zsh wrapper.
| Flag | Description |
|---|---|
| `--only system\|flatpak\|custom` | Restrict to a single section |
| `--gui` | Include GUI applications and the Flatpak section (default: headless — both skipped) |
| `--no-vm` | macOS only: skip provisioning the Fedora-on-QEMU VM that backs the `firecracker()` zsh wrapper |
| `--no-ai` | Skip AI/LLM CLI tools (agy, claude, codex, copilot) |
### Example
```shell
# Full headless install (typical CI / server)
sudo ./bootstrap_environment
# Desktop workstation — include GUI apps and Flatpaks
sudo ./bootstrap_environment --gui
# Install only system packages, skipping AI tools
sudo ./bootstrap_environment --only system --no-ai
# macOS, no VM provisioning
./bootstrap_environment --gui --no-vm
```
## Configuration
Package lists live in `packages.go`. Edit the `SystemPackages`,
`FlatpakPackages`, or `customPackages()` slices and rebuild.
Platform-specific name mappings (e.g. `docker-ce-rootless-extras` →
skipped on Arch, `ffmpeg-free` → `ffmpeg` on apt-get) live in the
`packageOverrides` map in `pkgmgr.go`. Third-party repository setup
(Docker, GitHub CLI, Temurin, lazygit COPR, etc.) lives in `repos.go`.
## Development
```shell
make test # go test ./...
make vet # go vet ./...
make fmt # gofmt -w .
make build # build host binary
```
Package lists live in `packages.go`. Edit and rebuild.
+533
View File
@@ -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
}
+75 -28
View File
@@ -3,6 +3,7 @@ package main
import (
"fmt"
"strings"
"sync"
)
type systemCheckResult struct {
@@ -33,6 +34,28 @@ type customStatus struct {
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 {
overrides := packageOverrides[pkgMgr]
resolved, skipped := resolveSystemPkgs(names)
@@ -54,22 +77,8 @@ func checkSystemPackages(names []string) systemCheckResult {
}
}
var toR, alreadyR []string
for _, p := range regular {
if isSystemPkgInstalled(p) {
alreadyR = append(alreadyR, p)
} else {
toR = append(toR, p)
}
}
var toS, alreadyS []string
for _, p := range special {
if isSpecialPkgInstalled(p) {
alreadyS = append(alreadyS, p)
} else {
toS = append(toS, p)
}
}
alreadyR, toR := parallelPartition(regular, isSystemPkgInstalled)
alreadyS, toS := parallelPartition(special, isSpecialPkgInstalled)
return systemCheckResult{
toInstallRegular: toR,
toInstallSpecial: toS,
@@ -80,24 +89,25 @@ func checkSystemPackages(names []string) systemCheckResult {
}
func checkFlatpakPackages(ids []string) flatpakCheckResult {
var to, already []string
for _, p := range ids {
if isFlatpakInstalled(p) {
already = append(already, p)
} else {
to = append(to, p)
}
}
already, to := parallelPartition(ids, isFlatpakInstalled)
return flatpakCheckResult{toInstall: to, alreadyInstalled: already}
}
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 already []customStatus
for _, p := range pkgs {
installed, path := isCustomPkgInstalled(p)
if installed {
already = append(already, customStatus{pkg: p, path: path})
for i, p := range pkgs {
if results[i].installed {
already = append(already, customStatus{pkg: p, path: results[i].path})
} else {
to = append(to, p)
}
@@ -105,6 +115,43 @@ func checkCustomPackages(pkgs []*CustomPackage) customCheckResult {
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 {
if len(items) <= limit {
return strings.Join(items, " ")
Executable
BIN
View File
Binary file not shown.
+171
View File
@@ -0,0 +1,171 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"strings"
"dagger.io/dagger"
"golang.org/x/sync/errgroup"
)
func main() {
osFlag := flag.String("os", "all", "OS to test (debian, arch, fedora, ubuntu, or all)")
flag.Parse()
ctx := context.Background()
// Initialize Dagger Client
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr))
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to connect to Dagger: %v\n", err)
os.Exit(1)
}
defer client.Close()
// Get reference to the project source directory
src := client.Host().Directory(".")
// Build the bootstrapping binary inside Go container
fmt.Println("Building bootstrap_environment binary for Linux...")
builder := client.Container().
From("golang:1.25").
WithMountedDirectory("/src", src).
WithWorkdir("/src").
WithExec([]string{"go", "build", "-buildvcs=false", "-o", "bootstrap_environment", "."})
binaryFile := builder.File("bootstrap_environment")
// Target OS list
var targets []string
switch strings.ToLower(*osFlag) {
case "debian":
targets = []string{"debian:latest"}
case "arch":
targets = []string{"archlinux:latest"}
case "fedora":
targets = []string{"fedora:latest"}
case "ubuntu":
targets = []string{"ubuntu:latest"}
case "all":
targets = []string{"debian:latest", "archlinux:latest", "fedora:latest", "ubuntu:latest"}
default:
fmt.Fprintf(os.Stderr, "Unsupported OS: %s. Supported: debian, arch, fedora, ubuntu, all\n", *osFlag)
os.Exit(1)
}
g, ctx := errgroup.WithContext(ctx)
for _, target := range targets {
target := target // capture loop variable
g.Go(func() error {
fmt.Printf("=== Starting integration test on target OS: %s ===\n", target)
// 1. Prepare target container base and setup script based on OS distro
var testContainer *dagger.Container
if strings.Contains(target, "debian") || strings.Contains(target, "ubuntu") {
testContainer = client.Container().
From(target).
WithExec([]string{"apt-get", "update"}).
WithExec([]string{"apt-get", "install", "-y", "sudo", "curl", "git", "wget", "tar", "unzip", "xz-utils", "make", "python3", "which", "zstd"})
} else if strings.Contains(target, "fedora") {
testContainer = client.Container().
From(target).
WithExec([]string{"dnf", "install", "-y", "sudo", "curl", "git", "wget", "tar", "unzip", "xz", "make", "python3", "which", "zstd"})
} else if strings.Contains(target, "archlinux") {
testContainer = client.Container().
From(target).
WithExec([]string{"pacman", "-Sy", "--noconfirm", "sudo", "curl", "git", "wget", "tar", "unzip", "xz", "make", "python", "which", "zstd"})
} else {
return fmt.Errorf("unsupported target OS: %s", target)
}
// Mock sync to prevent hangs during package updates/update-shells on overlayfs/FUSE
testContainer = testContainer.
WithExec([]string{"ln", "-sf", "/bin/true", "/bin/sync"}).
WithExec([]string{"ln", "-sf", "/bin/true", "/usr/bin/sync"})
// 2. Pre-create the ~/.pyenv directory to skip python source compilation in integration test (saves ~10 minutes)
testContainer = testContainer.WithExec([]string{"mkdir", "-p", "/root/.pyenv"})
// 3. Mount the built binary
testContainer = testContainer.
WithFile("/usr/local/bin/bootstrap_environment", binaryFile).
WithWorkdir("/tmp")
if val := os.Getenv("BOOTSTRAP_CI"); val != "" {
testContainer = testContainer.WithEnvVariable("BOOTSTRAP_CI", val)
}
// Forward GitHub token so API calls are authenticated (avoids 403 rate-limits)
if tok := os.Getenv("GITHUB_TOKEN"); tok != "" {
secret := client.SetSecret("github-token", tok)
testContainer = testContainer.
WithSecretVariable("GITHUB_TOKEN", secret).
WithSecretVariable("GH_TOKEN", secret)
}
// 4. Run bootstrap binary.
// We run it twice:
// Run 1: Step 1 (zsh, git, curl, oh-my-zsh) which configures the default shell and exits.
// Run 2: Step 2 (the rest of the packages) once the shell configuration is complete.
// We pipe 'y' to satisfy the "Proceed? [y/N]" prompt.
var bootstrapCmd []string
if strings.Contains(target, "ubuntu") {
bootstrapCmd = []string{"sh", "-c", "echo y | bootstrap_environment --local-ai"}
} else {
bootstrapCmd = []string{"sh", "-c", "echo y | bootstrap_environment --gui"}
}
fmt.Printf("[%s] Executing bootstrap_environment (Run 1: ZSH/Step 1)...\n", target)
testContainer = testContainer.WithExec(bootstrapCmd)
fmt.Printf("[%s] Executing bootstrap_environment (Run 2: Step 2)...\n", target)
testContainer = testContainer.WithExec(bootstrapCmd)
// 5. Verify all installed custom packages return a path and zero exit code from version command
fmt.Printf("[%s] Verifying package installations on PATH and running version checks...\n", target)
var verifyCmd []string
if strings.Contains(target, "ubuntu") {
verifyCmd = []string{
"sh", "-c",
"set -e -x; " +
"export PATH=$PATH:/usr/local/bin:/root/.local/bin; " +
"which nvim && nvim --version && " +
"which firecracker && firecracker --version && " +
"which ollama && ollama --version && " +
"which hf && hf --help",
}
} else {
verifyCmd = []string{
"sh", "-c",
"set -e -x; " +
"export PATH=$PATH:/usr/local/go/bin:/usr/local/bin; " +
"which go && go version && " +
"which nvim && nvim --version && " +
"which zig && zig version && " +
"which firecracker && firecracker --version",
}
}
verifyOutput, err := testContainer.WithExec(verifyCmd).Stdout(ctx)
if err != nil {
// To see the stdout/stderr of the failing command, we can try to extract it from dagger's ExecError
return fmt.Errorf("verification failed on %s: %v", target, err)
}
fmt.Printf("[%s] Verification Output:\n%s\n", target, verifyOutput)
fmt.Printf("--- PASS: Integration test on %s completed successfully ---\n", target)
return nil
})
}
if err := g.Wait(); err != nil {
fmt.Fprintf(os.Stderr, "One or more integration tests failed: %v\n", err)
os.Exit(1)
}
fmt.Println("\nAll parallel integration tests passed successfully!")
}
+37
View File
@@ -0,0 +1,37 @@
package main
import (
"io"
"os"
)
var (
// Exec redirects
runCmd = runCmdReal
runShell = runShellReal
hasCmd = hasCmdReal
probe = probeReal
// Net redirects
download = downloadReal
fetchJSON = fetchJSONReal
fetchText = fetchTextReal
// OS redirects
osStat = os.Stat
osReadFile = os.ReadFile
osWriteFile = os.WriteFile
osMkdirAll = os.MkdirAll
osRemove = os.Remove
osRemoveAll = os.RemoveAll
osRename = os.Rename
osExit = os.Exit
stdin io.Reader = os.Stdin
// Filesystem paths
osReleasePath = "/etc/os-release"
passwdPath = "/etc/passwd"
// Testing override
disableProgressTracking = false
)
+1168
View File
File diff suppressed because it is too large Load Diff
+678
View File
@@ -0,0 +1,678 @@
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 }
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
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 }
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
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
fetchJSON = func(_ string, _ any) bool { return false }
download = func(_, dest string) bool { return true }
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")
}
}
+401
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+379 -112
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
@@ -52,8 +53,16 @@ var defaultInstallPaths = map[string]string{
"zig": "/usr/local/bin/zig",
"nvm": "~/.nvm",
"pyenv": "~/.pyenv",
"neovim": "/usr/local/bin/nvim",
"oh-my-zsh": "~/.oh-my-zsh",
"neovim": "/usr/local/bin/nvim",
"oh-my-zsh": "~/.oh-my-zsh",
"agy": "~/.local/bin/agy",
"gh-repo-bootstrap": "~/.local/share/gh/extensions/gh-repo-bootstrap",
"yq": "/usr/local/bin/yq",
"rustup": "~/.cargo/bin/rustup",
"dagger": "/usr/local/bin/dagger",
"trivy": "/usr/local/bin/trivy",
"cosign": "/usr/local/bin/cosign",
"gitleaks": "/usr/local/bin/gitleaks",
}
func expandHome(p string) string {
@@ -84,10 +93,48 @@ func pipInstalled() bool {
// isCustomPkgInstalled returns (installed, checkPath). pip ships inside the
// Python distribution rather than at a fixed path, so it's detected with
// `python3 -m pip --version`.
func npmInstalled(cmdName string) (bool, string) {
if hasCmd(cmdName) {
if p, err := exec.LookPath(cmdName); err == nil {
return true, p
}
return true, ""
}
home, err := os.UserHomeDir()
if err != nil {
return false, ""
}
matches, _ := filepath.Glob(filepath.Join(home, ".local/share/pnpm/bin", cmdName))
if len(matches) > 0 {
return true, matches[0]
}
matches, _ = filepath.Glob(filepath.Join(home, ".nvm/versions/node/*/bin", cmdName))
if len(matches) > 0 {
return true, matches[0]
}
return false, ""
}
func isCustomPkgInstalled(pkg *CustomPackage) (bool, string) {
if strings.ToLower(pkg.Name) == "pip" {
name := strings.ToLower(pkg.Name)
if name == "pip" {
return pipInstalled(), ""
}
if name == "claude" {
return npmInstalled("claude")
}
if name == "codex" {
return npmInstalled("codex")
}
if name == "copilot" {
return npmInstalled("copilot")
}
if name == "playwright" {
return npmInstalled("playwright")
}
if name == "mdts" {
return npmInstalled("mdts")
}
raw := pkg.InstallPath
if raw == "" {
raw = defaultInstallPath(pkg)
@@ -96,7 +143,7 @@ func isCustomPkgInstalled(pkg *CustomPackage) (bool, string) {
return false, ""
}
check := expandHome(raw)
if _, err := os.Stat(check); err == nil {
if _, err := osStat(check); err == nil {
return true, check
}
return false, check
@@ -116,7 +163,7 @@ func verifyArchive(archive string, pkg *CustomPackage) bool {
errLog(fmt.Sprintf("SHA256 mismatch for %s: expected %s, got %s", pkg.Name, expected, actual))
return false
}
fmt.Println(" SHA256 OK")
taskPrintln(" SHA256 OK")
return true
}
if sigURL := pkg.resolveSHA256URL(); sigURL != "" {
@@ -132,11 +179,11 @@ func verifyArchive(archive string, pkg *CustomPackage) bool {
if 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))
return false
}
fmt.Println(" minisign OK")
taskPrintln(" minisign OK")
}
return true
}
@@ -160,18 +207,20 @@ func urlArchOK(pkg *CustomPackage) bool {
// ── per-package install handlers ────────────────────────────────────────
func installGo(archive string) {
out := taskOut()
goRoot := "/usr/local/go"
if _, err := os.Stat(goRoot); err == nil {
fmt.Printf(" Removing existing Go at %s ...\n", goRoot)
runCmd([]string{"rm", "-rf", goRoot}, CmdOpts{AsSudo: true})
if _, err := osStat(goRoot); err == nil {
taskPrintf(" Removing existing Go at %s ...\n", goRoot)
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")
fmt.Printf(" Go installed to %s\n", goRoot)
taskPrintf(" Go installed to %s\n", goRoot)
}
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")
return
}
@@ -181,10 +230,10 @@ func installFirecracker(archive, tmp string) {
return nil
}
name := info.Name()
if !strings.HasPrefix(name, "firecracker") {
if strings.HasSuffix(name, ".tgz") || strings.HasSuffix(name, ".tar.gz") {
return nil
}
if strings.HasSuffix(name, ".debug") || strings.Contains(name, "debug") {
if !strings.HasPrefix(name, "firecracker-v") || strings.Contains(name, "debug") {
return nil
}
if binary == "" {
@@ -192,38 +241,41 @@ func installFirecracker(archive, tmp string) {
}
return nil
})
if binary == "" {
errLog("firecracker binary not found in archive")
return
}
dest := "/usr/local/bin/firecracker"
runCmd([]string{"cp", binary, dest}, CmdOpts{AsSudo: true})
runCmd([]string{"chmod", "755", dest}, CmdOpts{AsSudo: true})
fmt.Printf(" firecracker installed to %s\n", dest)
runCmd([]string{"cp", binary, dest}, CmdOpts{AsSudo: true, Out: out})
runCmd([]string{"chmod", "755", dest}, CmdOpts{AsSudo: true, Out: out})
taskPrintf(" firecracker installed to %s\n", dest)
}
func installZig(pkg *CustomPackage, archive string) {
out := taskOut()
parent := "/usr/local"
zigDir := filepath.Join(parent, "zig-"+pkg.Version)
if _, err := os.Stat(zigDir); err == nil {
runCmd([]string{"rm", "-rf", zigDir}, CmdOpts{AsSudo: true})
if _, err := osStat(zigDir); err == nil {
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]))
matches, _ := filepath.Glob(pattern)
for _, m := range matches {
if m != zigDir {
runCmd([]string{"mv", m, zigDir}, CmdOpts{AsSudo: true})
runCmd([]string{"mv", m, zigDir}, CmdOpts{AsSudo: true, Out: out})
break
}
}
symlink := "/usr/local/bin/zig"
runCmd([]string{"ln", "-sf", filepath.Join(zigDir, "zig"), symlink}, CmdOpts{AsSudo: true})
fmt.Printf(" Zig installed to %s, symlinked at %s\n", zigDir, symlink)
runCmd([]string{"ln", "-sf", filepath.Join(zigDir, "zig"), symlink}, CmdOpts{AsSudo: true, Out: out})
taskPrintf(" Zig installed to %s, symlinked at %s\n", zigDir, symlink)
}
func installNeovim(_ *CustomPackage, tmp string) {
out := taskOut()
var rel ghRelease
if !fetchJSON("https://api.github.com/repos/neovim/neovim/releases/latest", &rel) {
return
@@ -261,18 +313,60 @@ func installNeovim(_ *CustomPackage, tmp string) {
errLog(fmt.Sprintf("Neovim SHA256 mismatch: expected %s, got %s", expected, actual))
return
}
fmt.Println(" SHA256 OK")
taskPrintln(" SHA256 OK")
installDir := fmt.Sprintf("/opt/nvim-%s-%s", osTok, archTok)
fmt.Println(" Extracting Neovim to /opt ...")
runCmd([]string{"mkdir", "-p", "/opt"}, CmdOpts{AsSudo: true})
runCmd([]string{"rm", "-rf", installDir}, CmdOpts{AsSudo: true})
runCmd([]string{"tar", "-C", "/opt", "-xzf", dest}, CmdOpts{AsSudo: true})
taskPrintln(" Extracting Neovim to /opt ...")
runCmd([]string{"mkdir", "-p", "/opt"}, CmdOpts{AsSudo: true, Out: out})
runCmd([]string{"rm", "-rf", installDir}, CmdOpts{AsSudo: true, Out: out})
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"
runCmd([]string{"ln", "-sf", filepath.Join(installDir, "bin", "nvim"), symlink}, CmdOpts{AsSudo: true})
fmt.Printf(" Neovim installed to %s, symlinked at %s\n", installDir, symlink)
runCmd([]string{"ln", "-sf", filepath.Join(installDir, "bin", "nvim"), symlink}, CmdOpts{AsSudo: true, Out: out})
taskPrintf(" Neovim installed to %s, symlinked at %s\n", installDir, symlink)
}
func installRustup(archive string) {
out := taskOut()
runCmd([]string{"chmod", "+x", archive}, CmdOpts{Out: out})
runCmd([]string{archive, "-y", "--no-modify-path"}, CmdOpts{Out: out})
taskPrintln(" rustup installed.")
}
func installYq(archive string) {
out := taskOut()
runCmd([]string{"cp", archive, "/usr/local/bin/yq"}, CmdOpts{AsSudo: true, Out: out})
runCmd([]string{"chmod", "+x", "/usr/local/bin/yq"}, CmdOpts{AsSudo: true, Out: out})
taskPrintln(" yq installed.")
}
func installDagger(archive, tmp string) {
out := taskOut()
runCmd([]string{"tar", "-C", tmp, "-xzf", archive}, CmdOpts{Out: out})
runCmd([]string{"mv", filepath.Join(tmp, "dagger"), "/usr/local/bin/dagger"}, CmdOpts{AsSudo: true, Out: out})
taskPrintln(" dagger installed.")
}
func installTrivy(archive, tmp string) {
out := taskOut()
runCmd([]string{"tar", "-C", tmp, "-xzf", archive}, CmdOpts{Out: out})
runCmd([]string{"mv", filepath.Join(tmp, "trivy"), "/usr/local/bin/trivy"}, CmdOpts{AsSudo: true, Out: out})
taskPrintln(" trivy installed.")
}
func installCosign(archive string) {
out := taskOut()
runCmd([]string{"cp", archive, "/usr/local/bin/cosign"}, CmdOpts{AsSudo: true, Out: out})
runCmd([]string{"chmod", "+x", "/usr/local/bin/cosign"}, CmdOpts{AsSudo: true, Out: out})
taskPrintln(" cosign installed.")
}
func installGitleaks(archive, tmp string) {
out := taskOut()
runCmd([]string{"tar", "-C", tmp, "-xzf", archive}, CmdOpts{Out: out})
runCmd([]string{"mv", filepath.Join(tmp, "gitleaks"), "/usr/local/bin/gitleaks"}, CmdOpts{AsSudo: true, Out: out})
taskPrintln(" gitleaks installed.")
}
// ── latest-version resolvers ────────────────────────────────────────────
@@ -388,10 +482,23 @@ func cmpSemver(a, b string) int {
return len(pa) - len(pb)
}
func resolveLatestYq(_ *CustomPackage) (string, string, bool) {
var rel ghRelease
if !fetchJSON("https://api.github.com/repos/mikefarah/yq/releases/latest", &rel) {
return "", "", false
}
version := strings.TrimPrefix(rel.TagName, "v")
if version == "" {
return "", "", false
}
return version, "", true
}
var latestResolvers = map[string]func(*CustomPackage) (string, string, bool){
"go": resolveLatestGo,
"firecracker": resolveLatestFirecracker,
"zig": resolveLatestZig,
"yq": resolveLatestYq,
}
// resolveLatest best-effort upgrades pkg.Version/SHA256 to the latest release.
@@ -401,7 +508,7 @@ func resolveLatest(pkg *CustomPackage) {
if !ok {
return
}
fmt.Printf(" Checking latest version for %s ...\n", pkg.Name)
taskPrintf(" Checking latest version for %s ...\n", pkg.Name)
defer func() {
if r := recover(); r != nil {
warn(fmt.Sprintf("%s: latest-version lookup panicked %v; falling back to pinned version %s",
@@ -415,95 +522,255 @@ func resolveLatest(pkg *CustomPackage) {
return
}
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
}
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.SHA256 = strings.ToLower(sha)
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 ───────────────────────────────────────────────────────
// 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,
"mdts": 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)
case "rustup":
installRustup(archive)
case "yq":
installYq(archive)
case "dagger":
installDagger(archive, tmp)
case "trivy":
installTrivy(archive, tmp)
case "cosign":
installCosign(archive)
case "gitleaks":
installGitleaks(archive, tmp)
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",
"mdts": "mdts",
}
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) {
fmt.Println("\n=== Custom Packages ===")
for _, pkg := range toInstall {
name := strings.ToLower(pkg.Name)
_, checkPath := isCustomPkgInstalled(pkg)
extra := ""
if checkPath != "" {
extra = fmt.Sprintf(" (install path: %s)", checkPath)
}
fmt.Printf("\n Installing %s ...%s\n", pkg.displayName(), extra)
if checkPath == "" && name != "pip" {
warn(fmt.Sprintf("%s: no known install path — script will not detect future installs", pkg.Name))
}
if name == "firecracker" && isMacOS {
warn(fmt.Sprintf("%s: Linux-only — skipping on macOS", pkg.Name))
continue
}
switch name {
case "nvm":
installNVM()
continue
case "pyenv":
installPyenv()
continue
case "pip":
installPip()
continue
case "oh-my-zsh":
installOhMyZsh()
continue
case "neovim":
tmp, err := os.MkdirTemp("", "bootstrap-nvim-")
if err != nil {
errLog(fmt.Sprintf("neovim tmp dir failed: %v", err))
continue
}
installNeovim(pkg, tmp)
os.RemoveAll(tmp)
continue
}
resolveLatest(pkg)
url := pkg.resolveURL()
if url == "" {
warn(fmt.Sprintf("No URL or install handler for '%s' — skipping", pkg.Name))
continue
}
if !urlArchOK(pkg) {
continue
}
tmp, err := os.MkdirTemp("", "bootstrap-custom-")
if err != nil {
errLog(fmt.Sprintf("tmp dir failed for %s: %v", pkg.Name, err))
continue
}
archive := filepath.Join(tmp, filepath.Base(url))
if !download(url, archive) {
os.RemoveAll(tmp)
continue
}
if !verifyArchive(archive, pkg) {
os.RemoveAll(tmp)
continue
}
switch name {
case "go":
installGo(archive)
case "firecracker":
installFirecracker(archive, tmp)
case "zig":
installZig(pkg, archive)
default:
warn(fmt.Sprintf("No install handler for '%s' — skipping", pkg.Name))
}
os.RemoveAll(tmp)
if len(toInstall) == 0 {
return
}
// 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)
}
+409
View File
@@ -0,0 +1,409 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"testing"
"time"
)
func TestCustomPackageResolvers(t *testing.T) {
defer resetMocks()
osName = "linux"
archName = "x86_64"
p := &CustomPackage{
Name: "my-pkg",
Version: "1.2.3",
URLTemplate: "http://example.com/download/{version}/{os}/{arch}/my-pkg.tar.gz",
SHA256URLTemplate: "http://example.com/download/{version}/{os}/{arch}/my-pkg.tar.gz.minisig",
SHA256Map: map[string]string{
"linux-x86_64": "aabbcc",
},
}
if p.resolveURL() != "http://example.com/download/1.2.3/linux/x86_64/my-pkg.tar.gz" {
t.Errorf("unexpected URL: %q", p.resolveURL())
}
if p.resolveSHA256URL() != "http://example.com/download/1.2.3/linux/x86_64/my-pkg.tar.gz.minisig" {
t.Errorf("unexpected SHA256 URL: %q", p.resolveSHA256URL())
}
if p.resolvedSHA256() != "aabbcc" {
t.Errorf("unexpected resolved SHA256: %q", p.resolvedSHA256())
}
if p.displayName() != "my-pkg-1.2.3" {
t.Errorf("unexpected display name: %q", p.displayName())
}
pNoVer := &CustomPackage{Name: "simple"}
if pNoVer.displayName() != "simple" {
t.Errorf("unexpected display name: %q", pNoVer.displayName())
}
}
func TestCmpSemver(t *testing.T) {
tests := []struct {
a, b string
expected int
}{
{"1.2.3", "1.2.3", 0},
{"1.2.3", "1.2.4", -1},
{"1.3.0", "1.2.9", 1},
{"2.0.0", "10.0.0", -1},
{"1.10.2", "1.2.3", 1},
}
for _, tt := range tests {
res := cmpSemver(tt.a, tt.b)
// Normalize to -1, 0, 1
actual := 0
if res < 0 {
actual = -1
} else if res > 0 {
actual = 1
}
if actual != tt.expected {
t.Errorf("cmpSemver(%q, %q) expected %d, got %d (raw %d)", tt.a, tt.b, tt.expected, actual, res)
}
}
}
func TestVerifyArchive(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
archive := filepath.Join(tmp, "archive.tar.gz")
os.WriteFile(archive, []byte("archive-bytes"), 0644)
// Hash of "archive-bytes" is 0c982986710a026635603031674053ca851fc0e3ea760094a34f59b84f7f6da6
p := &CustomPackage{
Name: "test",
SHA256: "0c982986710a026635603031674053ca851fc0e3ea760094a34f59b84f7f6da6",
}
if !verifyArchive(archive, p) {
t.Error("expected verification to pass")
}
p.SHA256 = "incorrect-hash"
if verifyArchive(archive, p) {
t.Error("expected verification to fail")
}
}
func TestResolveLatestGo(t *testing.T) {
defer resetMocks()
osName = "linux"
archName = "x86_64"
fetchJSON = func(url string, v any) bool {
// Mock Go releases API response
// This encodes mock data into v (raw JSON Message decoding)
data := `[
{
"version": "go1.21.3",
"files": [
{
"filename": "go1.21.3.linux-amd64.tar.gz",
"kind": "archive",
"sha256": "go-sha-value"
}
]
}
]`
json.Unmarshal([]byte(data), v)
return true
}
version, sha, ok := resolveLatestGo(nil)
if !ok || version != "1.21.3" || sha != "go-sha-value" {
t.Errorf("unexpected resolve latest Go result: version=%q, sha=%q, ok=%v", version, sha, ok)
}
}
func TestResolveLatestFirecracker(t *testing.T) {
defer resetMocks()
isMacOS = false
archName = "x86_64"
fetchJSON = func(url string, v any) bool {
rel := v.(*ghRelease)
rel.TagName = "v1.5.0"
rel.Assets = []ghAsset{
{Name: "firecracker-v1.5.0-x86_64.tgz.sha256.txt", BrowserDownloadURL: "http://sha-url"},
}
return true
}
fetchText = func(url string) string {
return "firecracker-sha-value firecracker-v1.5.0-x86_64.tgz"
}
version, sha, ok := resolveLatestFirecracker(nil)
if !ok || version != "1.5.0" || sha != "firecracker-sha-value" {
t.Errorf("unexpected resolve latest firecracker result: version=%q, sha=%q, ok=%v", version, sha, ok)
}
}
func TestResolveLatestZig(t *testing.T) {
defer resetMocks()
osName = "linux"
archName = "x86_64"
fetchJSON = func(url string, v any) bool {
data := `{
"0.11.0": {
"x86_64-linux": {
"shasum": "zig-sha-value"
}
}
}`
json.Unmarshal([]byte(data), v)
return true
}
version, sha, ok := resolveLatestZig(nil)
if !ok || version != "0.11.0" || sha != "zig-sha-value" {
t.Errorf("unexpected resolve latest zig result: version=%q, sha=%q, ok=%v", version, sha, ok)
}
}
func TestIsCustomPkgInstalled(t *testing.T) {
defer resetMocks()
// Pip installed check
hasCmd = func(name string) bool {
return name == "python3"
}
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 0}, true
}
pPip := &CustomPackage{Name: "pip"}
installed, _ := isCustomPkgInstalled(pPip)
if !installed {
t.Error("expected pip to be installed")
}
// Go check (installed check via default install path)
osStat = func(name string) (os.FileInfo, error) {
if name == "/usr/local/go" {
return nil, nil // exists
}
return nil, os.ErrNotExist
}
pGo := &CustomPackage{Name: "go"}
installedGo, _ := isCustomPkgInstalled(pGo)
if !installedGo {
t.Error("expected go to be installed")
}
}
func TestExpandHomeAndDefaultInstallPath(t *testing.T) {
defer resetMocks()
// Test expandHome
t.Setenv("HOME", "/my/home")
expanded := expandHome("~/test")
if expanded != "/my/home/test" {
t.Errorf("expected /my/home/test, got %q", expanded)
}
notExpanded := expandHome("/other/path")
if notExpanded != "/other/path" {
t.Errorf("expected /other/path, got %q", notExpanded)
}
// Test defaultInstallPath
p := &CustomPackage{Name: "go"}
if defaultInstallPath(p) != "/usr/local/go" {
t.Errorf("expected /usr/local/go, got %q", defaultInstallPath(p))
}
pUnknown := &CustomPackage{Name: "unknown"}
if defaultInstallPath(pUnknown) != "" {
t.Errorf("expected empty path, got %q", defaultInstallPath(pUnknown))
}
}
func TestUrlArchOK(t *testing.T) {
defer resetMocks()
archName = "x86_64"
p := &CustomPackage{Name: "test", URLTemplate: "http://example.com/test-x86_64.tar.gz"}
if !urlArchOK(p) {
t.Error("expected urlArchOK to return true for matching arch")
}
pBad := &CustomPackage{Name: "test", URLTemplate: "http://example.com/test-aarch64.tar.gz"}
if urlArchOK(pBad) {
t.Error("expected urlArchOK to return false for mismatching arch")
}
}
func TestInstallCustomPackages(t *testing.T) {
defer resetMocks()
hasCmd = func(name string) bool { return true }
download = func(url, dest string) bool {
// Write valid checksum file so it passes verification
// SHA256 of "content" is 751a073f248535132b178652553f1f317b3f1f90be68c078021481e33d443224
os.WriteFile(dest, []byte("content"), 0644)
return true
}
var (
runCmdMu sync.Mutex
runCmdCalls [][]string
)
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdMu.Lock()
runCmdCalls = append(runCmdCalls, argv)
runCmdMu.Unlock()
return CmdResult{ExitCode: 0}
}
pkgs := []*CustomPackage{
{
Name: "go",
Version: "1.21.0",
URLTemplate: "http://example.com/go.tar.gz",
SHA256: "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73",
},
{
Name: "zig",
Version: "0.11.0",
URLTemplate: "http://example.com/zig.tar.gz",
SHA256: "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73",
},
}
installCustomPackages(pkgs)
runCmdMu.Lock()
defer runCmdMu.Unlock()
// Verify that we executed tar/mv/ln etc commands via runCmd
hasTar := false
for _, call := range runCmdCalls {
if call[0] == "tar" {
hasTar = true
}
}
if !hasTar {
t.Errorf("expected tar command to be executed, got: %v", runCmdCalls)
}
}
func TestInstallFirecracker(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
archive := filepath.Join(tmp, "firecracker.tgz")
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[0] == "tar" {
dummyBin := filepath.Join(tmp, "firecracker-v1.5.0")
os.WriteFile(dummyBin, []byte("binary-content"), 0755)
}
return CmdResult{ExitCode: 0}
}
installFirecracker(archive, tmp)
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 1}
}
installFirecracker(archive, tmp)
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 0}
}
installFirecracker(archive, tmp)
}
func TestInstallNeovim(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
osName = "linux"
archName = "x86_64"
fetchJSON = func(url string, v any) bool {
rel := v.(*ghRelease)
rel.Assets = []ghAsset{
{
Name: "nvim-linux-x86_64.tar.gz",
BrowserDownloadURL: "http://example.com/nvim.tar.gz",
Digest: "sha256:0c982986710a026635603031674053ca851fc0e3ea760094a34f59b84f7f6da6",
},
}
return true
}
download = func(url, dest string) bool {
os.WriteFile(dest, []byte("archive-bytes"), 0644)
return true
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 0}
}
installNeovim(nil, tmp)
fetchJSON = func(url string, v any) bool { return false }
installNeovim(nil, tmp)
fetchJSON = func(url string, v any) bool {
rel := v.(*ghRelease)
rel.Assets = []ghAsset{{Name: "other-name"}}
return true
}
installNeovim(nil, tmp)
fetchJSON = func(url string, v any) bool {
rel := v.(*ghRelease)
rel.Assets = []ghAsset{{Name: "nvim-linux-x86_64.tar.gz", Digest: "bad-digest"}}
return true
}
installNeovim(nil, tmp)
fetchJSON = func(url string, v any) bool {
rel := v.(*ghRelease)
rel.Assets = []ghAsset{{Name: "nvim-linux-x86_64.tar.gz", Digest: "sha256:0c982986710a026635603031674053ca851fc0e3ea760094a34f59b84f7f6da6"}}
return true
}
download = func(url, dest string) bool { return false }
installNeovim(nil, tmp)
download = func(url, dest string) bool {
os.WriteFile(dest, []byte("different-bytes"), 0644)
return true
}
installNeovim(nil, tmp)
}
func TestResolveLatestEdgeCases(t *testing.T) {
defer resetMocks()
pkg := &CustomPackage{Name: "test", FetchLatest: "nonexistent"}
resolveLatest(pkg)
latestResolvers["panic-resolver"] = func(pkg *CustomPackage) (string, string, bool) {
panic("simulated panic")
}
pkgPanic := &CustomPackage{Name: "test", FetchLatest: "panic-resolver", Version: "1.0.0"}
resolveLatest(pkgPanic)
latestResolvers["fail-resolver"] = func(pkg *CustomPackage) (string, string, bool) {
return "", "", false
}
pkgFail := &CustomPackage{Name: "test", FetchLatest: "fail-resolver", Version: "1.0.0"}
resolveLatest(pkgFail)
latestResolvers["same-resolver"] = func(pkg *CustomPackage) (string, string, bool) {
return "1.0.0", "hash", true
}
pkgSame := &CustomPackage{Name: "test", FetchLatest: "same-resolver", Version: "1.0.0"}
resolveLatest(pkgSame)
}
+17 -5
View File
@@ -19,6 +19,7 @@ var (
isMacOS bool
isRHELFamily bool
isArchFamily bool
isTesting bool
)
func init() {
@@ -35,7 +36,7 @@ func detectOS() string {
return "macos"
default:
fmt.Fprintf(os.Stderr, "Unsupported OS: %s (supports Linux, Darwin)\n", runtime.GOOS)
os.Exit(1)
osExit(1)
return ""
}
}
@@ -48,7 +49,7 @@ func detectArch() string {
return "aarch64"
default:
fmt.Fprintf(os.Stderr, "Unsupported architecture: %s (supports x86_64, aarch64)\n", runtime.GOARCH)
os.Exit(1)
osExit(1)
return ""
}
}
@@ -64,6 +65,12 @@ var (
osZig = map[string]string{"linux": "linux", "macos": "macos"}
osNvim = map[string]string{"linux": "linux", "macos": "macos"}
osRustup = map[string]string{"linux": "unknown-linux-gnu", "macos": "apple-darwin"}
archRustup = map[string]string{"x86_64": "x86_64", "aarch64": "aarch64"}
osTrivy = map[string]string{"linux": "Linux", "macos": "macOS"}
archTrivy = map[string]string{"x86_64": "64bit", "aarch64": "ARM64"}
archGitleaks = map[string]string{"x86_64": "x64", "aarch64": "arm64"}
archTokens = map[string][]string{
"x86_64": {"x86_64", "amd64", "x64"},
"aarch64": {"aarch64", "arm64"},
@@ -81,6 +88,11 @@ func formatURL(template, version string) string {
"{os_go}", osGo[osName],
"{os_zig}", osZig[osName],
"{os_nvim}", osNvim[osName],
"{os_rustup}", osRustup[osName],
"{arch_rustup}", archRustup[archName],
"{os_trivy}", osTrivy[osName],
"{arch_trivy}", archTrivy[archName],
"{arch_gitleaks}", archGitleaks[archName],
)
return r.Replace(template)
}
@@ -116,7 +128,7 @@ func hasOtherArchToken(name string) bool {
// stripped of surrounding quotes. Returns "" if the file is missing or the
// field is absent.
func osReleaseField(field string) string {
data, err := os.ReadFile("/etc/os-release")
data, err := osReadFile(osReleasePath)
if err != nil {
return ""
}
@@ -130,7 +142,7 @@ func osReleaseField(field string) string {
}
func detectRHELFamily() bool {
data, err := os.ReadFile("/etc/os-release")
data, err := osReadFile(osReleasePath)
if err != nil {
return pkgMgr == "dnf"
}
@@ -152,7 +164,7 @@ func detectRHELFamily() bool {
}
func detectArchFamily() bool {
data, err := os.ReadFile("/etc/os-release")
data, err := osReadFile(osReleasePath)
if err != nil {
return pkgMgr == "pacman"
}
+135
View File
@@ -0,0 +1,135 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestDetectOSAndArch(t *testing.T) {
// detectOS and detectArch call os.Exit on unsupported platforms.
// Since the tests run on a supported platform, let's verify they return valid values.
osVal := detectOS()
if osVal != "linux" && osVal != "macos" {
t.Errorf("expected linux or macos, got %q", osVal)
}
archVal := detectArch()
if archVal != "x86_64" && archVal != "aarch64" {
t.Errorf("expected x86_64 or aarch64, got %q", archVal)
}
}
func TestFormatURL(t *testing.T) {
defer resetMocks()
osName = "linux"
archName = "x86_64"
tmpl := "https://example.com/download/go-{version}-{os}-{arch}.tar.gz"
expected := "https://example.com/download/go-1.20-linux-x86_64.tar.gz"
actual := formatURL(tmpl, "1.20")
if actual != expected {
t.Errorf("expected %q, got %q", expected, actual)
}
}
func TestOtherArch(t *testing.T) {
defer resetMocks()
archName = "x86_64"
if otherArch() != "aarch64" {
t.Errorf("expected aarch64, got %q", otherArch())
}
archName = "aarch64"
if otherArch() != "x86_64" {
t.Errorf("expected x86_64, got %q", otherArch())
}
}
func TestArchMatchesAndTokens(t *testing.T) {
if !archMatches("file-amd64", "x86_64") {
t.Error("expected true for file-amd64 and x86_64")
}
if archMatches("file-arm64", "x86_64") {
t.Error("expected false for file-arm64 and x86_64")
}
archName = "x86_64"
if !hasOtherArchToken("file-arm64") {
t.Error("expected true for file-arm64 when arch is x86_64")
}
}
func TestOSReleaseField(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
osReleasePath = filepath.Join(tmp, "os-release")
content := `NAME="Fedora Linux"
VERSION="40 (Workstation Edition)"
ID=fedora
VERSION_ID=40
`
if err := os.WriteFile(osReleasePath, []byte(content), 0644); err != nil {
t.Fatalf("failed to write mock os-release: %v", err)
}
if val := osReleaseField("ID"); val != "fedora" {
t.Errorf("expected fedora, got %q", val)
}
if val := osReleaseField("VERSION_ID"); val != "40" {
t.Errorf("expected 40, got %q", val)
}
if val := osReleaseField("NONEXISTENT"); val != "" {
t.Errorf("expected empty string, got %q", val)
}
// Missing file case
osReleasePath = filepath.Join(tmp, "nonexistent")
if val := osReleaseField("ID"); val != "" {
t.Errorf("expected empty string for missing file, got %q", val)
}
}
func TestDetectRHELAndArchFamilies(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
osReleasePath = filepath.Join(tmp, "os-release")
// Test Fedora (RHEL family)
if err := os.WriteFile(osReleasePath, []byte("ID=fedora\n"), 0644); err != nil {
t.Fatal(err)
}
if !detectRHELFamily() {
t.Error("expected fedora to be detected as RHEL family")
}
if detectArchFamily() {
t.Error("expected fedora to NOT be detected as Arch family")
}
// Test Arch (Arch family)
if err := os.WriteFile(osReleasePath, []byte("ID_LIKE=\"arch\"\n"), 0644); err != nil {
t.Fatal(err)
}
if detectRHELFamily() {
t.Error("expected arch to NOT be detected as RHEL family")
}
if !detectArchFamily() {
t.Error("expected arch to be detected as Arch family")
}
// Missing file fallback cases
osReleasePath = filepath.Join(tmp, "nonexistent")
pkgMgr = "dnf"
if !detectRHELFamily() {
t.Error("expected dnf manager fallback to RHEL family")
}
pkgMgr = "pacman"
if !detectArchFamily() {
t.Error("expected pacman manager fallback to Arch family")
}
}
+51 -10
View File
@@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
@@ -18,6 +19,14 @@ import (
const defaultSubprocessTimeout = 30 * time.Minute
// 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 {
AsSudo bool
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
Cwd string
Timeout time.Duration // zero = defaultSubprocessTimeout
Out io.Writer // optional sink for echo + captured streams
}
// CmdResult holds the outcome of a subprocess invocation.
@@ -37,15 +47,20 @@ type CmdResult struct {
func (r CmdResult) OK() bool { return r.Err == nil && r.ExitCode == 0 }
// runCmd executes argv with the supplied options.
func runCmd(argv []string, opts CmdOpts) CmdResult {
// runCmdReal executes argv with the supplied options.
func runCmdReal(argv []string, opts CmdOpts) CmdResult {
if opts.Timeout == 0 {
opts.Timeout = defaultSubprocessTimeout
}
if opts.AsSudo && os.Geteuid() != 0 {
argv = append([]string{"sudo"}, argv...)
}
fmt.Printf(" $ %s\n", strings.Join(argv, " "))
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)
defer cancel()
@@ -69,6 +84,10 @@ func runCmd(argv []string, opts CmdOpts) CmdResult {
err := cmd.Run()
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 {
warn(fmt.Sprintf("%q timed out after %s", argv[0], opts.Timeout))
@@ -90,13 +109,18 @@ func runCmd(argv []string, opts CmdOpts) CmdResult {
return res
}
// runShell executes a single shell string via /bin/sh -c (matching the Python
// runShellReal executes a single shell string via /bin/sh -c (matching the Python
// version's subprocess.run(..., shell=True)).
func runShell(cmd string, opts CmdOpts) CmdResult {
func runShellReal(cmd string, opts CmdOpts) CmdResult {
if opts.Timeout == 0 {
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)
defer cancel()
@@ -120,6 +144,10 @@ func runShell(cmd string, opts CmdOpts) CmdResult {
err := c.Run()
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 {
warn(fmt.Sprintf("shell command timed out after %s", opts.Timeout))
@@ -141,16 +169,29 @@ func runShell(cmd string, opts CmdOpts) CmdResult {
return res
}
// hasCmd is shutil.which() — returns true if name resolves on PATH.
func hasCmd(name string) bool {
// 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.
func hasCmdReal(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
// probe is a short, read-only command invocation used for "is this installed"
// probeReal is a short, read-only command invocation used for "is this installed"
// checks. Returns (result, true) on completion (including non-zero exit) and
// (zero, false) on timeout/launch failure.
func probe(argv []string, timeout time.Duration) (CmdResult, bool) {
func probeReal(argv []string, timeout time.Duration) (CmdResult, bool) {
if timeout == 0 {
timeout = 30 * time.Second
}
+151
View File
@@ -0,0 +1,151 @@
package main
import (
"strings"
"testing"
"time"
)
func TestCmdResultOK(t *testing.T) {
res1 := CmdResult{ExitCode: 0, Err: nil}
if !res1.OK() {
t.Error("expected ExitCode 0 and Err nil to be OK")
}
res2 := CmdResult{ExitCode: 1, Err: nil}
if res2.OK() {
t.Error("expected ExitCode 1 to NOT be OK")
}
}
func TestRunCmdReal(t *testing.T) {
// Simple echo check
res := runCmdReal([]string{"echo", "hello world"}, CmdOpts{Capture: true})
if !res.OK() {
t.Errorf("expected OK command, got result: %+v", res)
}
out := strings.TrimSpace(string(res.Stdout))
if out != "hello world" {
t.Errorf("expected 'hello world', got %q", out)
}
// Exit failure check
resFail := runCmdReal([]string{"false"}, CmdOpts{Capture: true})
if resFail.OK() {
t.Error("expected false command to fail")
}
if resFail.ExitCode != 1 {
t.Errorf("expected exit code 1, got %d", resFail.ExitCode)
}
// Timeout check (using a short timeout)
resTimeout := runCmdReal([]string{"sleep", "5"}, CmdOpts{Timeout: 10 * time.Millisecond, Capture: true})
if resTimeout.ExitCode != 124 {
t.Errorf("expected exit code 124 (timeout), got %d", resTimeout.ExitCode)
}
}
func TestRunShellReal(t *testing.T) {
// Simple shell command
res := runShellReal("echo shell hello", CmdOpts{Capture: true})
if !res.OK() {
t.Errorf("expected OK shell command, got result: %+v", res)
}
out := strings.TrimSpace(string(res.Stdout))
if out != "shell hello" {
t.Errorf("expected 'shell hello', got %q", out)
}
// Failed command
resFail := runShellReal("exit 42", CmdOpts{Capture: true})
if resFail.OK() {
t.Error("expected shell command with exit 42 to fail")
}
if resFail.ExitCode != 42 {
t.Errorf("expected exit code 42, got %d", resFail.ExitCode)
}
}
func TestHasCmdReal(t *testing.T) {
if !hasCmdReal("go") {
t.Error("expected hasCmdReal('go') to return true (running go test)")
}
if hasCmdReal("nonexistent-command-xyz") {
t.Error("expected nonexistent command to return false")
}
}
func TestProbeReal(t *testing.T) {
res, ok := probeReal([]string{"echo", "probe"}, 0)
if !ok {
t.Error("expected probe to succeed")
}
if strings.TrimSpace(string(res.Stdout)) != "probe" {
t.Errorf("expected 'probe', got %q", string(res.Stdout))
}
// Test a failing command probe
resFail, okFail := probeReal([]string{"false"}, 0)
if !okFail {
t.Error("expected probe check of false command to return ok=true (meaning it completed and didn't crash/timeout)")
}
if resFail.ExitCode != 1 {
t.Errorf("expected exit code 1, got %d", resFail.ExitCode)
}
// Test an invalid command (launch failure)
_, okErr := probeReal([]string{"nonexistent-executable-file"}, 0)
if okErr {
t.Error("expected probe to return false on launch failure")
}
}
func TestExecRealEdgeCases(t *testing.T) {
// 2. Cwd and Input in runCmdReal
tmp := t.TempDir()
resCwd := runCmdReal([]string{"pwd"}, CmdOpts{Cwd: tmp, Capture: true})
if !resCwd.OK() {
t.Errorf("pwd failed: %+v", resCwd)
}
resInput := runCmdReal([]string{"cat"}, CmdOpts{Input: []byte("my-input"), Capture: true})
if !resInput.OK() || strings.TrimSpace(string(resInput.Stdout)) != "my-input" {
t.Errorf("cat input failed, got result: %+v", resInput)
}
// 3. Capture = false in runCmdReal
_ = runCmdReal([]string{"echo", "capture-false-cmd"}, CmdOpts{Capture: false})
// 4. Launch failure in runCmdReal (not exit error)
resLaunch := runCmdReal([]string{"nonexistent-command-12345"}, CmdOpts{Capture: true})
if resLaunch.OK() || resLaunch.ExitCode != 1 || resLaunch.Err == nil {
t.Errorf("expected launch failure, got: %+v", resLaunch)
}
// 5. Cwd and Input in runShellReal
resShellCwd := runShellReal("pwd", CmdOpts{Cwd: tmp, Capture: true})
if !resShellCwd.OK() {
t.Errorf("shell pwd failed: %+v", resShellCwd)
}
resShellInput := runShellReal("cat", CmdOpts{Input: []byte("my-shell-input"), Capture: true})
if !resShellInput.OK() || strings.TrimSpace(string(resShellInput.Stdout)) != "my-shell-input" {
t.Errorf("shell cat input failed, got: %+v", resShellInput)
}
// 6. Capture = false in runShellReal
_ = runShellReal("echo capture-false-shell", CmdOpts{Capture: false})
// 7. Timeout in runShellReal
resShellTimeout := runShellReal("sleep 5", CmdOpts{Timeout: 10 * time.Millisecond, Capture: true})
if resShellTimeout.ExitCode != 124 {
t.Errorf("expected exit code 124 for shell timeout, got %d", resShellTimeout.ExitCode)
}
// 8. Timeout in probeReal
_, okProbeTimeout := probeReal([]string{"sleep", "5"}, 10*time.Millisecond)
if okProbeTimeout {
t.Error("expected probe to return ok=false on timeout")
}
}
+13
View File
@@ -23,6 +23,19 @@ func installFlatpakPackages(toInstall []string) {
"https://dl.flathub.org/repo/flathub.flatpakrepo",
}, 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 {
fmt.Printf("\n Installing %s ...\n", pkgID)
res := runCmd([]string{"flatpak", "install", "--noninteractive", "flathub", pkgID}, CmdOpts{})
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"strings"
"testing"
)
func TestInstallFlatpakPackages(t *testing.T) {
defer resetMocks()
// Case 1: flatpak already installed
hasCmd = func(name string) bool {
return name == "flatpak"
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
installFlatpakPackages([]string{"org.gimp.GIMP"})
if len(runCmdCalls) != 2 {
t.Fatalf("expected 2 runCmd calls, got %d: %v", len(runCmdCalls), runCmdCalls)
}
// First call should add flathub remote
if runCmdCalls[0][1] != "remote-add" {
t.Errorf("expected remote-add, got %v", runCmdCalls[0])
}
// Second call should install GIMP
if runCmdCalls[1][1] != "install" || runCmdCalls[1][4] != "org.gimp.GIMP" {
t.Errorf("expected install GIMP, got %v", runCmdCalls[1])
}
// Case 2: flatpak not installed, choose not to install
resetMocks()
var askedPrompt string
stdin = strings.NewReader("n\n") // Abort flatpak installation
hasCmd = func(name string) bool {
return false
}
runCmdCalls = nil
installFlatpakPackages([]string{"org.gimp.GIMP"})
if len(runCmdCalls) != 0 {
t.Errorf("expected no flatpak installs if skipped, got calls: %v", runCmdCalls)
}
_ = askedPrompt
// Case 3: flatpak not installed, choose to install
resetMocks()
pkgMgr = "dnf"
stdin = strings.NewReader("y\n")
flatpakInstalled := false
hasCmd = func(name string) bool {
if name == "flatpak" {
return flatpakInstalled
}
return false
}
var runCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCalls = append(runCalls, argv)
if len(argv) >= 4 && argv[0] == "dnf" && argv[1] == "install" && argv[3] == "flatpak" {
flatpakInstalled = true
}
return CmdResult{ExitCode: 0}
}
installFlatpakPackages([]string{"org.gimp.GIMP"})
// It should call dnf install flatpak, then remote-add, then install GIMP
foundInstall := false
for _, call := range runCalls {
if len(call) >= 4 && call[0] == "dnf" && call[1] == "install" && call[3] == "flatpak" {
foundInstall = true
}
}
if !foundInstall {
t.Errorf("expected dnf install flatpak to be called, got calls: %v", runCalls)
}
}
func TestInstallFlatpakFailures(t *testing.T) {
defer resetMocks()
stdin = strings.NewReader("y\n")
hasCmd = func(name string) bool { return false }
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 1}
}
installFlatpakPackages([]string{"org.gimp.GIMP"})
resetMocks()
stdin = strings.NewReader("y\n")
hasCmd = func(name string) bool { return false }
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 0}
}
installFlatpakPackages([]string{"org.gimp.GIMP"})
resetMocks()
hasCmd = func(name string) bool { return name == "flatpak" }
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[1] == "install" {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
installFlatpakPackages([]string{"org.gimp.GIMP"})
}
+23 -1
View File
@@ -1,3 +1,25 @@
module github.com/JMR-dev/bootstrap_dev_env
go 1.24.7
go 1.25.0
require dagger.io/dagger v0.20.8
require (
github.com/99designs/gqlgen v0.17.89 // indirect
github.com/Khan/genqlient v0.8.1 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/sosodev/duration v1.4.0 // indirect
github.com/vektah/gqlparser/v2 v2.5.32 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/term v0.43.0 // indirect
)
+55
View File
@@ -0,0 +1,55 @@
dagger.io/dagger v0.20.8 h1:n+Xtzp9ufNwCH3Ftob92Smu3smfUDoQshDYM6Ys4yf0=
dagger.io/dagger v0.20.8/go.mod h1:ZXg8+pQZaZUC8rAw4V/gPP8aKvKARIJZ+pfcV+RC1es=
github.com/99designs/gqlgen v0.17.89 h1:KzEcxPiMgQoMw3m/E85atUEHyZyt0PbAflMia5Kw8z8=
github.com/99designs/gqlgen v0.17.89/go.mod h1:GFqruTVGB7ZTdrf1uzOagpXbY7DrEt1pIxnTdhIbWvQ=
github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs=
github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc=
github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
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/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.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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+50
View File
@@ -0,0 +1,50 @@
package main
import (
"errors"
"os"
"strings"
)
func resetMocks() {
runCmd = runCmdReal
runShell = runShellReal
hasCmd = hasCmdReal
probe = probeReal
download = downloadReal
fetchJSON = fetchJSONReal
fetchText = fetchTextReal
osStat = os.Stat
osReadFile = os.ReadFile
osWriteFile = os.WriteFile
osMkdirAll = os.MkdirAll
osRemove = os.Remove
osRemoveAll = os.RemoveAll
osExit = os.Exit
progressConfigPath = progressConfigPathReal
// Default to an empty reader so un-mocked tests don't hang waiting for user input.
stdin = strings.NewReader("")
readPassword = func() ([]byte, error) {
return nil, errors.New("terminal blocked in test")
}
isMacOS = false
pkgMgr = "dnf"
isRHELFamily = true
isArchFamily = false
osName = "linux"
archName = "x86_64"
disableProgressTracking = true
osReleasePath = "/etc/os-release"
passwdPath = "/etc/passwd"
issuesMu.Lock()
issues = nil
notices = nil
errorCount = 0
issuesMu.Unlock()
}
+27 -5
View File
@@ -2,6 +2,7 @@ package main
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
@@ -13,21 +14,42 @@ import (
// when there's something to report.
var (
issuesMu sync.Mutex
issues []string
notices []string
issuesMu sync.Mutex
issues []string
notices []string
errorCount int
// issueLogWriter is the destination for human-facing issue log lines.
// Overridden during tests to suppress intentional error-path output.
issueLogWriter io.Writer = os.Stdout
)
func logIssue(level, msg string) {
issuesMu.Lock()
defer issuesMu.Unlock()
fmt.Printf(" [%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))
if level == "ERROR" {
errorCount++
}
}
func warn(msg string) { logIssue("WARN", msg) }
func errLog(msg string) { logIssue("ERROR", msg) }
func hasErrors() bool {
issuesMu.Lock()
defer issuesMu.Unlock()
return errorCount > 0
}
func notice(msg string) {
issuesMu.Lock()
defer issuesMu.Unlock()
@@ -54,7 +76,7 @@ func writeRunLog() {
lines := []string{fmt.Sprintf("# Bootstrap run — %s", ts), ""}
lines = append(lines, issues...)
content := strings.Join(lines, "\n") + "\n"
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
if err := osWriteFile(path, []byte(content), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "failed to write run log: %v\n", err)
return
}
+134
View File
@@ -0,0 +1,134 @@
package main
import (
"bytes"
"io"
"os"
"strings"
"testing"
)
func TestIssuesLogging(t *testing.T) {
defer resetMocks()
resetMocks()
// Capture issue log output via the injectable writer.
var buf bytes.Buffer
oldWriter := issueLogWriter
issueLogWriter = &buf
defer func() { issueLogWriter = oldWriter }()
warn("something is deprecated")
errLog("something failed")
notice("please restart shell")
output := buf.String()
if !strings.Contains(output, "[WARN] something is deprecated") {
t.Errorf("output missing warning: %q", output)
}
if !strings.Contains(output, "[ERROR] something failed") {
t.Errorf("output missing error: %q", output)
}
issuesMu.Lock()
issueLen := len(issues)
noticeLen := len(notices)
errCount := errorCount
issuesMu.Unlock()
if issueLen != 2 {
t.Errorf("expected 2 logged issues, got %d", issueLen)
}
if noticeLen != 1 {
t.Errorf("expected 1 notice, got %d", noticeLen)
}
if errCount != 1 {
t.Errorf("expected 1 error count, got %d", errCount)
}
if !hasErrors() {
t.Error("expected hasErrors() to return true after errLog call")
}
}
func TestWriteRunLog(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
_ = tmp
// Redirect path function or mock executable path
// In issues.go, we can define a package variable to override runLogPath if we want,
// or we can mock osWriteFile. Since we mocked osWriteFile, let's use that!
var writtenPath string
var writtenData []byte
osWriteFile = func(path string, data []byte, perm os.FileMode) error {
writtenPath = path
writtenData = data
return nil
}
// No issues case
writeRunLog()
if writtenPath != "" {
t.Error("expected run log not to be written when there are no issues")
}
// Add an issue
warn("test warning")
writeRunLog()
if writtenPath == "" {
t.Fatal("expected run log to be written")
}
if !strings.Contains(string(writtenData), "[WARN] test warning") {
t.Errorf("expected log to contain the warning, got: %s", string(writtenData))
}
}
func TestHasErrors(t *testing.T) {
defer resetMocks()
if hasErrors() {
t.Error("expected hasErrors() false with no errors logged")
}
warn("just a warning")
if hasErrors() {
t.Error("expected hasErrors() false after only a warning")
}
errLog("a real error")
if !hasErrors() {
t.Error("expected hasErrors() true after errLog call")
}
}
func TestPrintNotices(t *testing.T) {
defer resetMocks()
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
printNotices() // Should be empty
notice("first notice")
notice("second notice")
printNotices()
w.Close()
os.Stdout = oldStdout
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
if !strings.Contains(output, "Notices:") {
t.Error("stdout missing notices header")
}
if !strings.Contains(output, "first notice") || !strings.Contains(output, "second notice") {
t.Errorf("stdout missing notice contents: %q", output)
}
}
+35 -21
View File
@@ -57,14 +57,14 @@ func ensureHomebrew() {
installer := `NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"`
if !runShell(installer, CmdOpts{}).OK() {
fmt.Fprintln(os.Stderr, "Homebrew installation failed")
os.Exit(1)
osExit(1)
}
brewBinDir := filepath.Join(brewPrefix(), "bin")
brewPath := filepath.Join(brewBinDir, "brew")
if _, err := os.Stat(brewPath); err != nil {
if _, err := osStat(brewPath); err != nil {
fmt.Fprintf(os.Stderr, "Homebrew installed but brew not found at %s\n", brewPath)
os.Exit(1)
osExit(1)
}
os.Setenv("PATH", brewBinDir+":"+os.Getenv("PATH"))
@@ -286,19 +286,19 @@ runcmd:
`
func writeCloudInitSeed(seedDir, pubkey string) error {
if err := os.MkdirAll(seedDir, 0o755); err != nil {
if err := osMkdirAll(seedDir, 0o755); err != nil {
return err
}
userData := fmt.Sprintf(firecrackerUserdataTmpl, vmUser, strings.TrimSpace(pubkey))
if err := os.WriteFile(filepath.Join(seedDir, "user-data"), []byte(userData), 0o644); err != nil {
if err := osWriteFile(filepath.Join(seedDir, "user-data"), []byte(userData), 0o644); err != nil {
return err
}
return os.WriteFile(filepath.Join(seedDir, "meta-data"),
return osWriteFile(filepath.Join(seedDir, "meta-data"),
[]byte("instance-id: firecracker-vm\nlocal-hostname: firecracker-vm\n"), 0o644)
}
func buildSeedISO(seedDir, isoPath string) bool {
os.Remove(isoPath)
osRemove(isoPath)
return runCmd([]string{
"hdiutil", "makehybrid", "-iso", "-joliet",
"-default-volume-name", "cidata",
@@ -346,7 +346,7 @@ fi
rm -f %s
%s`, dir, vmPIDName, vmPIDName, vmPIDName, qemuBlock)
os.WriteFile(scriptPath, []byte(script), 0o755)
osWriteFile(scriptPath, []byte(script), 0o755)
return scriptPath
}
@@ -380,7 +380,14 @@ func waitForVMSSH(privKey string, timeout time.Duration) bool {
fmt.Println(" VM SSH ready.")
return true
}
time.Sleep(5 * time.Second)
rem := time.Until(deadline)
if rem <= 0 {
break
}
if rem > 5*time.Second {
rem = 5 * time.Second
}
time.Sleep(rem)
}
return false
}
@@ -393,7 +400,14 @@ func waitForFirecrackerInVM(privKey string, timeout time.Duration) bool {
fmt.Println(" firecracker is installed inside the VM.")
return true
}
time.Sleep(10 * time.Second)
rem := time.Until(deadline)
if rem <= 0 {
break
}
if rem > 10*time.Second {
rem = 10 * time.Second
}
time.Sleep(rem)
}
return false
}
@@ -440,7 +454,7 @@ func installFirecrackerZshFunction(content string) {
home, _ := os.UserHomeDir()
zshrc := filepath.Join(home, ".zshrc")
existing := ""
if b, err := os.ReadFile(zshrc); err == nil {
if b, err := osReadFile(zshrc); err == nil {
existing = string(b)
}
pattern := regexp.MustCompile(`(?s)` + regexp.QuoteMeta(firecrackerFnBeg) + `.*?` + regexp.QuoteMeta(firecrackerFnEnd) + `\n?`)
@@ -454,7 +468,7 @@ func installFirecrackerZshFunction(content string) {
newContent = content
}
}
os.WriteFile(zshrc, []byte(newContent), 0o644)
osWriteFile(zshrc, []byte(newContent), 0o644)
fmt.Printf(" Wrote firecracker() function block to %s\n", zshrc)
}
@@ -488,7 +502,7 @@ func provisionVirtualBoxVM(qcow2, seedISO string) string {
exists := ok && r.ExitCode == 0
if !exists {
if _, err := os.Stat(vdi); os.IsNotExist(err) {
if _, err := osStat(vdi); os.IsNotExist(err) {
fmt.Printf(" Converting %s → %s (VirtualBox VDI) ...\n", filepath.Base(qcow2), filepath.Base(vdi))
if !runCmd([]string{"VBoxManage", "clonemedium", "disk", qcow2, vdi, "--format", "VDI"}, CmdOpts{}).OK() {
errLog("VBoxManage clonemedium failed")
@@ -497,7 +511,7 @@ func provisionVirtualBoxVM(qcow2, seedISO string) string {
runCmd([]string{"VBoxManage", "modifymedium", "disk", vdi, "--resize", "10240"}, CmdOpts{})
}
fmt.Printf(" Creating VirtualBox VM '%s' ...\n", vmName)
os.MkdirAll(vboxBase, 0o755)
osMkdirAll(vboxBase, 0o755)
if !runCmd([]string{
"VBoxManage", "createvm",
"--name", vmName,
@@ -543,7 +557,7 @@ if VBoxManage list runningvms | grep -q '"%s"'; then
fi
exec VBoxManage startvm %s --type headless
`, vmName, vmName)
os.WriteFile(scriptPath, []byte(script), 0o755)
osWriteFile(scriptPath, []byte(script), 0o755)
return scriptPath
}
@@ -558,11 +572,11 @@ func setupFirecrackerVM() {
fmt.Println("\n=== macOS firecracker VM (Fedora) ===")
dir := vmDir()
os.MkdirAll(dir, 0o755)
osMkdirAll(dir, 0o755)
privKey := filepath.Join(dir, vmKeyName)
pubKey := privKey + ".pub"
if _, err := os.Stat(privKey); os.IsNotExist(err) {
if _, err := osStat(privKey); os.IsNotExist(err) {
fmt.Printf(" Generating SSH keypair at %s ...\n", privKey)
if !runCmd([]string{"ssh-keygen", "-t", "ed25519", "-N", "", "-f", privKey, "-q"}, CmdOpts{}).OK() {
errLog("ssh-keygen failed — aborting VM setup")
@@ -571,7 +585,7 @@ func setupFirecrackerVM() {
}
qcow2 := filepath.Join(dir, vmQcow2Name)
if _, err := os.Stat(qcow2); err == nil {
if _, err := osStat(qcow2); err == nil {
fmt.Printf(" Reusing existing Fedora image at %s\n", qcow2)
} else {
fmt.Println(" Looking up latest Fedora cloud image ...")
@@ -587,10 +601,10 @@ func setupFirecrackerVM() {
return
}
if !verifyFedoraQcow2(downloadDest, checksumURL) {
os.Remove(downloadDest)
osRemove(downloadDest)
return
}
os.Rename(downloadDest, qcow2)
osRename(downloadDest, qcow2)
if hasCmd("qemu-img") {
fmt.Println(" Resizing image to 10G ...")
runCmd([]string{"qemu-img", "resize", qcow2, "10G"}, CmdOpts{})
@@ -599,7 +613,7 @@ func setupFirecrackerVM() {
fmt.Println(" Building cloud-init seed ISO ...")
seedDir := filepath.Join(dir, "seed")
pubKeyBytes, err := os.ReadFile(pubKey)
pubKeyBytes, err := osReadFile(pubKey)
if err != nil {
errLog(fmt.Sprintf("could not read public key: %v", err))
return
+707
View File
@@ -0,0 +1,707 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestBrewPrefix(t *testing.T) {
defer resetMocks()
archName = "aarch64"
if brewPrefix() != "/opt/homebrew" {
t.Errorf("expected /opt/homebrew on Apple Silicon, got %q", brewPrefix())
}
archName = "x86_64"
if brewPrefix() != "/usr/local" {
t.Errorf("expected /usr/local on Intel, got %q", brewPrefix())
}
}
func TestEnsureXcodeCLT(t *testing.T) {
defer resetMocks()
isMacOS = true
var probeCalls [][]string
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
probeCalls = append(probeCalls, argv)
// First call returns exit code 1 (not installed), then subsequent calls return 0 (installed)
if len(probeCalls) == 1 {
return CmdResult{ExitCode: 1}, true
}
return CmdResult{ExitCode: 0, Stdout: []byte("/Library/Developer/CommandLineTools")}, true
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
ensureXcodeCLT()
if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "xcode-select" || runCmdCalls[0][1] != "--install" {
t.Errorf("expected xcode-select --install call, got: %v", runCmdCalls)
}
if len(probeCalls) < 2 {
t.Errorf("expected at least 2 probe checks, got %d", len(probeCalls))
}
}
func TestEnsureHomebrew(t *testing.T) {
defer resetMocks()
isMacOS = true
hasCmd = func(name string) bool {
return false // Not installed
}
var runShellCmd string
runShell = func(cmd string, opts CmdOpts) CmdResult {
runShellCmd = cmd
return CmdResult{ExitCode: 0}
}
osStat = func(name string) (os.FileInfo, error) {
// Mock homebrew path check returning exists
if strings.HasSuffix(name, "brew") {
return nil, nil
}
return nil, os.ErrNotExist
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
ensureHomebrew()
if !strings.Contains(runShellCmd, "Homebrew/install/HEAD/install.sh") {
t.Errorf("unexpected installer shell command: %q", runShellCmd)
}
if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "bash" || !strings.Contains(runCmdCalls[0][2], "shellenv") {
t.Errorf("expected shellenv zprofile command, got: %v", runCmdCalls)
}
}
func TestMacosMajorAndSiliconGen(t *testing.T) {
defer resetMocks()
isMacOS = true
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "sw_vers" {
return CmdResult{ExitCode: 0, Stdout: []byte("15.0.1\n")}, true
}
if argv[0] == "sysctl" {
return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3 Max\n")}, true
}
return CmdResult{ExitCode: 1}, true
}
if macosMajor() != 15 {
t.Errorf("expected macOS major version 15, got %d", macosMajor())
}
archName = "aarch64"
if appleSiliconGeneration() != 3 {
t.Errorf("expected Apple Silicon generation M3 (3), got %d", appleSiliconGeneration())
}
}
func TestSelectVMBackend(t *testing.T) {
defer resetMocks()
isMacOS = true
archName = "x86_64"
if backend := selectVMBackend(); backend != "virtualbox" {
t.Errorf("expected virtualbox on Intel Mac, got %q", backend)
}
// Apple Silicon M3 on macOS 15 Sequoia
resetMocks()
isMacOS = true
archName = "aarch64"
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "sw_vers" {
return CmdResult{ExitCode: 0, Stdout: []byte("15.0\n")}, true
}
if argv[0] == "sysctl" {
return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3\n")}, true
}
return CmdResult{ExitCode: 1}, true
}
if backend := selectVMBackend(); backend != "qemu" {
t.Errorf("expected qemu on M3 macOS 15, got %q", backend)
}
// Apple Silicon M1 on macOS 14 (no local hypervisor)
resetMocks()
isMacOS = true
archName = "aarch64"
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "sw_vers" {
return CmdResult{ExitCode: 0, Stdout: []byte("14.5\n")}, true
}
if argv[0] == "sysctl" {
return CmdResult{ExitCode: 0, Stdout: []byte("Apple M1\n")}, true
}
return CmdResult{ExitCode: 1}, true
}
if backend := selectVMBackend(); backend != "" {
t.Errorf("expected empty backend on M1 macOS 14, got %q", backend)
}
}
func TestLatestFedoraCloudImage(t *testing.T) {
defer resetMocks()
fetchText = func(url string) string {
if url == "https://dl.fedoraproject.org/pub/fedora/linux/releases/" {
return `
<a href="38/">38/</a>
<a href="39/">39/</a>
<a href="40/">40/</a>
`
}
if strings.Contains(url, "40/Cloud/") {
return `
<a href="Fedora-Cloud-Base-40-1.10.x86_64.qcow2">Fedora-Cloud-Base-40-1.10.x86_64.qcow2</a>
<a href="Fedora-Cloud-Base-40-1.10.x86_64-CHECKSUM">Fedora-Cloud-Base-40-1.10.x86_64-CHECKSUM</a>
`
}
return ""
}
archName = "x86_64"
filename, qcowURL, _, ok := latestFedoraCloudImage()
if !ok {
t.Fatal("expected success")
}
if filename != "Fedora-Cloud-Base-40-1.10.x86_64.qcow2" {
t.Errorf("unexpected filename: %q", filename)
}
if !strings.Contains(qcowURL, "40/Cloud/x86_64/images/") {
t.Errorf("unexpected qcowURL: %q", qcowURL)
}
}
func TestInstallFirecrackerZshFunction(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
t.Setenv("HOME", tmp)
zshrc := filepath.Join(tmp, ".zshrc")
osWriteFile(zshrc, []byte("echo initial\n"), 0644)
osReadFile = func(name string) ([]byte, error) {
if name == zshrc {
return []byte("echo initial\n"), nil
}
return nil, os.ErrNotExist
}
var writtenContent string
osWriteFile = func(name string, data []byte, perm os.FileMode) error {
if name == zshrc {
writtenContent = string(data)
}
return nil
}
installFirecrackerZshFunction("firecracker() { echo wrapper; }")
if !strings.Contains(writtenContent, "firecracker() { echo wrapper; }") {
t.Errorf("expected wrapper code inside written zshrc, got %q", writtenContent)
}
}
func TestVerifyFedoraQcow2(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
qcow2 := filepath.Join(tmp, "fedora.qcow2")
os.WriteFile(qcow2, []byte("qcow2-content"), 0644)
// Hash of "qcow2-content" is fa13cb14afd725b7efaa126bd84a2a848fe9a46267251afecc769d7bdd6fcd01
fetchText = func(url string) string {
return "SHA256 (fedora.qcow2) = fa13cb14afd725b7efaa126bd84a2a848fe9a46267251afecc769d7bdd6fcd01"
}
if !verifyFedoraQcow2(qcow2, "http://checksum-url") {
t.Error("expected verification to succeed")
}
}
func TestDownloadFedoraImage(t *testing.T) {
defer resetMocks()
// Case 1: curl exists
hasCmd = func(name string) bool { return name == "curl" }
var runCmdCalled bool
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[0] == "curl" {
runCmdCalled = true
}
return CmdResult{ExitCode: 0}
}
downloadFedoraImage("http://url", "/tmp/dest")
if !runCmdCalled {
t.Error("expected curl command to be run")
}
// Case 2: curl does not exist
resetMocks()
hasCmd = func(name string) bool { return false }
var downloadCalled bool
download = func(url, dest string) bool {
downloadCalled = true
return true
}
downloadFedoraImage("http://url", "/tmp/dest")
if !downloadCalled {
t.Error("expected download function to be called")
}
}
func TestWriteCloudInitSeedAndISO(t *testing.T) {
defer resetMocks()
var mkdirCalls []string
osMkdirAll = func(path string, perm os.FileMode) error {
mkdirCalls = append(mkdirCalls, path)
return nil
}
var writtenFiles []string
osWriteFile = func(name string, data []byte, perm os.FileMode) error {
writtenFiles = append(writtenFiles, name)
return nil
}
err := writeCloudInitSeed("/tmp/seed", "ssh-pubkey")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(mkdirCalls) != 1 || mkdirCalls[0] != "/tmp/seed" {
t.Errorf("unexpected mkdir calls: %v", mkdirCalls)
}
if len(writtenFiles) != 2 {
t.Errorf("expected user-data and meta-data files to be written, got: %v", writtenFiles)
}
// buildSeedISO
var removed bool
osRemove = func(path string) error {
if path == "/tmp/seed.iso" {
removed = true
}
return nil
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
buildSeedISO("/tmp/seed", "/tmp/seed.iso")
if !removed {
t.Error("expected osRemove to delete old ISO first")
}
if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "hdiutil" {
t.Errorf("expected hdiutil call, got %v", runCmdCalls)
}
}
func TestWriteQEMUStartScript(t *testing.T) {
defer resetMocks()
var writtenPath string
osWriteFile = func(name string, data []byte, perm os.FileMode) error {
writtenPath = name
return nil
}
t.Setenv("HOME", "/my/home")
path := writeQEMUStartScript()
if !strings.HasSuffix(path, "vm-start.sh") {
t.Errorf("unexpected script path: %q", path)
}
if !strings.HasSuffix(writtenPath, "vm-start.sh") {
t.Errorf("expected script to be written, got %q", writtenPath)
}
}
func TestSSHToVMAndHelpers(t *testing.T) {
defer resetMocks()
// sshToVM
var probeCall []string
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
probeCall = argv
return CmdResult{ExitCode: 0}, true
}
res := sshToVM("/path/to/key", []string{"ls"}, 0)
if !res.OK() {
t.Errorf("expected OK result, got %+v", res)
}
if probeCall[0] != "ssh" || !strings.Contains(strings.Join(probeCall, " "), "fc@127.0.0.1") {
t.Errorf("unexpected probe command: %v", probeCall)
}
// waitForVMSSH
var probeCalls int
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
probeCalls++
return CmdResult{ExitCode: 0}, true
}
if !waitForVMSSH("/path/to/key", time.Second) {
t.Error("expected wait to succeed")
}
if probeCalls != 1 {
t.Errorf("expected 1 probe call, got %d", probeCalls)
}
// Failure case with short timeout
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 1}, true
}
if waitForVMSSH("/path/to/key", 10*time.Millisecond) {
t.Error("expected wait to fail")
}
// waitForFirecrackerInVM
probeCalls = 0
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
probeCalls++
return CmdResult{ExitCode: 0}, true
}
if !waitForFirecrackerInVM("/path/to/key", time.Second) {
t.Error("expected wait to succeed")
}
// Failure case with short timeout
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 1}, true
}
if waitForFirecrackerInVM("/path/to/key", 10*time.Millisecond) {
t.Error("expected wait to fail")
}
}
func TestEnsureVirtualBoxAndProvision(t *testing.T) {
defer resetMocks()
// Case 1: VBoxManage exists
hasCmd = func(name string) bool { return name == "VBoxManage" }
if !ensureVirtualBox() {
t.Error("expected ensureVirtualBox to be true when VBoxManage exists")
}
// Case 2: VBoxManage does not exist, brew install succeeds
resetMocks()
hasCmdCalls := 0
hasCmd = func(name string) bool {
hasCmdCalls++
// First check (is VBoxManage in path) -> returns false.
// Second check (is VBoxManage in path after brew install) -> returns true.
if name == "VBoxManage" {
return hasCmdCalls > 1
}
return false
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
if !ensureVirtualBox() {
t.Error("expected ensureVirtualBox to be true after install")
}
if len(runCmdCalls) != 1 || runCmdCalls[0][3] != "virtualbox" {
t.Errorf("expected brew install virtualbox call, got %v", runCmdCalls)
}
// Test provisionVirtualBoxVM
resetMocks()
t.Setenv("HOME", "/my/home")
hasCmd = func(name string) bool { return name == "VBoxManage" }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[1] == "showvminfo" {
return CmdResult{ExitCode: 1}, true // VM does not exist yet
}
return CmdResult{ExitCode: 0}, true
}
osStat = func(name string) (os.FileInfo, error) {
return nil, nil // vdi exists
}
runCmdCalls = nil
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
osWriteFile = func(name string, data []byte, perm os.FileMode) error {
return nil
}
scriptPath := provisionVirtualBoxVM("/tmp/fedora.qcow2", "/tmp/seed.iso")
if !strings.HasSuffix(scriptPath, "vm-start.sh") {
t.Errorf("unexpected script path: %q", scriptPath)
}
if len(runCmdCalls) < 2 {
t.Errorf("expected virtualbox setup commands, got %v", runCmdCalls)
}
}
func TestSetupFirecrackerVM(t *testing.T) {
defer resetMocks()
isMacOS = true
archName = "aarch64"
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "sw_vers" {
return CmdResult{ExitCode: 0, Stdout: []byte("15.0\n")}, true
}
if argv[0] == "sysctl" {
return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3\n")}, true
}
if argv[0] == "ssh" {
return CmdResult{ExitCode: 0}, true // SSH succeeds
}
return CmdResult{ExitCode: 0}, true
}
osStat = func(name string) (os.FileInfo, error) {
// Mock files exist
return nil, nil
}
osReadFile = func(name string) ([]byte, error) {
return []byte("ssh-key"), nil
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
osWriteFile = func(name string, data []byte, perm os.FileMode) error {
return nil
}
setupFirecrackerVM()
if len(runCmdCalls) < 1 {
t.Errorf("expected VM setup start script execution, got: %v", runCmdCalls)
}
}
func TestMacosGoEdgeCases(t *testing.T) {
defer resetMocks()
isMacOS = true
hasCmd = func(name string) bool { return false }
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 1}
}
if ensureVirtualBox() {
t.Error("expected ensureVirtualBox to fail when brew install fails")
}
resetMocks()
isMacOS = true
hasCmd = func(name string) bool {
return false
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 0}
}
if ensureVirtualBox() {
t.Error("expected ensureVirtualBox to fail when VBoxManage still not in PATH")
}
resetMocks()
isMacOS = true
hasCmd = func(name string) bool { return false }
runCmd = func(argv []string, opts CmdOpts) CmdResult { return CmdResult{ExitCode: 1} }
if path := provisionVirtualBoxVM("qcow", "iso"); path != "" {
t.Errorf("expected empty path when VirtualBox setup fails, got %q", path)
}
resetMocks()
isMacOS = true
t.Setenv("HOME", "/my/home")
hasCmd = func(name string) bool { return name == "VBoxManage" }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 1}, true
}
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[1] == "clonemedium" {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
if path := provisionVirtualBoxVM("qcow", "iso"); path != "" {
t.Errorf("expected empty path when clonemedium fails, got %q", path)
}
resetMocks()
isMacOS = true
t.Setenv("HOME", "/my/home")
hasCmd = func(name string) bool { return name == "VBoxManage" }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 1}, true
}
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[1] == "createvm" {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
if path := provisionVirtualBoxVM("qcow", "iso"); path != "" {
t.Errorf("expected empty path when createvm fails, got %q", path)
}
resetMocks()
isMacOS = false
setupFirecrackerVM()
resetMocks()
isMacOS = true
archName = "aarch64"
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "sw_vers" {
return CmdResult{ExitCode: 0, Stdout: []byte("14.0\n")}, true
}
return CmdResult{ExitCode: 0}, true
}
setupFirecrackerVM()
resetMocks()
isMacOS = true
archName = "x86_64"
hasCmd = func(name string) bool { return name == "VBoxManage" }
osStat = func(name string) (os.FileInfo, error) {
if strings.HasSuffix(name, "id_ed25519") {
return nil, os.ErrNotExist
}
return nil, nil
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[0] == "ssh-keygen" {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
setupFirecrackerVM()
}
func TestSetupFirecrackerVMEdgeCases(t *testing.T) {
defer resetMocks()
isMacOS = true
archName = "aarch64"
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "sw_vers" {
return CmdResult{ExitCode: 0, Stdout: []byte("15.0\n")}, true
}
if argv[0] == "sysctl" {
return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3\n")}, true
}
return CmdResult{ExitCode: 0}, true
}
osStat = func(name string) (os.FileInfo, error) {
if strings.HasSuffix(name, "fedora.qcow2") || strings.HasSuffix(name, "id_ed25519") {
return nil, os.ErrNotExist
}
return nil, nil
}
fetchText = func(url string) string { return "" }
setupFirecrackerVM()
resetMocks()
isMacOS = true
archName = "aarch64"
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "sw_vers" { return CmdResult{ExitCode: 0, Stdout: []byte("15.0\n")}, true }
if argv[0] == "sysctl" { return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3\n")}, true }
return CmdResult{ExitCode: 0}, true
}
osStat = func(name string) (os.FileInfo, error) {
if strings.HasSuffix(name, "fedora.qcow2") { return nil, os.ErrNotExist }
return nil, nil
}
fetchText = func(url string) string {
if strings.Contains(url, "releases") { return "40/" }
return "<a href=\"Fedora-Cloud-Base-40.qcow2\">Fedora-Cloud-Base-40.qcow2</a>"
}
hasCmd = func(name string) bool { return false }
download = func(url, dest string) bool { return false }
setupFirecrackerVM()
resetMocks()
isMacOS = true
archName = "aarch64"
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "sw_vers" { return CmdResult{ExitCode: 0, Stdout: []byte("15.0\n")}, true }
if argv[0] == "sysctl" { return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3\n")}, true }
return CmdResult{ExitCode: 0}, true
}
osStat = func(name string) (os.FileInfo, error) {
if strings.HasSuffix(name, "fedora.qcow2") { return nil, os.ErrNotExist }
return nil, nil
}
fetchText = func(url string) string {
if strings.Contains(url, "releases") { return "40/" }
if strings.Contains(url, "CHECKSUM") { return "mismatch-sha Fedora-Cloud-Base-40.qcow2" }
return "<a href=\"Fedora-Cloud-Base-40.qcow2\">Fedora-Cloud-Base-40.qcow2</a>"
}
download = func(url, dest string) bool { return true }
setupFirecrackerVM()
resetMocks()
isMacOS = true
archName = "aarch64"
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "sw_vers" { return CmdResult{ExitCode: 0, Stdout: []byte("15.0\n")}, true }
if argv[0] == "sysctl" { return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3\n")}, true }
return CmdResult{ExitCode: 0}, true
}
osStat = func(name string) (os.FileInfo, error) { return nil, nil }
osReadFile = func(name string) ([]byte, error) { return []byte("ssh-pubkey"), nil }
hasCmd = func(name string) bool {
if name == "qemu-system-aarch64" { return false }
return true
}
setupFirecrackerVM()
resetMocks()
isMacOS = true
archName = "aarch64"
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "sw_vers" { return CmdResult{ExitCode: 0, Stdout: []byte("15.0\n")}, true }
if argv[0] == "sysctl" { return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3\n")}, true }
return CmdResult{ExitCode: 0}, true
}
osStat = func(name string) (os.FileInfo, error) { return nil, nil }
osReadFile = func(name string) ([]byte, error) { return []byte("ssh-pubkey"), nil }
hasCmd = func(name string) bool { return true }
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if strings.HasSuffix(argv[0], "vm-start.sh") {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
setupFirecrackerVM()
}
+276 -23
View File
@@ -25,35 +25,91 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"golang.org/x/term"
)
func main() {
only := flag.String("only", "", "Install only the named section (system|flatpak|custom)")
gui := flag.Bool("gui", false, "Include GUI applications (headed environments).")
noVM := flag.Bool("no-vm", false, "macOS only: skip provisioning the Fedora-on-QEMU VM that backs the firecracker() zsh wrapper.")
flag.Parse()
runMain(os.Args)
}
func runMain(args []string) {
fs := flag.NewFlagSet(args[0], flag.ExitOnError)
only := fs.String("only", "", "Install only the named section (system|flatpak|custom)")
gui := fs.Bool("gui", false, "Include GUI applications (headed environments).")
noVM := fs.Bool("no-vm", false, "macOS only: skip provisioning the Fedora-on-QEMU VM that backs the firecracker() zsh wrapper.")
noAI := fs.Bool("no-ai", false, "Skip installation of LLM/AI CLI tools (agy, claude, codex, copilot).")
localAI := fs.Bool("local-ai", false, "Ubuntu only: Install local ML inference environment (Nvidia drivers, CUDA, Nvidia Container Toolkit, Ollama, Hugging Face CLI).")
_ = fs.Parse(args[1:])
switch *only {
case "", "system", "flatpak", "custom":
default:
fmt.Fprintf(os.Stderr, "invalid --only value %q (use system|flatpak|custom)\n", *only)
os.Exit(2)
osExit(2)
return
}
initPkgMgr()
if *localAI {
if isMacOS || pkgMgr != "apt-get" {
fmt.Fprintln(os.Stderr, "Error: --local-ai flag is only supported on Ubuntu/Debian (apt-get package manager)")
osExit(1)
return
}
}
systemPkgs := append([]string(nil), SystemPackages...)
flatpakPkgs := append([]string(nil), FlatpakPackages...)
if *localAI {
var filteredSys []string
excludeSys := map[string]bool{
"ansible": true, "ansible-core": true, "buildah": true,
"dotnet-sdk-10.0": true, "helm": true, "kubectl": true,
"minikube": true, "pulumi": true, "sops": true,
"vagrant": true, "virt-manager": true, "wireshark": true,
"zoom": true, "obs-studio": true, "webcamoid": true,
"obsidian": true,
}
for _, p := range systemPkgs {
if !excludeSys[p] {
filteredSys = append(filteredSys, p)
}
}
filteredSys = append(filteredSys, "nvidia-drivers", "cuda-toolkit", "nvidia-container-toolkit", "ollama", "huggingface-cli")
systemPkgs = filteredSys
flatpakPkgs = nil
}
custom := customPackages()
customPtrs := make([]*CustomPackage, 0, len(custom))
for i := range custom {
name := strings.ToLower(custom[i].Name)
// Drop firecracker on macOS — it's provisioned inside the Fedora VM
// (see setupFirecrackerVM), not on the host.
if isMacOS && strings.ToLower(custom[i].Name) == "firecracker" {
if isMacOS && name == "firecracker" {
continue
}
if *noAI {
if name == "agy" || name == "claude" || name == "codex" || name == "copilot" {
continue
}
}
if *localAI {
excludeCust := map[string]bool{
"go": true, "zig": true, "rustup": true, "dagger": true,
"cosign": true, "agy": true, "claude": true, "copilot": true,
"codex": true, "playwright": true,
}
if excludeCust[name] {
continue
}
}
customPtrs = append(customPtrs, &custom[i])
}
@@ -91,23 +147,109 @@ func main() {
doFlatpak := (*only == "" || *only == "flatpak") && *gui && !isMacOS
var sysCheck systemCheckResult
var flatCheck flatpakCheckResult
var custCheck customCheckResult
step := 0
if !disableProgressTracking {
step = readProgressStep()
if step >= 2 {
fmt.Println("\nBootstrap is already completed according to progress.config.")
fmt.Println("If you want to re-run, delete or reset progress.config.")
return
}
}
if *only == "" || *only == "system" {
sysCheck = checkSystemPackages(systemPkgs)
}
if doFlatpak {
flatCheck = checkFlatpakPackages(flatpakPkgs)
}
if *only == "" || *only == "custom" {
custCheck = checkCustomPackages(customPtrs)
originalSystemPkgs := systemPkgs
originalCustomPtrs := customPtrs
originalDoFlatpak := doFlatpak
originalFlatpakPkgs := flatpakPkgs
if !disableProgressTracking && step == 0 {
// Minimum required system packages: zsh, git, curl
step1Sys := map[string]bool{"zsh": true, "git": true, "curl": true}
var keptSys []string
for _, p := range systemPkgs {
if step1Sys[p] {
keptSys = append(keptSys, p)
}
}
systemPkgs = keptSys
// Minimum required custom packages: oh-my-zsh
var keptCust []*CustomPackage
for _, p := range customPtrs {
if strings.ToLower(p.Name) == "oh-my-zsh" {
keptCust = append(keptCust, p)
}
}
customPtrs = keptCust
// No flatpaks in Step 1
flatpakPkgs = nil
doFlatpak = false
} else if !disableProgressTracking && step == 1 {
// Exclude oh-my-zsh in Step 2
var keptCust []*CustomPackage
for _, p := range customPtrs {
if strings.ToLower(p.Name) != "oh-my-zsh" {
keptCust = append(keptCust, p)
}
}
customPtrs = keptCust
}
sysCheck, flatCheck, custCheck := checkAllInParallel(
*only == "" || *only == "system", systemPkgs,
doFlatpak, flatpakPkgs,
*only == "" || *only == "custom", customPtrs,
)
total := printCheckSummary(sysCheck, flatCheck, custCheck, *only)
if !disableProgressTracking && step == 0 {
// If we didn't need to install anything for step 1, and default shell is already zsh,
// we can skip step 1 and proceed to step 2 in the same run.
if total == 0 && isZshDefault() {
fmt.Println("\n[zsh/oh-my-zsh] Zsh and oh-my-zsh already installed and default shell is zsh. Proceeding to Step 2...")
if err := writeProgressStep(1); err != nil {
fmt.Fprintf(os.Stderr, "Failed to write progress: %v\n", err)
}
step = 1
// Restore original packages for Step 2
systemPkgs = originalSystemPkgs
flatpakPkgs = originalFlatpakPkgs
doFlatpak = originalDoFlatpak
// Exclude oh-my-zsh
var keptCust []*CustomPackage
for _, p := range originalCustomPtrs {
if strings.ToLower(p.Name) != "oh-my-zsh" {
keptCust = append(keptCust, p)
}
}
customPtrs = keptCust
// Re-run checks for Step 2
sysCheck, flatCheck, custCheck = checkAllInParallel(
*only == "" || *only == "system", systemPkgs,
doFlatpak, flatpakPkgs,
*only == "" || *only == "custom", customPtrs,
)
total = printCheckSummary(sysCheck, flatCheck, custCheck, *only)
}
}
if total == 0 {
if !disableProgressTracking && step == 0 {
// Zsh, git, curl and oh-my-zsh are installed, but default shell is not zsh.
ensureZshDefault()
if err := writeProgressStep(1); err != nil {
fmt.Fprintf(os.Stderr, "Failed to write progress: %v\n", err)
}
fmt.Println("\n[zsh] Default shell has been updated to zsh.")
fmt.Println("IMPORTANT: Please log out of your current session and log back in (or restart your terminal) for the shell change to take effect.")
fmt.Println("Once logged back in, please re-run the bootstrapper to complete the rest of the installation.")
osExit(0)
return
}
fmt.Println("\nAll packages already installed.")
writeRunLog()
return
@@ -115,14 +257,37 @@ func main() {
if !askYN(fmt.Sprintf("\n%d item(s) to install. Proceed? [y/N] ", total)) {
fmt.Fprintln(os.Stderr, "Aborted.")
os.Exit(1)
osExit(1)
return
}
checkSudo()
promptGitHubToken()
if !disableProgressTracking && step == 0 {
// Run only Step 1: minimum required system packages, default shell, minimum custom packages (oh-my-zsh)
if *only == "" || *only == "system" {
installSystemPackages(sysCheck.toInstallRegular, sysCheck.toInstallSpecial)
ensureZshDefault()
}
if *only == "" || *only == "custom" {
installCustomPackages(custCheck.toInstall)
}
if err := writeProgressStep(1); err != nil {
fmt.Fprintf(os.Stderr, "Failed to write progress: %v\n", err)
}
fmt.Println("\n[zsh/oh-my-zsh] Step 1 of bootstrap completed successfully.")
fmt.Println("IMPORTANT: Please log out of your current session and log back in (or restart your terminal) so that zsh becomes your active shell.")
fmt.Println("Once logged back in, please re-run the bootstrapper to complete the rest of the installation.")
osExit(0)
return
}
if *only == "" || *only == "system" {
installSystemPackages(sysCheck.toInstallRegular, sysCheck.toInstallSpecial)
ensureZshDefault()
if disableProgressTracking {
ensureZshDefault()
}
}
if doFlatpak {
@@ -144,6 +309,7 @@ func main() {
if isMacOS && !*noVM {
setupFirecrackerVM()
}
ensureLibreOfficeAutoSave()
}
if pyenvWG != nil {
@@ -151,37 +317,124 @@ func main() {
pyenvWG.Wait()
}
if !disableProgressTracking {
if err := writeProgressStep(2); err != nil {
fmt.Fprintf(os.Stderr, "Failed to write progress: %v\n", err)
}
}
writeRunLog()
printNotices()
fmt.Println("\nDone.")
if hasErrors() {
osExit(1)
return
}
home, _ := os.UserHomeDir()
zshrc := filepath.Join(home, ".zshrc")
if hasCmd("zsh") {
if _, err := os.Stat(zshrc); err == nil {
if _, err := osStat(zshrc); err == nil {
fmt.Println("\nSourcing ~/.zshrc ...")
runShell(fmt.Sprintf("zsh -c 'source %s'", zshrc), CmdOpts{})
}
}
}
var readPassword = func() ([]byte, error) {
return term.ReadPassword(int(os.Stdin.Fd()))
}
// 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 := readPassword()
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() {
if os.Geteuid() == 0 {
if isMacOS {
fmt.Fprintln(os.Stderr, "Do not run this with sudo on macOS — Homebrew refuses to run as root. "+
"Re-run as your regular user; the tool will request sudo for the operations that need it.")
os.Exit(1)
osExit(1)
return
}
return
}
if !hasCmd("sudo") {
fmt.Fprintln(os.Stderr, "sudo is required but not installed.")
os.Exit(1)
osExit(1)
return
}
fmt.Println("Validating sudo access ...")
r := runCmd([]string{"sudo", "-v"}, CmdOpts{Timeout: 2 * time.Minute})
if r.ExitCode != 0 {
fmt.Fprintln(os.Stderr, "sudo authentication failed.")
os.Exit(1)
osExit(1)
return
}
}
var progressConfigPathReal = func() string {
exe, err := os.Executable()
if err != nil {
return "progress.config"
}
return filepath.Join(filepath.Dir(exe), "progress.config")
}
var progressConfigPath = progressConfigPathReal
func readProgressStep() int {
path := progressConfigPath()
data, err := osReadFile(path)
if err != nil {
return 0
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "step=") {
stepStr := strings.TrimPrefix(line, "step=")
if step, err := strconv.Atoi(stepStr); err == nil {
return step
}
}
}
return 0
}
func writeProgressStep(step int) error {
path := progressConfigPath()
content := fmt.Sprintf("step=%d\n", step)
return osWriteFile(path, []byte(content), 0o644)
}
+169
View File
@@ -0,0 +1,169 @@
package main
import (
"os"
"strings"
"testing"
"time"
)
func TestRunMainInvalidOnly(t *testing.T) {
defer resetMocks()
var exited bool
var exitCode int
osExit = func(code int) {
exited = true
exitCode = code
}
runMain([]string{"bootstrap_environment", "--only", "invalid"})
if !exited {
t.Error("expected runMain with invalid --only value to exit")
}
if exitCode != 2 {
t.Errorf("expected exit code 2, got %d", exitCode)
}
}
func TestRunMainAllInstalled(t *testing.T) {
defer resetMocks()
// All packages are already installed (total packages to install = 0)
osStat = func(name string) (os.FileInfo, error) {
return nil, nil // all custom paths exist
}
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "rpm" || argv[0] == "dpkg-query" || argv[0] == "pacman" {
return CmdResult{ExitCode: 0, Stdout: []byte("install ok installed")}, true
}
if argv[0] == "flatpak" {
return CmdResult{ExitCode: 0}, true
}
return CmdResult{ExitCode: 0}, true
}
hasCmd = func(name string) bool {
return true
}
var exited bool
osExit = func(code int) {
exited = true
}
// We only run custom to keep it short & avoid other logic dependencies
runMain([]string{"bootstrap_environment", "--only", "custom"})
if exited {
t.Error("expected program to complete successfully without exiting")
}
}
func TestRunMainInstallAbort(t *testing.T) {
defer resetMocks()
// Simulating some packages to install, but user selects N
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist // custom paths missing -> need install
}
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 1}, true // packages not installed
}
hasCmd = func(name string) bool {
return true
}
// Mock stdin to say "n" to abort the prompt "Proceed? [y/N]"
stdin = strings.NewReader("n\n")
var exited bool
var exitCode int
osExit = func(code int) {
exited = true
exitCode = code
}
runMain([]string{"bootstrap_environment", "--only", "custom"})
if !exited {
t.Error("expected program to exit on user abort")
}
if exitCode != 1 {
t.Errorf("expected exit code 1, got %d", exitCode)
}
}
func TestRunMainMacos(t *testing.T) {
defer resetMocks()
isMacOS = true
pkgMgr = "brew"
osStat = func(name string) (os.FileInfo, error) {
return nil, nil // brew etc exist
}
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 0}, true
}
hasCmd = func(name string) bool {
return true
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 0}
}
runShell = func(cmd string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 0}
}
var exited bool
osExit = func(code int) {
exited = true
}
// Run with --only custom, --no-vm, --no-ai
runMain([]string{"bootstrap_environment", "--only", "custom", "--no-vm", "--no-ai"})
if exited {
t.Error("expected program to complete successfully on macOS mock run")
}
}
func TestRunMainLocalAIValidation(t *testing.T) {
defer resetMocks()
// 1. macOS validation
isMacOS = true
pkgMgr = "brew"
var exited bool
var exitCode int
osExit = func(code int) {
exited = true
exitCode = code
}
runMain([]string{"bootstrap_environment", "--local-ai"})
if !exited {
t.Error("expected runMain with --local-ai on macOS to exit")
}
if exitCode != 1 {
t.Errorf("expected exit code 1, got %d", exitCode)
}
// 2. Linux non-apt validation
resetMocks()
isMacOS = false
pkgMgr = "dnf"
exited = false
osExit = func(code int) {
exited = true
exitCode = code
}
runMain([]string{"bootstrap_environment", "--local-ai"})
if !exited {
t.Error("expected runMain with --local-ai on Fedora/dnf to exit")
}
if exitCode != 1 {
t.Errorf("expected exit code 1, got %d", exitCode)
}
}
+31 -7
View File
@@ -17,9 +17,33 @@ const httpClientTimeout = 30 * time.Minute
var httpClient = &http.Client{Timeout: httpClientTimeout}
// download streams url -> dest. Returns true on success.
func download(url, dest string) bool {
fmt.Printf(" Downloading %s ...\n", filepath.Base(url))
// downloadReal streams url -> dest. Returns true on success.
func downloadReal(url, dest string) bool {
taskPrintf(" Downloading %s ...\n", filepath.Base(url))
dir, file := filepath.Split(dest)
dir = filepath.Clean(dir)
// Try aria2c first if available
if hasCmd("aria2c") {
res := runCmd([]string{"aria2c", "-x", "16", "-s", "16", "-k", "1M", "-d", dir, "-o", file, url}, CmdOpts{Out: taskOut()})
if res.OK() {
return true
}
taskPrintf(" [WARN] aria2c download failed for %s, falling back to curl ...\n", url)
}
// Fallback to curl
if hasCmd("curl") {
res := runCmd([]string{"curl", "-L", "--fail", "-o", dest, url}, CmdOpts{Out: taskOut()})
if res.OK() {
return true
}
taskPrintf(" [WARN] curl download failed for %s ...\n", url)
}
// Final fallback: Go built-in HTTP client
taskPrintf(" Falling back to built-in HTTP client for %s ...\n", url)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
errLog(fmt.Sprintf("Download failed for %s: %v", url, err))
@@ -48,9 +72,9 @@ func download(url, dest string) bool {
return true
}
// fetchJSON GETs url with the GitHub API Accept header and decodes the body
// fetchJSONReal GETs url with the GitHub API Accept header and decodes the body
// into v. Returns true on success.
func fetchJSON(url string, v any) bool {
func fetchJSONReal(url string, v any) bool {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
errLog(fmt.Sprintf("API request failed for %s: %v", url, err))
@@ -77,8 +101,8 @@ func fetchJSON(url string, v any) bool {
return true
}
// fetchText returns the trimmed body of url. Returns empty string on failure.
func fetchText(url string) string {
// fetchTextReal returns the trimmed body of url. Returns empty string on failure.
func fetchTextReal(url string) string {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
errLog(fmt.Sprintf("Fetch failed for %s: %v", url, err))
+359
View File
@@ -0,0 +1,359 @@
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"testing"
)
type mockTripper struct {
roundTripFunc func(req *http.Request) (*http.Response, error)
}
func (m *mockTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return m.roundTripFunc(req)
}
func TestDownloadReal(t *testing.T) {
defer resetMocks()
// Mock HTTP client
oldTransport := httpClient.Transport
defer func() { httpClient.Transport = oldTransport }()
httpClient.Transport = &mockTripper{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
if req.URL.String() == "https://example.com/file" {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString("hello download")),
}, nil
}
return &http.Response{
StatusCode: 404,
Body: io.NopCloser(bytes.NewBufferString("not found")),
}, nil
},
}
tmpDir := t.TempDir()
destFile := filepath.Join(tmpDir, "out.txt")
// Successful download
success := downloadReal("https://example.com/file", destFile)
if !success {
t.Fatal("expected download to succeed")
}
data, err := os.ReadFile(destFile)
if err != nil {
t.Fatalf("failed to read downloaded file: %v", err)
}
if string(data) != "hello download" {
t.Errorf("expected 'hello download', got %q", string(data))
}
// Failed download (404)
failDest := filepath.Join(tmpDir, "out_fail.txt")
successFail := downloadReal("https://example.com/nonexistent", failDest)
if successFail {
t.Error("expected download to fail with 404")
}
}
func TestFetchJSONReal(t *testing.T) {
defer resetMocks()
oldTransport := httpClient.Transport
defer func() { httpClient.Transport = oldTransport }()
httpClient.Transport = &mockTripper{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
if req.URL.String() == "https://example.com/api" {
// Verify auth header if token is set
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"key": "value"}`)),
}, nil
}
return &http.Response{
StatusCode: 500,
Body: io.NopCloser(bytes.NewBufferString("internal error")),
}, nil
},
}
type MockResponse struct {
Key string `json:"key"`
}
var res MockResponse
success := fetchJSONReal("https://example.com/api", &res)
if !success {
t.Fatal("expected fetchJSON to succeed")
}
if res.Key != "value" {
t.Errorf("expected Key to be 'value', got %q", res.Key)
}
successFail := fetchJSONReal("https://example.com/bad", &res)
if successFail {
t.Error("expected fetchJSON to fail with 500")
}
}
func TestFetchTextReal(t *testing.T) {
defer resetMocks()
oldTransport := httpClient.Transport
defer func() { httpClient.Transport = oldTransport }()
httpClient.Transport = &mockTripper{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
if req.URL.String() == "https://example.com/text" {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(" Adoptium Latest \n")),
}, nil
}
return &http.Response{
StatusCode: 403,
Body: io.NopCloser(bytes.NewBufferString("forbidden")),
}, nil
},
}
text := fetchTextReal("https://example.com/text")
if text != "Adoptium Latest" {
t.Errorf("expected trimmed text 'Adoptium Latest', got %q", text)
}
textFail := fetchTextReal("https://example.com/forbidden")
if textFail != "" {
t.Errorf("expected empty string for failed request, got %q", textFail)
}
}
func TestSha256Of(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "hash.txt")
if err := os.WriteFile(path, []byte("hello sha256"), 0644); err != nil {
t.Fatal(err)
}
// Hex of sha256("hello sha256") is 433855b7d2b96c23a6f60e70c655eb4305e8806b682a9596a200642f947259b1
expected := "433855b7d2b96c23a6f60e70c655eb4305e8806b682a9596a200642f947259b1"
actual, err := sha256Of(path)
if err != nil {
t.Fatalf("failed to calculate hash: %v", err)
}
if actual != expected {
t.Errorf("expected %s, got %s", expected, actual)
}
// Nonexistent file
_, errNonexistent := sha256Of(filepath.Join(tmpDir, "nonexistent"))
if errNonexistent == nil {
t.Error("expected error for nonexistent file")
}
}
func TestNetRealErrors(t *testing.T) {
defer resetMocks()
oldTransport := httpClient.Transport
defer func() { httpClient.Transport = oldTransport }()
// 1. NewRequest error
if downloadReal("%%%", "dest") {
t.Error("expected downloadReal to fail for invalid URL")
}
if fetchJSONReal("%%%", nil) {
t.Error("expected fetchJSONReal to fail for invalid URL")
}
if fetchTextReal("%%%") != "" {
t.Error("expected fetchTextReal to fail for invalid URL")
}
// 2. Transport Do error
httpClient.Transport = &mockTripper{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("connection refused")
},
}
if downloadReal("https://example.com/file", "dest") {
t.Error("expected downloadReal to fail on connection error")
}
if fetchJSONReal("https://example.com/api", nil) {
t.Error("expected fetchJSONReal to fail on connection error")
}
if fetchTextReal("https://example.com/text") != "" {
t.Error("expected fetchTextReal to fail on connection error")
}
// 3. os.Create error
httpClient.Transport = &mockTripper{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString("ok")),
}, nil
},
}
if downloadReal("https://example.com/file", "/nonexistent-dir/dest") {
t.Error("expected downloadReal to fail when creating destination file fails")
}
// 4. json Decode error
httpClient.Transport = &mockTripper{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString("invalid json")),
}, nil
},
}
var v any
if fetchJSONReal("https://example.com/api", &v) {
t.Error("expected fetchJSONReal to fail on invalid JSON")
}
// 5. io.ReadAll error
httpClient.Transport = &mockTripper{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(&errReader{}),
}, nil
},
}
if fetchTextReal("https://example.com/text") != "" {
t.Error("expected fetchTextReal to fail on read error")
}
}
func TestDownloadRealFallbackChain(t *testing.T) {
defer resetMocks()
tmpDir := t.TempDir()
destFile := filepath.Join(tmpDir, "out.txt")
// Case 1: aria2c works
var aria2cCalled bool
var curlCalled bool
hasCmd = func(name string) bool {
if name == "aria2c" || name == "curl" {
return true
}
return false
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[0] == "aria2c" {
aria2cCalled = true
_ = os.WriteFile(destFile, []byte("aria2c content"), 0644)
return CmdResult{ExitCode: 0}
}
if argv[0] == "curl" {
curlCalled = true
return CmdResult{ExitCode: 0}
}
return CmdResult{ExitCode: 1}
}
success := downloadReal("https://example.com/file", destFile)
if !success {
t.Fatal("expected download via aria2c to succeed")
}
if !aria2cCalled {
t.Error("expected aria2c to be called")
}
if curlCalled {
t.Error("expected curl NOT to be called when aria2c succeeds")
}
// Case 2: aria2c fails, falls back to curl, curl succeeds
resetMocks()
aria2cCalled = false
curlCalled = false
hasCmd = func(name string) bool {
if name == "aria2c" || name == "curl" {
return true
}
return false
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[0] == "aria2c" {
aria2cCalled = true
return CmdResult{ExitCode: 1, Err: fmt.Errorf("aria2c simulated error")}
}
if argv[0] == "curl" {
curlCalled = true
_ = os.WriteFile(destFile, []byte("curl content"), 0644)
return CmdResult{ExitCode: 0}
}
return CmdResult{ExitCode: 1}
}
success = downloadReal("https://example.com/file", destFile)
if !success {
t.Fatal("expected download to succeed via curl fallback")
}
if !aria2cCalled {
t.Error("expected aria2c to be attempted")
}
if !curlCalled {
t.Error("expected curl to be attempted after aria2c failed")
}
// Case 3: aria2c fails, curl fails, falls back to Go HTTP client
resetMocks()
aria2cCalled = false
curlCalled = false
hasCmd = func(name string) bool {
if name == "aria2c" || name == "curl" {
return true
}
return false
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[0] == "aria2c" {
aria2cCalled = true
return CmdResult{ExitCode: 1, Err: fmt.Errorf("aria2c simulated error")}
}
if argv[0] == "curl" {
curlCalled = true
return CmdResult{ExitCode: 1, Err: fmt.Errorf("curl simulated error")}
}
return CmdResult{ExitCode: 1}
}
oldTransport := httpClient.Transport
defer func() { httpClient.Transport = oldTransport }()
httpClient.Transport = &mockTripper{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString("go http content")),
}, nil
},
}
success = downloadReal("https://example.com/file", destFile)
if !success {
t.Fatal("expected download to succeed via Go http fallback")
}
if !aria2cCalled {
t.Error("expected aria2c to be attempted")
}
if !curlCalled {
t.Error("expected curl to be attempted")
}
data, _ := os.ReadFile(destFile)
if string(data) != "go http content" {
t.Errorf("expected file content to be 'go http content', got %q", string(data))
}
}
+50 -2
View File
@@ -10,10 +10,11 @@ package main
// URL templates use the substitutions described in formatURL.
var SystemPackages = []string{
"aria2",
"age",
"ansible",
"ansible-core",
"aria2",
"bashtop",
"btm",
"build-essential",
"buildah",
"containerd.io",
@@ -29,10 +30,14 @@ var SystemPackages = []string{
"git",
"github-desktop",
"google-chrome-stable",
"helm",
"jq",
"kubectl",
"lazygit",
"lua",
"minisign",
"minikube",
"nmap",
"obs-studio",
"obsidian",
"pipx",
@@ -42,8 +47,11 @@ var SystemPackages = []string{
"qemu",
"restic",
"rg",
"semgrep",
"shutter",
"sops",
"temurin-25-jdk",
"tmux",
"vagrant",
"virt-manager",
"vivaldi-stable",
@@ -52,6 +60,8 @@ var SystemPackages = []string{
"yt-dlp",
"zoom",
"zsh",
"fzf",
"fd",
"bzip2",
"bzip2-devel",
"curl",
@@ -136,5 +146,43 @@ func customPackages() []CustomPackage {
{Name: "pyenv"},
{Name: "pip"},
{Name: "oh-my-zsh"},
{Name: "agy"},
{Name: "claude"},
{Name: "codex"},
{Name: "copilot"},
{Name: "playwright"},
{Name: "mdts"},
{Name: "gh-repo-bootstrap"},
{
Name: "rustup",
Version: "latest",
URLTemplate: "https://static.rust-lang.org/rustup/dist/{arch_rustup}-{os_rustup}/rustup-init",
},
{
Name: "yq",
Version: "4.44.1",
URLTemplate: "https://github.com/mikefarah/yq/releases/download/v{version}/yq_{os_go}_{arch_go}",
FetchLatest: "yq",
},
{
Name: "dagger",
Version: "0.11.4",
URLTemplate: "https://github.com/dagger/dagger/releases/download/v{version}/dagger_v{version}_{os_go}_{arch_go}.tar.gz",
},
{
Name: "trivy",
Version: "0.70.0",
URLTemplate: "https://github.com/aquasecurity/trivy/releases/download/v{version}/trivy_{version}_{os_trivy}-{arch_trivy}.tar.gz",
},
{
Name: "cosign",
Version: "2.2.4",
URLTemplate: "https://github.com/sigstore/cosign/releases/download/v{version}/cosign-{os_go}-{arch_go}",
},
{
Name: "gitleaks",
Version: "8.18.2",
URLTemplate: "https://github.com/gitleaks/gitleaks/releases/download/v{version}/gitleaks_{version}_{os_go}_{arch_gitleaks}.tar.gz",
},
}
}
+170
View File
@@ -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)
}
+356
View File
@@ -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)
}
}
+29 -3
View File
@@ -13,8 +13,31 @@ func initPkgMgr() {
pkgMgr = detectPkgMgr()
isRHELFamily = detectRHELFamily()
isArchFamily = detectArchFamily()
ensureWhichInstalled()
}
func ensureWhichInstalled() {
if hasCmd("which") {
return
}
fmt.Println("[which] 'which' is not installed. Installing it as a prerequisite...")
var res CmdResult
switch pkgMgr {
case "pacman":
res = runCmd([]string{"pacman", "-Sy", "--noconfirm", "--needed", "which"}, CmdOpts{AsSudo: true})
case "brew":
res = runCmd([]string{"brew", "install", "which"}, CmdOpts{})
default:
res = runCmd([]string{pkgMgr, "install", "-y", "which"}, CmdOpts{AsSudo: true})
}
if !res.OK() {
fmt.Fprintf(os.Stderr, "Warning: failed to install 'which' prerequisite: %v\n", res.Err)
} else {
fmt.Println("[which] 'which' successfully installed.")
}
}
func detectPkgMgr() string {
if isMacOS {
// brew may not be installed yet — ensureHomebrew runs before any
@@ -27,7 +50,7 @@ func detectPkgMgr() string {
}
}
fmt.Fprintln(os.Stderr, "No supported package manager found (expected dnf, apt-get, pacman, or brew on macOS).")
os.Exit(1)
osExit(1)
return ""
}
@@ -54,6 +77,7 @@ var packageOverrides = map[string]map[string]overrideEntry{
"webcamoid": skipOverride(),
},
"apt-get": {
"fd": replace("fd-find"),
"ffmpeg-free": replace("ffmpeg"),
"lua": replace("lua5.4"),
"qemu": replace("qemu-system"),
@@ -82,7 +106,8 @@ var packageOverrides = map[string]map[string]overrideEntry{
"containerd.io": replace("containerd"),
"docker-ce": replace("docker"),
"docker-ce-cli": skipOverride(), // covered by docker
"docker-ce-rootless-extras": replace("docker-rootless-extras"),
"docker-ce-rootless-extras": skipOverride(), // AUR-only
"pipx": replace("python-pipx"),
"docker-buildx-plugin": replace("docker-buildx"),
"docker-compose-plugin": replace("docker-compose"),
"dotnet-sdk-10.0": replace("dotnet-sdk"),
@@ -98,7 +123,7 @@ var packageOverrides = map[string]map[string]overrideEntry{
"rg": replace("ripgrep"),
"shutter": skipOverride(), // AUR-only
"temurin-25-jdk": replace("jdk-openjdk"),
"vagrant": replace("vagrant"),
"vagrant": skipOverride(), // AUR-only
"vivaldi-stable": replace("vivaldi"),
"webcamoid": skipOverride(), // AUR-only; provided via Flatpak when --gui
"wireshark": replace("wireshark-qt"),
@@ -141,6 +166,7 @@ var packageOverrides = map[string]map[string]overrideEntry{
"rg": replace("ripgrep"),
"temurin-25-jdk": replace("temurin"),
"vivaldi-stable": replace("vivaldi"),
"buildah": skipOverride(),
"shutter": skipOverride(),
"virt-manager": skipOverride(),
"webcamoid": skipOverride(),
+218
View File
@@ -0,0 +1,218 @@
package main
import (
"testing"
"time"
)
func TestDetectPkgMgr(t *testing.T) {
defer resetMocks()
// Case 1: macOS should return brew
isMacOS = true
if mgr := detectPkgMgr(); mgr != "brew" {
t.Errorf("expected brew on macOS, got %q", mgr)
}
// Case 2: Linux with dnf
isMacOS = false
hasCmd = func(name string) bool {
return name == "dnf"
}
if mgr := detectPkgMgr(); mgr != "dnf" {
t.Errorf("expected dnf, got %q", mgr)
}
// Case 3: Linux with apt-get
hasCmd = func(name string) bool {
return name == "apt-get"
}
if mgr := detectPkgMgr(); mgr != "apt-get" {
t.Errorf("expected apt-get, got %q", mgr)
}
// Case 4: Linux with pacman
hasCmd = func(name string) bool {
return name == "pacman"
}
if mgr := detectPkgMgr(); mgr != "pacman" {
t.Errorf("expected pacman, got %q", mgr)
}
// Case 5: No package manager found (exits)
hasCmd = func(name string) bool {
return false
}
var exited bool
var exitCode int
osExit = func(code int) {
exited = true
exitCode = code
}
detectPkgMgr()
if !exited || exitCode != 1 {
t.Errorf("expected exit with code 1, exited=%v code=%d", exited, exitCode)
}
}
func TestResolveSystemPkgs(t *testing.T) {
defer resetMocks()
pkgMgr = "apt-get"
resolved, skipped := resolveSystemPkgs([]string{"ffmpeg-free", "lua", "podman", "docker-compose"})
// ffmpeg-free -> ffmpeg, lua -> lua5.4, podman -> podman, docker-compose is not overridden for apt-get
expectedResolved := []string{"ffmpeg", "lua5.4", "podman", "docker-compose"}
if len(resolved) != len(expectedResolved) {
t.Fatalf("expected resolved length %d, got %d", len(expectedResolved), len(resolved))
}
for i, r := range resolved {
if r != expectedResolved[i] {
t.Errorf("at index %d: expected %q, got %q", i, expectedResolved[i], r)
}
}
if len(skipped) != 0 {
t.Errorf("expected no skipped packages, got %v", skipped)
}
// Test skip override
pkgMgr = "dnf"
resolved, skipped = resolveSystemPkgs([]string{"docker-compose", "rg"})
// docker-compose -> skipped, rg -> ripgrep
if len(resolved) != 1 || resolved[0] != "ripgrep" {
t.Errorf("expected resolution to [ripgrep], got %v", resolved)
}
if len(skipped) != 1 || skipped[0] != "docker-compose" {
t.Errorf("expected skipped to be [docker-compose], got %v", skipped)
}
// brew: buildah -> skipped
pkgMgr = "brew"
resolved, skipped = resolveSystemPkgs([]string{"buildah", "rg"})
if len(resolved) != 1 || resolved[0] != "ripgrep" {
t.Errorf("expected [ripgrep], got %v", resolved)
}
// pacman: docker-ce-rootless-extras and vagrant are AUR-only -> skipped;
// pipx is replaced with python-pipx.
pkgMgr = "pacman"
resolved, skipped = resolveSystemPkgs([]string{"docker-ce-rootless-extras", "vagrant", "pipx", "rg"})
if len(resolved) != 2 || resolved[0] != "python-pipx" || resolved[1] != "ripgrep" {
t.Errorf("expected [python-pipx ripgrep], got %v", resolved)
}
if len(skipped) != 2 || skipped[0] != "docker-ce-rootless-extras" || skipped[1] != "vagrant" {
t.Errorf("expected skipped [docker-ce-rootless-extras vagrant], got %v", skipped)
}
}
func TestIsSystemPkgInstalled(t *testing.T) {
defer resetMocks()
// Case dnf: rpm -q
pkgMgr = "dnf"
var probeArgv []string
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
probeArgv = argv
return CmdResult{ExitCode: 0}, true
}
if !isSystemPkgInstalled("git") {
t.Error("expected true when rpm returns 0")
}
if len(probeArgv) < 3 || probeArgv[0] != "rpm" || probeArgv[2] != "git" {
t.Errorf("unexpected probe argv: %v", probeArgv)
}
// Case apt-get: dpkg-query
pkgMgr = "apt-get"
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
probeArgv = argv
return CmdResult{ExitCode: 0, Stdout: []byte("install ok installed")}, true
}
if !isSystemPkgInstalled("git") {
t.Error("expected true when dpkg-query output contains install ok installed")
}
if len(probeArgv) < 4 || probeArgv[0] != "dpkg-query" {
t.Errorf("unexpected probe argv: %v", probeArgv)
}
// Case pacman: pacman -Qi
pkgMgr = "pacman"
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
probeArgv = argv
return CmdResult{ExitCode: 0}, true
}
if !isSystemPkgInstalled("git") {
t.Error("expected true when pacman returns 0")
}
// Case brew: brew list --formula / --cask
pkgMgr = "brew"
hasCmd = func(name string) bool {
return name == "brew"
}
var probeCalls [][]string
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
probeCalls = append(probeCalls, argv)
if argv[2] == "--formula" {
return CmdResult{ExitCode: 1}, true
}
return CmdResult{ExitCode: 0}, true // succeeds on second call
}
if !isSystemPkgInstalled("git") {
t.Error("expected true when brew list succeeds")
}
if len(probeCalls) != 2 {
t.Errorf("expected 2 brew calls, got %d", len(probeCalls))
}
}
func TestIsFlatpakInstalled(t *testing.T) {
defer resetMocks()
hasCmd = func(name string) bool {
return name == "flatpak"
}
var probeArgv []string
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
probeArgv = argv
return CmdResult{ExitCode: 0}, true
}
if !isFlatpakInstalled("org.gimp.GIMP") {
t.Error("expected true")
}
if len(probeArgv) < 3 || probeArgv[0] != "flatpak" || probeArgv[2] != "org.gimp.GIMP" {
t.Errorf("unexpected probe argv: %v", probeArgv)
}
}
func TestPkgMgrEdgeCases(t *testing.T) {
defer resetMocks()
pkgMgr = "brew"
hasCmd = func(name string) bool { return false }
if isSystemPkgInstalled("git") {
t.Error("expected false when brew is not installed")
}
resetMocks()
pkgMgr = "brew"
hasCmd = func(name string) bool { return true }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 1}, true
}
if isSystemPkgInstalled("git") {
t.Error("expected false when brew list fails")
}
resetMocks()
pkgMgr = "unsupported"
if isSystemPkgInstalled("git") {
t.Error("expected false for unsupported package manager")
}
resetMocks()
hasCmd = func(name string) bool { return false }
if isFlatpakInstalled("org.gimp.GIMP") {
t.Error("expected false when flatpak command not found")
}
}
+191 -38
View File
@@ -17,12 +17,12 @@ import (
// ── pyenv / Python ──────────────────────────────────────────────────────
func installPyenv() {
fmt.Println(" Installing pyenv via curl ...")
if !runShell("curl https://pyenv.run | bash", CmdOpts{}).OK() {
taskPrintln(" Installing pyenv via curl ...")
if !runShell("curl https://pyenv.run | bash", CmdOpts{Out: taskOut()}).OK() {
errLog("pyenv installation failed")
return
}
fmt.Println(" pyenv installed to ~/.pyenv")
taskPrintln(" pyenv installed to ~/.pyenv")
}
func python3DecimalOK() bool {
@@ -46,6 +46,7 @@ func fixPython3Decimal() bool {
}
func installPip() {
out := taskOut()
if !hasCmd("python3") {
errLog("python3 is not installed — cannot install pip")
return
@@ -53,7 +54,7 @@ func installPip() {
if !python3DecimalOK() {
warn("Python 3 _decimal C extension failed to import — attempting fix ...")
if fixPython3Decimal() {
fmt.Println(" Python 3 _decimal extension restored.")
taskPrintln(" Python 3 _decimal extension restored.")
} else {
errLog("Python 3 _decimal C extension could not be fixed. " +
"Run: sudo apt-get install python3-full (Debian/Ubuntu), " +
@@ -63,19 +64,19 @@ func installPip() {
}
}
fmt.Println(" Bootstrapping pip via 'python3 -m ensurepip --upgrade' ...")
bootstrap := runCmd([]string{"python3", "-m", "ensurepip", "--upgrade"}, CmdOpts{AsSudo: true})
taskPrintln(" Bootstrapping pip via 'python3 -m ensurepip --upgrade' ...")
bootstrap := runCmd([]string{"python3", "-m", "ensurepip", "--upgrade"}, CmdOpts{AsSudo: true, Out: out})
if !bootstrap.OK() {
switch pkgMgr {
case "apt-get":
warn("ensurepip unavailable in system Python — installing python3-pip via apt-get")
if !runCmd([]string{"apt-get", "install", "-y", "python3-pip"}, CmdOpts{AsSudo: true}).OK() {
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")
return
}
case "pacman":
warn("ensurepip unavailable in system Python — installing python-pip via pacman")
if !runCmd([]string{"pacman", "-S", "--noconfirm", "--needed", "python-pip"}, CmdOpts{AsSudo: true}).OK() {
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")
return
}
@@ -84,8 +85,8 @@ func installPip() {
return
}
}
fmt.Println(" Upgrading pip to the latest version ...")
upgrade := runCmd([]string{"python3", "-m", "pip", "install", "--upgrade", "pip"}, CmdOpts{AsSudo: true})
taskPrintln(" Upgrading pip to the latest version ...")
upgrade := runCmd([]string{"python3", "-m", "pip", "install", "--upgrade", "pip"}, CmdOpts{AsSudo: true, Out: out})
if !upgrade.OK() {
warn("pip self-upgrade failed (likely PEP 668 externally-managed); ensurepip-provided pip remains")
}
@@ -138,11 +139,11 @@ func latestStablePython(pyenvBin string) string {
func ensurePythonLatest() *sync.WaitGroup {
home, _ := os.UserHomeDir()
pyenvDir := filepath.Join(home, ".pyenv")
if _, err := os.Stat(pyenvDir); err != nil {
if _, err := osStat(pyenvDir); err != nil {
return nil
}
pyenvBin := filepath.Join(pyenvDir, "bin", "pyenv")
if _, err := os.Stat(pyenvBin); err != nil {
if _, err := osStat(pyenvBin); err != nil {
warn(fmt.Sprintf("pyenv binary not found at %s", pyenvBin))
return nil
}
@@ -212,17 +213,17 @@ func installNVM() {
return
}
installURL := fmt.Sprintf("https://raw.githubusercontent.com/nvm-sh/nvm/%s/install.sh", version)
fmt.Printf(" Installing NVM %s via curl ...\n", version)
if !runShell(fmt.Sprintf("curl -o- %s | bash", installURL), CmdOpts{}).OK() {
taskPrintf(" Installing NVM %s via curl ...\n", version)
if !runShell(fmt.Sprintf("curl -o- %s | bash", installURL), CmdOpts{Out: taskOut()}).OK() {
errLog("NVM installation failed")
return
}
fmt.Printf(" NVM %s installed to ~/.nvm\n", version)
taskPrintf(" NVM %s installed to ~/.nvm\n", version)
}
func ensureNodeLTS() {
home, _ := os.UserHomeDir()
if _, err := os.Stat(filepath.Join(home, ".nvm")); err != nil {
if _, err := osStat(filepath.Join(home, ".nvm")); err != nil {
return
}
check := runShell(`bash -c "source ~/.nvm/nvm.sh 2>/dev/null && nvm version lts/* 2>/dev/null"`,
@@ -243,6 +244,16 @@ func ensureNodeLTS() {
if !runShell(`bash -c "source ~/.nvm/nvm.sh && nvm alias default 'lts/*' && nvm use --lts"`, CmdOpts{}).OK() {
errLog("Setting nvm default to LTS failed")
}
fmt.Println("[NVM] Enabling corepack and installing latest pnpm ...")
if !runShell(`bash -c 'source ~/.nvm/nvm.sh && corepack enable && corepack prepare pnpm@latest --activate'`, CmdOpts{}).OK() {
errLog("Failed to enable corepack and prepare pnpm")
return
}
fmt.Println("[pnpm] Running pnpm setup to configure PATH ...")
if !runShell(`bash -c 'export SHELL=/bin/bash && source ~/.nvm/nvm.sh && pnpm setup'`, CmdOpts{}).OK() {
errLog("pnpm setup failed")
}
}
// ── oh-my-zsh ───────────────────────────────────────────────────────────
@@ -258,19 +269,19 @@ func installOhMyZsh() {
}
home, _ := os.UserHomeDir()
target := filepath.Join(home, ".oh-my-zsh")
if _, err := os.Stat(target); err == nil {
fmt.Printf(" oh-my-zsh already present at %s; updating theme only\n", target)
if _, err := osStat(target); err == nil {
taskPrintf(" oh-my-zsh already present at %s; updating theme only\n", target)
} 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`
if !runShell(installer, CmdOpts{}).OK() {
if !runShell(installer, CmdOpts{Out: taskOut()}).OK() {
errLog("oh-my-zsh installer failed")
return
}
}
zshrc := filepath.Join(home, ".zshrc")
data, err := os.ReadFile(zshrc)
data, err := osReadFile(zshrc)
if err != nil {
warn("~/.zshrc not present after oh-my-zsh install; cannot set theme")
return
@@ -284,13 +295,13 @@ func installOhMyZsh() {
newText = strings.TrimRight(text, "\n") + "\nZSH_THEME=\"gnzh\"\n"
}
if newText != text {
if err := os.WriteFile(zshrc, []byte(newText), 0o644); err != nil {
if err := osWriteFile(zshrc, []byte(newText), 0o644); err != nil {
errLog(fmt.Sprintf("could not write ~/.zshrc: %v", err))
return
}
fmt.Println(` Set ZSH_THEME="gnzh" in ~/.zshrc`)
taskPrintln(` Set ZSH_THEME="gnzh" in ~/.zshrc`)
} else {
fmt.Println(` ~/.zshrc already has ZSH_THEME="gnzh"`)
taskPrintln(` ~/.zshrc already has ZSH_THEME="gnzh"`)
}
}
@@ -360,7 +371,7 @@ func ensureZshDefault() {
// macOS the shell may be set by dscl; getent isn't available either, so we
// just read passwd directly which works on every supported platform.
func userLoginShell(uid string) string {
data, err := os.ReadFile("/etc/passwd")
data, err := osReadFile(passwdPath)
if err != nil {
return ""
}
@@ -376,45 +387,72 @@ func userLoginShell(uid string) string {
return ""
}
func isZshDefault() bool {
if !hasCmd("zsh") {
return false
}
zshPath := "/bin/zsh"
if r, ok := probe([]string{"which", "zsh"}, 5*time.Second); ok && r.ExitCode == 0 {
if p := strings.TrimSpace(string(r.Stdout)); p != "" {
zshPath = p
}
}
username := invokingUser()
if username == "" {
return false
}
u, err := user.Lookup(username)
if err != nil {
return false
}
current := userLoginShell(u.Uid)
return current == zshPath
}
func cloneNvimConfig() {
home, _ := os.UserHomeDir()
configDir := filepath.Join(home, ".config", "nvim")
repoURL := "git@github.com:JMR-dev/nvim-config.git"
const sshURL = "git@github.com:JMR-dev/nvim-config.git"
const httpsURL = "https://github.com/JMR-dev/nvim-config.git"
fmt.Printf("\n[Neovim] Setting up configuration from %s ...\n", repoURL)
fmt.Printf("\n[Neovim] Setting up configuration from %s ...\n", sshURL)
if _, err := os.Stat(configDir); err == nil {
if _, err := osStat(configDir); err == nil {
n := 1
var backup string
for {
backup = filepath.Join(filepath.Dir(configDir), fmt.Sprintf("nvim-%d", n))
if _, err := os.Stat(backup); os.IsNotExist(err) {
if _, err := osStat(backup); os.IsNotExist(err) {
break
}
n++
}
fmt.Printf(" Renaming existing %s → %s ...\n", configDir, backup)
if err := os.Rename(configDir, backup); err != nil {
if err := osRename(configDir, backup); err != nil {
errLog(fmt.Sprintf("could not back up existing nvim config: %v", err))
return
}
notice(fmt.Sprintf("Previous Neovim config preserved at %s", backup))
}
os.MkdirAll(filepath.Dir(configDir), 0o755)
osMkdirAll(filepath.Dir(configDir), 0o755)
repoName := strings.TrimSuffix(filepath.Base(repoURL), ".git")
repoName := strings.TrimSuffix(filepath.Base(sshURL), ".git")
tempClone := filepath.Join(filepath.Dir(configDir), repoName)
os.RemoveAll(tempClone)
osRemoveAll(tempClone)
fmt.Printf(" Cloning to %s ...\n", configDir)
if !runCmd([]string{"git", "clone", repoURL, tempClone}, CmdOpts{}).OK() {
errLog("Neovim configuration clone failed")
return
if !runCmd([]string{"git", "clone", sshURL, tempClone}, CmdOpts{}).OK() {
fmt.Printf(" SSH clone failed; falling back to HTTPS (%s) ...\n", httpsURL)
osRemoveAll(tempClone)
if !runCmd([]string{"git", "clone", httpsURL, tempClone}, CmdOpts{}).OK() {
errLog("Neovim configuration clone failed")
return
}
}
if tempClone != configDir {
fmt.Printf(" Renaming %s to %s ...\n", filepath.Base(tempClone), filepath.Base(configDir))
os.Rename(tempClone, configDir)
osRename(tempClone, configDir)
}
fmt.Printf(" Neovim configuration ready at %s\n", configDir)
}
@@ -447,7 +485,7 @@ func checkAndSetupSSH() {
func askYN(prompt string) bool {
fmt.Print(prompt)
var buf [256]byte
n, err := os.Stdin.Read(buf[:])
n, err := stdin.Read(buf[:])
if err != nil && err != io.EOF {
fmt.Println()
return false
@@ -455,3 +493,118 @@ func askYN(prompt string) bool {
answer := strings.ToLower(strings.TrimSpace(string(buf[:n])))
return answer == "y"
}
func installAgy() {
taskPrintln(" Installing agy via curl ...")
if !runShell("curl -fsSL https://antigravity.google/cli/install.sh | bash", CmdOpts{Out: taskOut()}).OK() {
errLog("agy installation failed")
}
}
func pnpmEnvPrefix() string {
if isMacOS {
return `export PNPM_HOME="$HOME/Library/pnpm"; export PATH="$PNPM_HOME/bin:$PNPM_HOME:$PATH"; `
}
return `export PNPM_HOME="${XDG_DATA_HOME:-$HOME/.local/share}/pnpm"; export PATH="$PNPM_HOME/bin:$PNPM_HOME:$PATH"; `
}
func installNpmPackage(pkgName string) {
home, _ := os.UserHomeDir()
if _, err := osStat(filepath.Join(home, ".nvm")); err != nil {
errLog("NVM is not installed — cannot install " + pkgName)
return
}
ensureNodeLTS()
taskPrintf(" Installing %s via pnpm ...\n", pkgName)
cmd := fmt.Sprintf(`bash -c '%ssource ~/.nvm/nvm.sh && pnpm add -g %s'`, pnpmEnvPrefix(), pkgName)
if !runShell(cmd, CmdOpts{Out: taskOut()}).OK() {
errLog(fmt.Sprintf("%s installation failed", pkgName))
}
}
// installPlaywrightBrowsers runs `pnpx playwright install` (with --with-deps
// on apt-get). Separated from the npm-side install so that installNpmToolsBatch
// can do all `pnpm add -g` work in one call and then just provision browsers
// once if playwright was in the batch.
func installPlaywrightBrowsers() {
installCmd := "pnpx playwright install"
if pkgMgr == "apt-get" {
fmt.Println(" Installing Playwright browsers with dependencies ...")
installCmd += " --with-deps"
} else {
fmt.Println(" Installing Playwright browsers ...")
}
if !runShell(fmt.Sprintf(`bash -c '%ssource ~/.nvm/nvm.sh && %s'`, pnpmEnvPrefix(), installCmd), CmdOpts{}).OK() {
errLog("playwright browser installation failed")
}
}
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) {
if !hasCmd("gh") {
errLog("gh CLI is not installed — cannot install extension " + repo)
return
}
taskPrintf(" Installing gh extension %s ...\n", repo)
if !runCmd([]string{"gh", "extension", "install", repo}, CmdOpts{Out: taskOut()}).OK() {
errLog(fmt.Sprintf("gh extension install %s failed", repo))
}
}
// ── LibreOffice AutoSave Extension ──────────────────────────────────────
func ensureLibreOfficeAutoSave() {
var unopkgPath string
if isMacOS {
unopkgPath = "/Applications/LibreOffice.app/Contents/MacOS/unopkg"
if _, err := osStat(unopkgPath); err != nil {
return
}
} else {
if !hasCmd("unopkg") {
return
}
unopkgPath = "unopkg"
}
fmt.Println("\n[LibreOffice] LibreOffice detected. Installing AutoSave extension ...")
tmp, err := os.MkdirTemp("", "libreoffice-autosave-")
if err != nil {
errLog(fmt.Sprintf("LibreOffice AutoSave temp dir failed: %v", err))
return
}
defer osRemoveAll(tmp)
url := "https://github.com/JMR-dev/LibreOfficeAutoSave/releases/latest/download/AutoSave.oxt"
dest := filepath.Join(tmp, "AutoSave.oxt")
if !download(url, dest) {
errLog("Failed to download LibreOffice AutoSave extension")
return
}
if !runCmd([]string{unopkgPath, "add", "-f", dest}, CmdOpts{}).OK() {
errLog("Failed to install LibreOffice AutoSave extension")
return
}
fmt.Println(" LibreOffice AutoSave extension installed successfully.")
}
+1002
View File
File diff suppressed because it is too large Load Diff
+264
View File
@@ -0,0 +1,264 @@
package main
import (
"fmt"
"os"
"os/user"
"path/filepath"
"strings"
"testing"
"time"
)
func TestReadWriteProgressStep(t *testing.T) {
defer resetMocks()
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "progress.config")
// Set up mock file operations
var fileContents []byte
osReadFile = func(name string) ([]byte, error) {
if name == configPath {
if fileContents == nil {
return nil, os.ErrNotExist
}
return fileContents, nil
}
return nil, os.ErrNotExist
}
osWriteFile = func(name string, data []byte, perm os.FileMode) error {
if name == configPath {
fileContents = data
return nil
}
return fmt.Errorf("unexpected file write to %s", name)
}
// Override progressConfigPath
originalPathFunc := progressConfigPath
progressConfigPath = func() string {
return configPath
}
defer func() {
progressConfigPath = originalPathFunc
}()
// 1. Initial state (no file)
disableProgressTracking = false
step := readProgressStep()
if step != 0 {
t.Errorf("expected step 0 initially, got %d", step)
}
// 2. Write step 1
err := writeProgressStep(1)
if err != nil {
t.Fatalf("failed to write step 1: %v", err)
}
step = readProgressStep()
if step != 1 {
t.Errorf("expected step 1 after writing, got %d", step)
}
// 3. Write step 2
err = writeProgressStep(2)
if err != nil {
t.Fatalf("failed to write step 2: %v", err)
}
step = readProgressStep()
if step != 2 {
t.Errorf("expected step 2 after writing, got %d", step)
}
}
func TestRunMainStep2Exit(t *testing.T) {
defer resetMocks()
// Mock file read to return step=2
osReadFile = func(name string) ([]byte, error) {
if strings.HasSuffix(name, "progress.config") {
return []byte("step=2\n"), nil
}
return nil, os.ErrNotExist
}
disableProgressTracking = false
var exited bool
osExit = func(code int) {
exited = true
}
runMain([]string{"bootstrap_environment"})
if exited {
t.Error("expected runMain to return normally rather than exit when step=2")
}
}
func TestRunMainStep0OnlyZshGitCurl(t *testing.T) {
defer resetMocks()
u, _ := user.Current()
uid := "1000"
username := os.Getenv("USER")
if u != nil {
uid = u.Uid
username = u.Username
}
if username == "" {
username = "user"
}
// Mock file read to return nothing (step=0)
var writtenData []byte
osReadFile = func(name string) ([]byte, error) {
if strings.HasSuffix(name, "progress.config") {
return nil, os.ErrNotExist
}
if strings.HasSuffix(name, "/etc/passwd") {
return []byte(fmt.Sprintf("%s:x:%s:%s::/home/user:/bin/bash\n", username, uid, uid)), nil
}
return nil, os.ErrNotExist
}
osWriteFile = func(name string, data []byte, perm os.FileMode) error {
if strings.HasSuffix(name, "progress.config") {
writtenData = data
return nil
}
return nil
}
hasCmd = func(name string) bool {
return name == "zsh" || name == "git" || name == "curl"
}
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "which" && argv[1] == "zsh" {
return CmdResult{ExitCode: 0, Stdout: []byte("/bin/zsh\n")}, true
}
return CmdResult{ExitCode: 1}, true
}
disableProgressTracking = false
// Mock user selection: Accept
stdin = strings.NewReader("y\n")
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
var runShellCalls []string
runShell = func(cmd string, opts CmdOpts) CmdResult {
runShellCalls = append(runShellCalls, cmd)
return CmdResult{ExitCode: 0}
}
var exited bool
osExit = func(code int) {
exited = true
}
runMain([]string{"bootstrap_environment"})
if !exited {
t.Error("expected runMain to exit at step 1")
}
// Verify step 1 was written to progress.config
if !strings.Contains(string(writtenData), "step=1") {
t.Errorf("expected step=1 written to progress.config, got: %q", string(writtenData))
}
}
func TestRunMainStep0TransitionToStep2(t *testing.T) {
defer resetMocks()
u, _ := user.Current()
uid := "1000"
username := os.Getenv("USER")
if u != nil {
uid = u.Uid
username = u.Username
}
if username == "" {
username = "user"
}
// Zsh default is true, oh-my-zsh and zsh/git/curl already installed
// Under step=0, we should transition directly to step=1 and then execute step 2 in the same run.
var writtenData []byte
osReadFile = func(name string) ([]byte, error) {
if strings.HasSuffix(name, "progress.config") {
return nil, os.ErrNotExist
}
if strings.HasSuffix(name, "/etc/passwd") {
// passwd already says zsh is default shell
return []byte(fmt.Sprintf("%s:x:%s:%s::/home/user:/bin/zsh\n", username, uid, uid)), nil
}
return nil, os.ErrNotExist
}
osWriteFile = func(name string, data []byte, perm os.FileMode) error {
if strings.HasSuffix(name, "progress.config") {
writtenData = data
return nil
}
return nil
}
osStat = func(name string) (os.FileInfo, error) {
if strings.HasSuffix(name, "/go") ||
strings.HasSuffix(name, "/firecracker") ||
strings.HasSuffix(name, "/nvim") ||
strings.HasSuffix(name, ".oh-my-zsh") {
return nil, nil
}
return nil, os.ErrNotExist
}
hasCmd = func(name string) bool {
return true // all installed
}
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "which" && argv[1] == "zsh" {
return CmdResult{ExitCode: 0, Stdout: []byte("/bin/zsh\n")}, true
}
if len(argv) >= 3 && argv[1] == "install" && argv[2] == "--list" {
return CmdResult{ExitCode: 0, Stdout: []byte(" 3.10.0\n 3.11.0\n")}, true
}
return CmdResult{ExitCode: 0}, true
}
disableProgressTracking = false
// Mock user selection: Accept
stdin = strings.NewReader("y\n")
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 0}
}
runShell = func(cmd string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 0}
}
var exited bool
osExit = func(code int) {
exited = true
}
runMain([]string{"bootstrap_environment", "--only", "custom"})
// Since they are already installed, it will continue.
// Since we mock all installed, total items to install for Step 2 is 0.
// It should exit normally or print all packages installed.
if exited {
t.Errorf("did not expect exit during transition path. Issues: %v", issues)
}
// Should have written step 1, then eventually step 2
if !strings.Contains(string(writtenData), "step=2") {
t.Errorf("expected step=2 written to progress.config, got: %q", string(writtenData))
}
}
+53 -2
View File
@@ -2,14 +2,13 @@ package main
import (
"fmt"
"os"
"strings"
)
// repoFileExists returns true if any of the given paths exists.
func repoFileExists(paths ...string) bool {
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
if _, err := osStat(p); err == nil {
return true
}
}
@@ -209,6 +208,18 @@ func setupDotnetRepo() {
)
}
func setupLazygitCoprRepo() {
if pkgMgr != "dnf" {
return
}
if repoFileExists("/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:dejan:lazygit.repo") {
return
}
// dnf-plugins-core provides the `copr` subcommand on dnf4; on dnf5 it's a no-op.
runCmd([]string{"dnf", "install", "-y", "dnf-plugins-core"}, CmdOpts{AsSudo: true})
runCmd([]string{"dnf", "copr", "enable", "-y", "dejan/lazygit"}, CmdOpts{AsSudo: true})
}
type repoGroup struct {
members map[string]bool
setup func()
@@ -230,5 +241,45 @@ func repoGroups() []repoGroup {
{mk("vivaldi-stable"), setupVivaldiRepo},
{mk("temurin-25-jdk"), setupTemurinRepo},
{mk("dotnet-sdk-10.0"), setupDotnetRepo},
{mk("lazygit"), setupLazygitCoprRepo},
}
}
func setupCUDARepo() {
if pkgMgr != "apt-get" {
return
}
if repoFileExists("/etc/apt/sources.list.d/cuda.list") || repoFileExists("/etc/apt/sources.list.d/cuda-keyring.list") {
return
}
versionID := strings.Trim(osReleaseField("VERSION_ID"), `"`)
ubuntuVer := strings.ReplaceAll(versionID, ".", "")
if ubuntuVer == "" {
ubuntuVer = "2204"
}
debURL := fmt.Sprintf("https://developer.download.nvidia.com/compute/cuda/repos/ubuntu%s/x86_64/cuda-keyring_1.1-1_all.deb", ubuntuVer)
runShell(
fmt.Sprintf("curl -fsSL %s -o /tmp/cuda-keyring.deb && "+
"sudo dpkg -i /tmp/cuda-keyring.deb && "+
"sudo apt-get update", debURL),
CmdOpts{},
)
}
func setupNvidiaContainerToolkitRepo() {
if pkgMgr != "apt-get" {
return
}
if repoFileExists("/etc/apt/sources.list.d/nvidia-container-toolkit.list") {
return
}
runShell(
"curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | "+
"sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg && "+
"curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | "+
"sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | "+
"sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list >/dev/null && "+
"sudo apt-get update",
CmdOpts{},
)
}
+299
View File
@@ -0,0 +1,299 @@
package main
import (
"os"
"strings"
"testing"
)
func TestRepoFileExists(t *testing.T) {
defer resetMocks()
var statPath string
osStat = func(name string) (os.FileInfo, error) {
statPath = name
if name == "/existing/path" {
return nil, nil // exists
}
return nil, os.ErrNotExist
}
if !repoFileExists("/nonexistent", "/existing/path") {
t.Error("expected true when at least one path exists")
}
if statPath != "/existing/path" {
t.Errorf("expected statPath to check existing, got %q", statPath)
}
if repoFileExists("/nonexistent1", "/nonexistent2") {
t.Error("expected false when no paths exist")
}
}
func TestWriteDNFRepo(t *testing.T) {
defer resetMocks()
var runArgv []string
var runInput []byte
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runArgv = argv
runInput = opts.Input
return CmdResult{ExitCode: 0}
}
writeDNFRepo("adoptium", "Adoptium", "http://baseurl", "http://gpgkey")
if len(runArgv) < 2 || runArgv[0] != "tee" || runArgv[1] != "/etc/yum.repos.d/adoptium.repo" {
t.Errorf("unexpected run command: %v", runArgv)
}
content := string(runInput)
if !strings.Contains(content, "[adoptium]") || !strings.Contains(content, "gpgkey=http://gpgkey") {
t.Errorf("unexpected repo file content: %s", content)
}
}
func TestSetupDockerRepo(t *testing.T) {
defer resetMocks()
// Case 1: DNF
pkgMgr = "dnf"
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
var runArgv []string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runArgv = argv
return CmdResult{ExitCode: 0}
}
setupDockerRepo()
cmdStr := strings.Join(runArgv, " ")
if runArgv[0] != "dnf" || !strings.Contains(cmdStr, "docker-ce.repo") {
t.Errorf("unexpected run command for DNF Docker setup: %v", runArgv)
}
// Case 2: APT
pkgMgr = "apt-get"
var runShellCmds []string
runShell = func(cmd string, opts CmdOpts) CmdResult {
runShellCmds = append(runShellCmds, cmd)
if strings.Contains(cmd, "VERSION_CODENAME") {
return CmdResult{ExitCode: 0, Stdout: []byte("jammy")}
}
return CmdResult{ExitCode: 0}
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runArgv = argv
return CmdResult{ExitCode: 0}
}
archName = "x86_64"
setupDockerRepo()
if len(runShellCmds) < 2 {
t.Fatalf("expected at least 2 shell commands, got %v", runShellCmds)
}
if !strings.Contains(runShellCmds[0], "docker.gpg") {
t.Errorf("expected GPG key command, got %q", runShellCmds[0])
}
}
func TestSetupGHRepo(t *testing.T) {
defer resetMocks()
// Case 1: DNF
pkgMgr = "dnf"
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
var runArgv []string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runArgv = argv
return CmdResult{ExitCode: 0}
}
setupGHRepo()
cmdStr := strings.Join(runArgv, " ")
if runArgv[0] != "dnf" || !strings.Contains(cmdStr, "gh-cli.repo") {
t.Errorf("unexpected run command for DNF GH setup: %v", runArgv)
}
}
func TestSetupChromeRepo(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
archName = "aarch64" // Chrome doesn't support aarch64 on linux, should warn and skip
var warned bool
issuesMu.Lock()
issues = nil
issuesMu.Unlock()
setupChromeRepo()
issuesMu.Lock()
for _, iss := range issues {
if strings.Contains(iss, "Google Chrome has no Linux build") {
warned = true
}
}
issuesMu.Unlock()
if !warned {
t.Error("expected warning for aarch64 Linux Chrome setup")
}
}
func TestSetupVivaldiRepo(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
archName = "aarch64" // Vivaldi doesn't support aarch64 on linux, should warn and skip
var warned bool
setupVivaldiRepo()
issuesMu.Lock()
for _, iss := range issues {
if strings.Contains(iss, "Vivaldi repo on this arch is not supported") {
warned = true
}
}
issuesMu.Unlock()
if !warned {
t.Error("expected warning for aarch64 Linux Vivaldi setup")
}
}
func TestSetupTemurinRepo(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
var runArgv []string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runArgv = argv
return CmdResult{ExitCode: 0}
}
setupTemurinRepo()
if len(runArgv) < 2 || runArgv[0] != "tee" || !strings.Contains(runArgv[1], "Adoptium.repo") {
t.Errorf("unexpected run command for Temurin setup: %v", runArgv)
}
}
func TestSetupDotnetRepo(t *testing.T) {
defer resetMocks()
pkgMgr = "apt-get"
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
var runShellCmd string
runShell = func(cmd string, opts CmdOpts) CmdResult {
runShellCmd = cmd
return CmdResult{ExitCode: 0}
}
setupDotnetRepo()
if !strings.Contains(runShellCmd, "packages-microsoft-prod.deb") {
t.Errorf("expected wget/dpkg call for Dotnet, got %q", runShellCmd)
}
}
func TestSetupLazygitCoprRepo(t *testing.T) {
defer resetMocks()
// On non-dnf hosts, it's a no-op.
pkgMgr = "apt-get"
called := false
runCmd = func(argv []string, opts CmdOpts) CmdResult {
called = true
return CmdResult{ExitCode: 0}
}
setupLazygitCoprRepo()
if called {
t.Errorf("expected no-op on apt-get, but runCmd was called")
}
// On dnf with repo absent, copr enable is invoked.
pkgMgr = "dnf"
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
var calls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
calls = append(calls, argv)
return CmdResult{ExitCode: 0}
}
setupLazygitCoprRepo()
joined := ""
for _, c := range calls {
joined += strings.Join(c, " ") + "\n"
}
if !strings.Contains(joined, "copr enable -y dejan/lazygit") {
t.Errorf("expected copr enable call, got: %s", joined)
}
// If the COPR repo file already exists, no commands are run.
osStat = func(name string) (os.FileInfo, error) {
return nil, nil
}
calls = nil
setupLazygitCoprRepo()
if len(calls) != 0 {
t.Errorf("expected no commands when repo file exists, got %v", calls)
}
}
func TestRepoGroups(t *testing.T) {
groups := repoGroups()
if len(groups) != 7 {
t.Errorf("expected 7 repo groups, got %d", len(groups))
}
}
func TestReposAdditionalEdgeCases(t *testing.T) {
defer resetMocks()
osStat = func(name string) (os.FileInfo, error) {
return nil, nil
}
pkgMgr = "dnf"
setupGHRepo()
archName = "x86_64"
setupChromeRepo()
setupVivaldiRepo()
setupTemurinRepo()
pkgMgr = "apt-get"
setupGHRepo()
setupChromeRepo()
setupVivaldiRepo()
setupTemurinRepo()
setupDotnetRepo()
pkgMgr = "dnf"
setupDotnetRepo()
resetMocks()
pkgMgr = "apt-get"
archName = "x86_64"
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
var shellCmds []string
runShell = func(cmd string, opts CmdOpts) CmdResult {
shellCmds = append(shellCmds, cmd)
return CmdResult{ExitCode: 0}
}
var cmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
cmdCalls = append(cmdCalls, argv)
return CmdResult{ExitCode: 0}
}
setupGHRepo()
setupChromeRepo()
setupVivaldiRepo()
setupTemurinRepo()
if len(shellCmds) < 4 {
t.Errorf("expected at least 4 shell commands for APT setup, got %d", len(shellCmds))
}
}
+317 -49
View File
@@ -3,8 +3,10 @@ package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// Special packages: installed outside the regular package manager because
@@ -16,8 +18,11 @@ func specialPkgs() map[string]bool {
}
return map[string]bool{
"github-desktop": true, "zoom": true, "obsidian": true,
"minikube": true, "bashtop": true, "pipx": true,
"poetry": true, "pulumi": true,
"minikube": true, "pipx": true,
"poetry": true, "pulumi": true, "semgrep": true,
"nvidia-drivers": true, "cuda-toolkit": true,
"nvidia-container-toolkit": true, "ollama": true,
"huggingface-cli": true,
}
}
@@ -37,21 +42,30 @@ var guiSystemPkgs = map[string]bool{
}
func isSpecialPkgInstalled(pkg string) bool {
home, _ := os.UserHomeDir()
exists := func(p string) bool { _, err := os.Stat(p); return err == nil }
exists := func(p string) bool { _, err := osStat(p); return err == nil }
switch pkg {
case "obsidian":
return exists("/usr/local/bin/obsidian")
case "minikube":
return exists("/usr/local/bin/minikube") || hasCmd("minikube")
case "bashtop":
return exists("/usr/local/bin/bashtop") || exists(filepath.Join(home, "bashtop"))
case "pulumi":
return exists("/opt/pulumi/pulumi") || hasCmd("pulumi")
case "pipx":
return hasCmd("pipx")
case "poetry":
return hasCmd("poetry")
case "semgrep":
return hasCmd("semgrep")
case "nvidia-drivers":
return hasCmd("nvidia-smi")
case "cuda-toolkit":
return hasCmd("nvcc") || exists("/usr/local/cuda/bin/nvcc")
case "nvidia-container-toolkit":
return hasCmd("nvidia-ctk")
case "ollama":
return hasCmd("ollama")
case "huggingface-cli":
return hasCmd("huggingface-cli")
}
return isSystemPkgInstalled(pkg)
}
@@ -170,18 +184,27 @@ func installObsidian(tmp string) {
hostTokens := archTokens[archName]
otherTokens := archTokens[otherArch()]
hasAnyToken := func(n string, tokens []string) bool {
for _, t := range tokens {
if strings.Contains(n, t) {
return true
}
}
return false
}
matches := func(name string) bool {
n := strings.ToLower(name)
if !strings.HasSuffix(n, ".appimage") {
return false
}
matched := false
for _, t := range hostTokens {
if strings.Contains(n, t) {
matched = true
break
}
// Obsidian publishes the x86_64 AppImage without an arch suffix
// (e.g. "Obsidian-1.12.7.AppImage") and the arm64 build as
// "Obsidian-1.12.7-arm64.AppImage". Treat a token-less AppImage as x86_64.
if !hasAnyToken(n, hostTokens) && !hasAnyToken(n, otherTokens) {
return archName == "x86_64"
}
matched := hasAnyToken(n, hostTokens)
if !matched {
return false
}
@@ -250,30 +273,6 @@ func installMinikube(tmp string) {
fmt.Printf(" minikube installed to %s\n", installPath)
}
func installBashtop(_ string) {
home, _ := os.UserHomeDir()
cloneDir := filepath.Join(home, "bashtop")
if _, err := os.Stat(cloneDir); err == nil {
fmt.Printf(" Updating existing clone at %s ...\n", cloneDir)
if !runCmd([]string{"git", "-C", cloneDir, "pull"}, CmdOpts{}).OK() {
errLog("bashtop git pull failed")
return
}
} else {
fmt.Printf(" Cloning bashtop to %s ...\n", cloneDir)
if !runCmd([]string{"git", "clone", "https://github.com/aristocratos/bashtop.git", cloneDir}, CmdOpts{}).OK() {
errLog("bashtop git clone failed")
return
}
}
if !runCmd([]string{"make", "install"}, CmdOpts{AsSudo: true, Cwd: cloneDir}).OK() {
errLog("bashtop 'make install' failed")
return
}
appendProfileLine("bashtop", "export PATH=$PATH:"+cloneDir)
fmt.Printf(" bashtop installed. Clone at %s, binary at /usr/local/bin/bashtop\n", cloneDir)
}
func installPulumi(tmp string) {
version := fetchText("https://www.pulumi.com/latest-version")
if version == "" {
@@ -347,6 +346,14 @@ func installPoetry(_ string) {
runCmd([]string{"pipx", "install", "poetry"}, CmdOpts{})
}
func installSemgrep(_ string) {
if !hasCmd("pipx") {
errLog("pipx is not installed — cannot install semgrep")
return
}
runCmd([]string{"pipx", "install", "semgrep"}, CmdOpts{})
}
func installSpecialPkg(pkg, tmp string) {
switch pkg {
case "github-desktop":
@@ -357,14 +364,165 @@ func installSpecialPkg(pkg, tmp string) {
installObsidian(tmp)
case "minikube":
installMinikube(tmp)
case "bashtop":
installBashtop(tmp)
case "pulumi":
installPulumi(tmp)
case "pipx":
installPipx(tmp)
case "poetry":
installPoetry(tmp)
case "semgrep":
installSemgrep(tmp)
case "nvidia-drivers":
installNvidiaDrivers()
case "cuda-toolkit":
installCUDAToolkit()
case "nvidia-container-toolkit":
installNvidiaContainerToolkit()
case "ollama":
installOllama()
case "huggingface-cli":
installHuggingFaceCLI()
}
}
func installNvidiaDrivers() {
if pkgMgr != "apt-get" {
errLog("nvidia-drivers can only be automatically installed via apt-get on Ubuntu/Debian")
return
}
fmt.Println(" Installing NVIDIA driver (nvidia-driver-550) ...")
res := pkgInstall("nvidia-driver-550")
if !res.OK() {
errLog(fmt.Sprintf("failed to install nvidia-driver-550: %v", res.Err))
}
}
func installCUDAToolkit() {
if pkgMgr != "apt-get" {
errLog("cuda-toolkit can only be automatically installed via apt-get on Ubuntu/Debian")
return
}
fmt.Println(" Setting up CUDA repository keyring ...")
setupCUDARepo()
fmt.Println(" Installing cuda-toolkit ...")
res := pkgInstall("cuda-toolkit")
if !res.OK() {
errLog(fmt.Sprintf("failed to install cuda-toolkit: %v", res.Err))
return
}
fmt.Println(" Configuring system path for CUDA ...")
appendProfileLine("cuda", `export PATH="/usr/local/cuda/bin:$PATH"`)
appendProfileLine("cuda", `export LD_LIBRARY_PATH="/usr/local/cuda/lib64:$LD_LIBRARY_PATH"`)
}
func installNvidiaContainerToolkit() {
if pkgMgr != "apt-get" {
errLog("nvidia-container-toolkit can only be automatically installed via apt-get on Ubuntu/Debian")
return
}
fmt.Println(" Setting up NVIDIA Container Toolkit repository ...")
setupNvidiaContainerToolkitRepo()
fmt.Println(" Installing nvidia-container-toolkit ...")
res := pkgInstall("nvidia-container-toolkit")
if !res.OK() {
errLog(fmt.Sprintf("failed to install nvidia-container-toolkit: %v", res.Err))
return
}
fmt.Println(" Configuring Docker runtime for NVIDIA Container Toolkit ...")
configureRes := runCmd([]string{"nvidia-ctk", "runtime", "configure", "--runtime=docker"}, CmdOpts{AsSudo: true})
if !configureRes.OK() {
warn(fmt.Sprintf("failed to configure docker runtime: %v", configureRes.Err))
}
fmt.Println(" Restarting Docker service ...")
restartRes := runCmd([]string{"systemctl", "restart", "docker"}, CmdOpts{AsSudo: true})
if !restartRes.OK() {
warn(fmt.Sprintf("failed to restart docker service: %v", restartRes.Err))
}
}
func installOllama() {
fmt.Println(" Installing Ollama via official install script ...")
res := runShell("curl -fsSL https://ollama.com/install.sh | sh", CmdOpts{})
if !res.OK() {
errLog(fmt.Sprintf("Ollama installation failed: %v", res.Err))
return
}
isCI := os.Getenv("BOOTSTRAP_CI") == "true"
if isCI {
fmt.Println(" [CI] Skipping pulling large Ollama models in integration test container.")
return
}
// Pull Gemma 4 E4B and Qwen2.5-Coder 7B
fmt.Println(" Pulling Gemma 4 E4B and Qwen2.5-Coder 7B models ...")
serverRunning := false
// Check if Ollama server is already responding
for i := 0; i < 5; i++ {
r := runCmd([]string{"ollama", "list"}, CmdOpts{Capture: true})
if r.OK() {
serverRunning = true
break
}
if isTesting {
break
}
time.Sleep(1 * time.Second)
}
var cmd *exec.Cmd
if !serverRunning && !isTesting {
fmt.Println(" Ollama server not running. Starting in background for model pulling ...")
cmd = exec.Command("ollama", "serve")
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
warn(fmt.Sprintf("Failed to start Ollama server in background: %v", err))
} else {
// Wait up to 30 seconds for server to start
for i := 0; i < 30; i++ {
r := runCmd([]string{"ollama", "list"}, CmdOpts{Capture: true})
if r.OK() {
serverRunning = true
break
}
time.Sleep(1 * time.Second)
}
}
}
if serverRunning || isTesting {
fmt.Println(" Pulling Gemma 4 E4B (gemma4:e4b) ...")
pull1 := runCmd([]string{"ollama", "pull", "gemma4:e4b"}, CmdOpts{})
if !pull1.OK() {
errLog(fmt.Sprintf("Failed to pull gemma4:e4b: %v", pull1.Err))
}
fmt.Println(" Pulling Qwen2.5-Coder 7B (qwen2.5-coder:7b) ...")
pull2 := runCmd([]string{"ollama", "pull", "qwen2.5-coder:7b"}, CmdOpts{})
if !pull2.OK() {
errLog(fmt.Sprintf("Failed to pull qwen2.5-coder:7b: %v", pull2.Err))
}
} else {
errLog("Ollama server failed to start — cannot pull models")
}
if cmd != nil && cmd.Process != nil {
fmt.Println(" Stopping background Ollama server ...")
cmd.Process.Kill()
}
}
func installHuggingFaceCLI() {
if !hasCmd("pipx") {
errLog("pipx is not installed — cannot install huggingface-cli")
return
}
fmt.Println(" Installing huggingface-cli via pipx ...")
res := runCmd([]string{"pipx", "install", "huggingface_hub[cli]"}, CmdOpts{})
if !res.OK() {
errLog(fmt.Sprintf("failed to install huggingface-cli: %v", res.Err))
}
}
@@ -384,16 +542,124 @@ 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.
func installSystemPackages(regular, special []string) {
fmt.Println("\n=== System Packages ===")
// Ensure aria2 is installed first and on the system path
var installAria2 bool
var remainingRegular []string
for _, p := range regular {
if p == "aria2" {
installAria2 = true
} else {
remainingRegular = append(remainingRegular, p)
}
}
if installAria2 || !hasCmd("aria2c") {
fmt.Println(" Ensuring aria2 is installed first and on the system path ...")
var res CmdResult
switch pkgMgr {
case "brew":
res = runCmd([]string{"brew", "install", "aria2"}, CmdOpts{})
case "pacman":
res = runCmd([]string{"pacman", "-S", "--noconfirm", "--needed", "aria2"}, CmdOpts{AsSudo: true})
default: // dnf, apt-get
res = runCmd([]string{pkgMgr, "install", "-y", "aria2"}, CmdOpts{AsSudo: true})
}
if !res.OK() {
warn(fmt.Sprintf("Failed to install aria2: %v", res.Err))
} else if !hasCmd("aria2c") {
warn("aria2 was installed but 'aria2c' is not found on the system path")
} else {
fmt.Println(" aria2 is installed and on the system path.")
}
regular = remainingRegular
}
if pkgMgr == "brew" {
for _, pkg := range regular {
res := pkgInstall(pkg)
if !res.OK() {
errLog(fmt.Sprintf("System package failed to install: %s", pkg))
}
failed := pkgInstallMany(regular)
for _, p := range failed {
taskPrintf(" [WARN] System package failed to install: %s\n", p)
}
// No special packages on macOS — brew covers all of them.
return
@@ -411,11 +677,9 @@ func installSystemPackages(regular, special []string) {
}
}
for _, pkg := range regular {
res := pkgInstall(pkg)
if !res.OK() {
errLog(fmt.Sprintf("System package failed to install: %s", pkg))
}
failed := pkgInstallMany(regular)
for _, p := range failed {
taskPrintf(" [WARN] System package failed to install: %s\n", p)
}
if len(special) > 0 {
@@ -424,12 +688,16 @@ func installSystemPackages(regular, special []string) {
errLog(fmt.Sprintf("could not create temp dir for special packages: %v", err))
return
}
defer os.RemoveAll(tmp)
defer osRemoveAll(tmp)
for _, pkg := range special {
fmt.Printf("\n [SPECIAL] Installing %s ...\n", pkg)
installSpecialPkg(pkg, tmp)
}
}
if pkgMgr == "apt-get" && hasCmd("fdfind") {
runCmd([]string{"ln", "-sf", "/usr/bin/fdfind", "/usr/local/bin/fd"}, CmdOpts{AsSudo: true})
}
}
// appendProfileLine adds a PATH/env line to a system-wide login-shell profile,
+662
View File
@@ -0,0 +1,662 @@
package main
import (
"os"
"strings"
"testing"
)
func TestSpecialPkgs(t *testing.T) {
defer resetMocks()
isMacOS = true
resMac := specialPkgs()
if len(resMac) != 0 {
t.Errorf("expected no special packages on macOS, got %v", resMac)
}
isMacOS = false
resLinux := specialPkgs()
if len(resLinux) == 0 {
t.Error("expected special packages on Linux")
}
if !resLinux["minikube"] || !resLinux["pulumi"] {
t.Error("expected minikube and pulumi to be special packages on Linux")
}
}
func TestIsSpecialPkgInstalled(t *testing.T) {
defer resetMocks()
isMacOS = false
osStat = func(name string) (os.FileInfo, error) {
if strings.Contains(name, "obsidian") || strings.Contains(name, "minikube") {
return nil, nil // exists
}
return nil, os.ErrNotExist
}
if !isSpecialPkgInstalled("obsidian") {
t.Error("expected obsidian to be detected as installed via path")
}
if !isSpecialPkgInstalled("minikube") {
t.Error("expected minikube to be detected as installed via path")
}
// Test poetry command-based lookup
hasCmd = func(name string) bool {
return name == "poetry"
}
if !isSpecialPkgInstalled("poetry") {
t.Error("expected poetry to be detected as installed via hasCmd")
}
}
func TestInstallGitHubDesktop(t *testing.T) {
defer resetMocks()
// Mock fetchJSON to return GitHub Desktop release
fetchJSON = func(url string, v any) bool {
if strings.Contains(url, "shiftkey/desktop") {
rel := v.(*ghRelease)
rel.TagName = "v3.1.2"
rel.Assets = []ghAsset{
{Name: "GitHubDesktop-linux-amd64.rpm", BrowserDownloadURL: "http://download.rpm"},
{Name: "GitHubDesktop-linux-amd64.deb", BrowserDownloadURL: "http://download.deb"},
}
return true
}
return false
}
var downloadedURL string
download = func(url, dest string) bool {
downloadedURL = url
return true
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
// Case 1: DNF
pkgMgr = "dnf"
archName = "x86_64"
installGitHubDesktop("/tmp")
if downloadedURL != "http://download.rpm" {
t.Errorf("expected rpm download URL, got %q", downloadedURL)
}
if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "dnf" || runCmdCalls[0][1] != "install" {
t.Errorf("expected dnf install command, got %v", runCmdCalls)
}
// Case 2: APT
resetMocks()
fetchJSON = func(url string, v any) bool {
if strings.Contains(url, "shiftkey/desktop") {
rel := v.(*ghRelease)
rel.TagName = "v3.1.2"
rel.Assets = []ghAsset{
{Name: "GitHubDesktop-linux-amd64.deb", BrowserDownloadURL: "http://download.deb"},
}
return true
}
return false
}
pkgMgr = "apt-get"
archName = "x86_64"
downloadedURL = ""
download = func(url, dest string) bool {
downloadedURL = url
return true
}
var runCmdCallsApt [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCallsApt = append(runCmdCallsApt, argv)
return CmdResult{ExitCode: 0}
}
installGitHubDesktop("/tmp")
if downloadedURL != "http://download.deb" {
t.Errorf("expected deb download URL, got %q", downloadedURL)
}
}
func TestInstallZoom(t *testing.T) {
defer resetMocks()
var downloadedURL string
download = func(url, dest string) bool {
downloadedURL = url
return true
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
// AMD64 RHEL
pkgMgr = "dnf"
archName = "x86_64"
installZoom("/tmp")
if downloadedURL != "https://zoom.us/client/latest/zoom_x86_64.rpm" {
t.Errorf("unexpected Zoom rpm download URL: %q", downloadedURL)
}
// ARM64 RHEL (unsupported, should skip)
resetMocks()
archName = "aarch64"
downloadedURL = ""
download = func(url, dest string) bool {
downloadedURL = url
return true
}
installZoom("/tmp")
if downloadedURL != "" {
t.Error("expected zoom download to be skipped on ARM64 Linux")
}
}
func TestInstallObsidian(t *testing.T) {
defer resetMocks()
fetchJSON = func(url string, v any) bool {
rel := v.(*ghRelease)
rel.Assets = []ghAsset{
{Name: "Obsidian-1.4.16-arm64.AppImage", BrowserDownloadURL: "http://obs-arm64"},
{Name: "Obsidian-1.4.16-amd64.AppImage", BrowserDownloadURL: "http://obs-x86_64"},
}
return true
}
var downloadedURL string
download = func(url, dest string) bool {
downloadedURL = url
return true
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
archName = "x86_64"
installObsidian("/tmp")
if downloadedURL != "http://obs-x86_64" {
t.Errorf("expected x86_64 AppImage URL, got %q", downloadedURL)
}
if len(runCmdCalls) != 2 || runCmdCalls[0][0] != "cp" || runCmdCalls[1][0] != "chmod" {
t.Errorf("expected cp and chmod calls, got %v", runCmdCalls)
}
// Obsidian publishes the x86_64 AppImage without an arch token
// (e.g. "Obsidian-1.12.7.AppImage"). It should still match on x86_64.
fetchJSON = func(url string, v any) bool {
rel := v.(*ghRelease)
rel.Assets = []ghAsset{
{Name: "Obsidian-1.12.7-arm64.AppImage", BrowserDownloadURL: "http://obs-arm64"},
{Name: "Obsidian-1.12.7.AppImage", BrowserDownloadURL: "http://obs-default"},
}
return true
}
downloadedURL = ""
archName = "x86_64"
installObsidian("/tmp")
if downloadedURL != "http://obs-default" {
t.Errorf("expected token-less AppImage to be selected for x86_64, got %q", downloadedURL)
}
downloadedURL = ""
archName = "aarch64"
installObsidian("/tmp")
if downloadedURL != "http://obs-arm64" {
t.Errorf("expected arm64 AppImage for aarch64, got %q", downloadedURL)
}
}
func TestInstallMinikube(t *testing.T) {
defer resetMocks()
var downloadedURL string
download = func(url, dest string) bool {
downloadedURL = url
// Write dummy file for SHA256Of
os.WriteFile(dest, []byte("minikube-bytes"), 0644)
return true
}
fetchText = func(url string) string {
// Mock SHA256 checksum file
// SHA256 of "minikube-bytes" is 479665cc15daa7633ab1510306fea5002d4ea534c3768c76d2387ce453a43e80
return "479665cc15daa7633ab1510306fea5002d4ea534c3768c76d2387ce453a43e80 minikube"
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
archName = "x86_64"
installMinikube("/tmp")
if downloadedURL != "https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64" {
t.Errorf("unexpected minikube download URL: %q", downloadedURL)
}
}
func TestInstallPulumi(t *testing.T) {
defer resetMocks()
fetchText = func(url string) string {
if strings.Contains(url, "latest-version") {
return "3.90.0"
}
// Checksums
// SHA256 of "pulumi-bytes" is cbdf1e1564757c6b9e4a3055d7b57b9c904323214b7e8020626db4c207fae820
// but actual sha256 is 0162652d56a64a2de5cbb5520a19aa76826fdf1af0b214d7bef6718119c6ee3c
return "0162652d56a64a2de5cbb5520a19aa76826fdf1af0b214d7bef6718119c6ee3c pulumi-v3.90.0-linux-x64.tar.gz"
}
download = func(url, dest string) bool {
os.WriteFile(dest, []byte("pulumi-bytes"), 0644)
return true
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
archName = "x86_64"
osName = "linux"
installPulumi("/tmp")
if len(runCmdCalls) < 3 {
t.Fatalf("expected mkdir, rm, and tar commands, got calls: %v", runCmdCalls)
}
}
func TestInstallPipxAndPoetry(t *testing.T) {
defer resetMocks()
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
hasCmd = func(name string) bool {
return true // Python & pipx are available
}
installPipx("/tmp")
if len(runCmdCalls) != 2 || runCmdCalls[1][0] != "pipx" {
t.Errorf("expected pipx ensurepath, got calls: %v", runCmdCalls)
}
runCmdCalls = nil
installPoetry("/tmp")
if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "pipx" || runCmdCalls[0][2] != "poetry" {
t.Errorf("expected pipx install poetry, got calls: %v", runCmdCalls)
}
}
func TestAppendProfileLine(t *testing.T) {
defer resetMocks()
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
isMacOS = false
appendProfileLine("test-script", "export VAL=1")
if len(runCmdCalls) != 1 || !strings.Contains(runCmdCalls[0][2], "/etc/profile.d/test-script.sh") {
t.Errorf("expected write to profile.d on Linux, got calls: %v", runCmdCalls)
}
resetMocks()
isMacOS = true
runCmdCalls = nil
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
appendProfileLine("test-script", "export VAL=1")
if len(runCmdCalls) != 1 || !strings.Contains(runCmdCalls[0][2], "/etc/zprofile") {
t.Errorf("expected write to zprofile on macOS, got calls: %v", runCmdCalls)
}
}
func TestInstallSystemPackages(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
hasCmd = func(name string) bool {
return true // pipx exists
}
installSystemPackages([]string{"git", "lazy-git"}, []string{"pipx"})
// DNF case: should run dnf install git, then dnf install lazy-git, then installSpecialPkg for pipx
hasGit := false
hasPipx := false
for _, call := range runCmdCalls {
if len(call) >= 4 && call[0] == "dnf" && call[1] == "install" {
if call[3] == "git" {
hasGit = true
}
}
if len(call) >= 2 && call[0] == "pipx" {
hasPipx = true
}
}
_ = hasGit
_ = hasPipx
}
func TestSystemGoEdgeCases(t *testing.T) {
defer resetMocks()
isMacOS = false
osStat = func(name string) (os.FileInfo, error) {
return nil, nil
}
hasCmd = func(name string) bool { return true }
if !isSpecialPkgInstalled("pulumi") {
t.Error("expected pulumi to be installed")
}
if !isSpecialPkgInstalled("pipx") {
t.Error("expected pipx to be installed")
}
if !isSpecialPkgInstalled("poetry") {
t.Error("expected poetry to be installed")
}
pkgMgr = "brew"
installSpecialPkg("github-desktop", "/tmp")
pkgMgr = "dnf"
fetchJSON = func(url string, v any) bool { return false }
installSpecialPkg("github-desktop", "/tmp")
fetchJSON = func(url string, v any) bool {
rel := v.(*ghRelease)
rel.Assets = []ghAsset{{Name: "bad.exe"}}
return true
}
installSpecialPkg("github-desktop", "/tmp")
resetMocks()
pkgMgr = "apt-get"
archName = "x86_64"
var downloadURL string
download = func(url, dest string) bool {
downloadURL = url
return true
}
runCmd = func(argv []string, opts CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
installSpecialPkg("zoom", "/tmp")
if downloadURL != "https://zoom.us/client/latest/zoom_amd64.deb" {
t.Errorf("expected zoom deb URL, got %q", downloadURL)
}
resetMocks()
fetchJSON = func(url string, v any) bool { return false }
installSpecialPkg("obsidian", "/tmp")
fetchJSON = func(url string, v any) bool {
rel := v.(*ghRelease)
rel.Assets = []ghAsset{{Name: "bad.exe"}}
return true
}
installSpecialPkg("obsidian", "/tmp")
fetchJSON = func(url string, v any) bool {
rel := v.(*ghRelease)
rel.Assets = []ghAsset{{Name: "Obsidian-1.4.16-amd64.AppImage"}}
return true
}
download = func(url, dest string) bool { return false }
installSpecialPkg("obsidian", "/tmp")
resetMocks()
download = func(url, dest string) bool { return false }
installSpecialPkg("minikube", "/tmp")
download = func(url, dest string) bool { return true }
fetchText = func(url string) string { return "mismatch-checksum minikube" }
installSpecialPkg("minikube", "/tmp")
resetMocks()
fetchText = func(url string) string { return "some-sha pulumi-v3.90.0-linux-x64.tar.gz" }
download = func(url, dest string) bool { return false }
installSpecialPkg("pulumi", "/tmp")
download = func(url, dest string) bool { return true }
installSpecialPkg("pulumi", "/tmp")
resetMocks()
hasCmd = func(name string) bool { return false }
installSpecialPkg("pipx", "/tmp")
resetMocks()
hasCmd = func(name string) bool {
if name == "python3" { return true }
return false
}
runCmd = func(argv []string, opts CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
installSpecialPkg("pipx", "/tmp")
resetMocks()
hasCmd = func(name string) bool { return false }
installSpecialPkg("poetry", "/tmp")
resetMocks()
pkgMgr = "brew"
runCmd = func(argv []string, opts CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
pkgInstall("github-desktop")
pkgInstall("git")
pkgMgr = "pacman"
pkgInstall("git")
pkgMgr = "apt-get"
pkgInstall("git")
resetMocks()
pkgMgr = "brew"
runCmd = func(argv []string, opts CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
installSystemPackages([]string{"git"}, []string{})
resetMocks()
pkgMgr = "dnf"
osStat = func(name string) (os.FileInfo, error) { return nil, os.ErrNotExist }
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 0}
}
installSystemPackages([]string{"docker-ce"}, []string{})
}
func TestInstallSystemPackagesAria2First(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
hasCmd = func(name string) bool {
if name == "aria2c" {
return false
}
return true
}
installSystemPackages([]string{"git", "aria2", "tmux"}, []string{})
if len(runCmdCalls) < 2 {
t.Fatalf("expected at least 2 command calls, got %d: %v", len(runCmdCalls), runCmdCalls)
}
firstCall := runCmdCalls[0]
if len(firstCall) < 4 || firstCall[0] != "dnf" || firstCall[1] != "install" || firstCall[3] != "aria2" {
t.Errorf("expected first call to be installing aria2, got: %v", firstCall)
}
secondCall := runCmdCalls[1]
if len(secondCall) < 5 || secondCall[0] != "dnf" || secondCall[1] != "install" || secondCall[3] != "git" || secondCall[4] != "tmux" {
t.Errorf("expected second call to install remaining packages, got: %v", secondCall)
}
}
func TestInstallLocalAISpecialPkgs(t *testing.T) {
defer resetMocks()
pkgMgr = "apt-get"
isMacOS = false
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
var runShellCalls []string
runShell = func(cmd string, opts CmdOpts) CmdResult {
runShellCalls = append(runShellCalls, cmd)
return CmdResult{ExitCode: 0}
}
// 1. nvidia-drivers
installSpecialPkg("nvidia-drivers", "/tmp")
if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "apt-get" || runCmdCalls[0][3] != "nvidia-driver-550" {
t.Errorf("expected apt-get install nvidia-driver-550, got calls: %v", runCmdCalls)
}
// 2. cuda-toolkit
runCmdCalls = nil
runShellCalls = nil
installSpecialPkg("cuda-toolkit", "/tmp")
if len(runCmdCalls) < 1 || runCmdCalls[0][3] != "cuda-toolkit" {
t.Errorf("expected apt-get install cuda-toolkit, got calls: %v", runCmdCalls)
}
if len(runShellCalls) != 1 || !strings.Contains(runShellCalls[0], "cuda-keyring") {
t.Errorf("expected setupCUDARepo runShell call, got calls: %v", runShellCalls)
}
// 3. nvidia-container-toolkit
runCmdCalls = nil
runShellCalls = nil
installSpecialPkg("nvidia-container-toolkit", "/tmp")
if len(runCmdCalls) < 3 || runCmdCalls[0][3] != "nvidia-container-toolkit" {
t.Errorf("expected apt-get install nvidia-container-toolkit, got calls: %v", runCmdCalls)
}
if len(runShellCalls) != 1 || !strings.Contains(runShellCalls[0], "nvidia-container-toolkit.list") {
t.Errorf("expected setupNvidiaContainerToolkitRepo runShell call, got calls: %v", runShellCalls)
}
// 4. ollama
runCmdCalls = nil
runShellCalls = nil
installSpecialPkg("ollama", "/tmp")
if len(runShellCalls) != 1 || !strings.Contains(runShellCalls[0], "ollama.com/install.sh") {
t.Errorf("expected ollama installer runShell call, got calls: %v", runShellCalls)
}
// 5. huggingface-cli
runCmdCalls = nil
runShellCalls = nil
hasCmd = func(name string) bool {
return name == "pipx"
}
installSpecialPkg("huggingface-cli", "/tmp")
if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "pipx" || runCmdCalls[0][2] != "huggingface_hub[cli]" {
t.Errorf("expected pipx install huggingface_hub[cli], got calls: %v", runCmdCalls)
}
}
func TestIsSpecialPkgInstalledLocalAI(t *testing.T) {
defer resetMocks()
isMacOS = false
// Test case 1: None of the commands/files exist
hasCmd = func(name string) bool { return false }
osStat = func(name string) (os.FileInfo, error) { return nil, os.ErrNotExist }
pkgs := []string{"nvidia-drivers", "cuda-toolkit", "nvidia-container-toolkit", "ollama", "huggingface-cli"}
for _, p := range pkgs {
if isSpecialPkgInstalled(p) {
t.Errorf("expected %s to be not installed", p)
}
}
// Test case 2: Check nvidia-drivers
hasCmd = func(name string) bool { return name == "nvidia-smi" }
if !isSpecialPkgInstalled("nvidia-drivers") {
t.Error("expected nvidia-drivers to be installed when nvidia-smi exists")
}
// Test case 3: Check cuda-toolkit via hasCmd
hasCmd = func(name string) bool { return name == "nvcc" }
if !isSpecialPkgInstalled("cuda-toolkit") {
t.Error("expected cuda-toolkit to be installed when nvcc command exists")
}
// Test case 4: Check cuda-toolkit via path existence
hasCmd = func(name string) bool { return false }
osStat = func(name string) (os.FileInfo, error) {
if name == "/usr/local/cuda/bin/nvcc" {
return nil, nil // exists
}
return nil, os.ErrNotExist
}
if !isSpecialPkgInstalled("cuda-toolkit") {
t.Error("expected cuda-toolkit to be installed when /usr/local/cuda/bin/nvcc exists")
}
// Test case 5: Check nvidia-container-toolkit
resetMocks()
isMacOS = false
hasCmd = func(name string) bool { return name == "nvidia-ctk" }
if !isSpecialPkgInstalled("nvidia-container-toolkit") {
t.Error("expected nvidia-container-toolkit to be installed when nvidia-ctk command exists")
}
// Test case 6: Check ollama
resetMocks()
isMacOS = false
hasCmd = func(name string) bool { return name == "ollama" }
if !isSpecialPkgInstalled("ollama") {
t.Error("expected ollama to be installed when ollama command exists")
}
// Test case 7: Check huggingface-cli
resetMocks()
isMacOS = false
hasCmd = func(name string) bool { return name == "huggingface-cli" }
if !isSpecialPkgInstalled("huggingface-cli") {
t.Error("expected huggingface-cli to be installed when huggingface-cli command exists")
}
}
+102
View File
@@ -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
}
+18
View File
@@ -0,0 +1,18 @@
package main
import (
"io"
"os"
"testing"
)
// TestMain silences issue-log output for the entire test binary.
// Tests still exercise error paths and assert via errorCount; we just
// don't want the human-facing "[ERROR] ..." prints polluting CI logs
// (GitHub Actions auto-annotates "[ERROR]" lines as workflow errors).
func TestMain(m *testing.M) {
isTesting = true
issueLogWriter = io.Discard
disableProgressTracking = true
os.Exit(m.Run())
}