Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24a50e73dd | ||
|
|
07c212d962 | ||
|
|
befcadd260 | ||
|
|
60cf195f45 | ||
|
|
a5726a7a60 | ||
|
|
ac933f7c7d | ||
|
|
206b4cb999 | ||
|
|
83c929c24b | ||
|
|
430b018aa9 | ||
|
|
c4f7f6d240 | ||
|
|
bc021018cd | ||
|
|
031bc618ec | ||
|
|
a7047d5018 | ||
|
|
8f7e592c72 | ||
|
|
de9d41494b | ||
|
|
165f9dbe4f | ||
|
|
58bf0d4bbe | ||
|
|
ad84585f72 | ||
|
|
95e905a8c5 | ||
|
|
60fd4846af | ||
|
|
de7f71fea6 | ||
|
|
aab9db9545 | ||
|
|
e14347fd31 | ||
|
|
41a6f74ce1 | ||
|
|
abcb973208 | ||
|
|
76a44c0999 | ||
|
|
3e27e54318 | ||
|
|
ad2976842f | ||
|
|
c3ddcb336a | ||
|
|
a2f36665a3 | ||
|
|
9093aedb66 | ||
|
|
4d99bba06f | ||
|
|
ee95457e7d | ||
|
|
669565bb87 | ||
|
|
39c23595ce | ||
|
|
8f11c8d68b | ||
|
|
d9e905ac20 | ||
|
|
14bf9f3e56 | ||
|
|
f6fdb3f6ae | ||
|
|
d7d87acb7e | ||
|
|
817a0ad8a4 | ||
|
|
03a90fffe6 | ||
|
|
658460fe4c | ||
|
|
c2ca21b444 | ||
|
|
26bd80c97d | ||
|
|
b3ad702da0 | ||
|
|
8a0e3ee9e4 | ||
|
|
4525cbc219 | ||
|
|
1c011f4a7d | ||
|
|
a4b855b796 | ||
|
|
cedaf4ce04 | ||
|
|
92a582efa7 | ||
|
|
10963c848b | ||
|
|
e5448b71ba | ||
|
|
6661aba3d6 | ||
|
|
16f058f83c | ||
|
|
da80fe81bc | ||
|
|
2e9cbb0cab | ||
|
|
18182aee58 | ||
|
|
5b184d81ff | ||
|
|
8668c12f7d | ||
|
|
539833344b | ||
|
|
01bb797433 | ||
|
|
caa010091f |
@@ -0,0 +1,81 @@
|
||||
name: Integration Tests
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: '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]
|
||||
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!"
|
||||
@@ -0,0 +1,64 @@
|
||||
name: macOS Manual Integration
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: '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
|
||||
@@ -0,0 +1,130 @@
|
||||
name: Release
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to release (e.g. v0.1.0).'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
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=${{ 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@v6
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
|
||||
- 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@v3
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
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: |
|
||||
dist/bootstrap_environment-*
|
||||
dist/SHA256SUMS
|
||||
@@ -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 ./...
|
||||
+4
-1
@@ -1 +1,4 @@
|
||||
__pycache__/
|
||||
dist/
|
||||
bootstrap_dev_env
|
||||
bootstrap_environment
|
||||
bootstrap_run.log
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
BINARY := bootstrap_environment
|
||||
DIST := dist
|
||||
PKG := .
|
||||
|
||||
# Statically-linked, stripped binaries for distribution.
|
||||
GOFLAGS := -trimpath -ldflags="-s -w"
|
||||
|
||||
TARGETS := \
|
||||
linux/amd64 \
|
||||
linux/arm64 \
|
||||
linux/arm/6 \
|
||||
darwin/amd64 \
|
||||
darwin/arm64
|
||||
|
||||
.PHONY: all build build-all test vet fmt clean
|
||||
|
||||
all: build
|
||||
|
||||
build:
|
||||
go build $(GOFLAGS) -o $(BINARY) $(PKG)
|
||||
|
||||
# Cross-compile native binaries for each supported (OS, arch[, 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=$$(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
|
||||
|
||||
$(DIST):
|
||||
mkdir -p $(DIST)
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
fmt:
|
||||
gofmt -w .
|
||||
|
||||
clean:
|
||||
rm -rf $(DIST) $(BINARY)
|
||||
@@ -1,8 +1,103 @@
|
||||
# Usage
|
||||
# bootstrap_dev_env
|
||||
|
||||
``` shell
|
||||
curl -L -o bootstrap.zip https://github.com/JMR-dev/bootstrap_dev_env/archive/refs/heads/main.zip && \
|
||||
unzip bootstrap.zip && \
|
||||
cd bootstrap_dev_env-main && \
|
||||
python3 bootstrap_environment.py
|
||||
A Go-based bootstrap tool that provisions a development environment across
|
||||
macOS, Debian/Ubuntu, RHEL/Fedora, and Arch Linux on `x86_64` and `aarch64`.
|
||||
|
||||
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 (Dagger) and macOS (native) | Push + pull requests to `main` / `dev` |
|
||||
|
||||
## Install
|
||||
|
||||
Build from source:
|
||||
|
||||
```shell
|
||||
git clone https://github.com/JMR-dev/bootstrap_dev_env.git
|
||||
cd bootstrap_dev_env
|
||||
make build # builds ./bootstrap_environment for the host
|
||||
```
|
||||
|
||||
Cross-compile all supported targets at once:
|
||||
|
||||
```shell
|
||||
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 [flags]
|
||||
|
||||
# macOS
|
||||
./bootstrap_environment [flags]
|
||||
```
|
||||
|
||||
### Flags
|
||||
|
||||
| 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
|
||||
```
|
||||
|
||||
|
||||
+533
@@ -0,0 +1,533 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestPkgInstallManyBatchesDnf verifies a single batched dnf call rather
|
||||
// than one per package.
|
||||
func TestPkgInstallManyBatchesDnf(t *testing.T) {
|
||||
defer resetMocks()
|
||||
pkgMgr = "dnf"
|
||||
|
||||
var calls [][]string
|
||||
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||
calls = append(calls, append([]string(nil), argv...))
|
||||
return CmdResult{ExitCode: 0}
|
||||
}
|
||||
|
||||
failed := pkgInstallMany([]string{"git", "curl", "vim"})
|
||||
if len(failed) != 0 {
|
||||
t.Errorf("expected no failures, got %v", failed)
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 batched call, got %d: %v", len(calls), calls)
|
||||
}
|
||||
got := strings.Join(calls[0], " ")
|
||||
if !strings.HasPrefix(got, "dnf install -y") {
|
||||
t.Errorf("expected 'dnf install -y …' prefix, got: %q", got)
|
||||
}
|
||||
for _, pkg := range []string{"git", "curl", "vim"} {
|
||||
if !strings.Contains(got, pkg) {
|
||||
t.Errorf("expected %s in batched call, got: %q", pkg, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPkgInstallManyBatchesApt verifies the same for apt-get.
|
||||
func TestPkgInstallManyBatchesApt(t *testing.T) {
|
||||
defer resetMocks()
|
||||
pkgMgr = "apt-get"
|
||||
|
||||
var calls [][]string
|
||||
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||
calls = append(calls, append([]string(nil), argv...))
|
||||
return CmdResult{ExitCode: 0}
|
||||
}
|
||||
|
||||
pkgInstallMany([]string{"a", "b", "c"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 batched call, got %d", len(calls))
|
||||
}
|
||||
if calls[0][0] != "apt-get" || calls[0][1] != "install" || calls[0][2] != "-y" {
|
||||
t.Errorf("expected 'apt-get install -y' prefix, got: %v", calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestPkgInstallManyBatchesPacman verifies pacman flags.
|
||||
func TestPkgInstallManyBatchesPacman(t *testing.T) {
|
||||
defer resetMocks()
|
||||
pkgMgr = "pacman"
|
||||
|
||||
var calls [][]string
|
||||
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||
calls = append(calls, append([]string(nil), argv...))
|
||||
return CmdResult{ExitCode: 0}
|
||||
}
|
||||
|
||||
pkgInstallMany([]string{"a", "b"})
|
||||
if len(calls) != 1 || calls[0][0] != "pacman" {
|
||||
t.Fatalf("expected single pacman call, got %v", calls)
|
||||
}
|
||||
joined := strings.Join(calls[0], " ")
|
||||
if !strings.Contains(joined, "--noconfirm") || !strings.Contains(joined, "--needed") {
|
||||
t.Errorf("expected --noconfirm --needed in pacman call, got: %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPkgInstallManyFallback verifies that a failed batch retries per-package
|
||||
// and returns the failures it identifies on the per-package retry.
|
||||
func TestPkgInstallManyFallback(t *testing.T) {
|
||||
defer resetMocks()
|
||||
pkgMgr = "dnf"
|
||||
|
||||
calls := 0
|
||||
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||
calls++
|
||||
// Fail the first (batched) call, succeed individual retries except for "bad".
|
||||
if calls == 1 {
|
||||
return CmdResult{ExitCode: 1}
|
||||
}
|
||||
for _, a := range argv {
|
||||
if a == "bad" {
|
||||
return CmdResult{ExitCode: 1}
|
||||
}
|
||||
}
|
||||
return CmdResult{ExitCode: 0}
|
||||
}
|
||||
|
||||
failed := pkgInstallMany([]string{"good1", "good2", "bad"})
|
||||
if len(failed) != 1 || failed[0] != "bad" {
|
||||
t.Errorf("expected only 'bad' to fail, got %v", failed)
|
||||
}
|
||||
// 1 batch + 3 per-package retries = 4 calls.
|
||||
if calls != 4 {
|
||||
t.Errorf("expected 4 total calls (1 batch + 3 retries), got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPkgInstallManyEmpty: no-op on empty input, no calls.
|
||||
func TestPkgInstallManyEmpty(t *testing.T) {
|
||||
defer resetMocks()
|
||||
pkgMgr = "dnf"
|
||||
called := false
|
||||
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||
called = true
|
||||
return CmdResult{ExitCode: 0}
|
||||
}
|
||||
failed := pkgInstallMany(nil)
|
||||
if len(failed) != 0 {
|
||||
t.Errorf("expected no failures, got %v", failed)
|
||||
}
|
||||
if called {
|
||||
t.Error("expected no runCmd call for empty input")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPkgInstallManyBrew verifies that brew is batched into a single
|
||||
// `brew install f1 f2 …` call (formulas only — no casks in this test).
|
||||
// Parallel brew calls would deadlock on shared transitive-dep locks
|
||||
// (cmake, ninja, libsodium, …), so we deliberately batch and serialize.
|
||||
func TestPkgInstallManyBrew(t *testing.T) {
|
||||
defer resetMocks()
|
||||
pkgMgr = "brew"
|
||||
|
||||
var mu sync.Mutex
|
||||
var calls [][]string
|
||||
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||
mu.Lock()
|
||||
calls = append(calls, append([]string(nil), argv...))
|
||||
mu.Unlock()
|
||||
return CmdResult{ExitCode: 0}
|
||||
}
|
||||
|
||||
if failed := pkgInstallMany([]string{"git", "vim", "curl"}); len(failed) != 0 {
|
||||
t.Errorf("expected no failures, got %v", failed)
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 batched brew call, got %d: %v", len(calls), calls)
|
||||
}
|
||||
got := strings.Join(calls[0], " ")
|
||||
if got != "brew install git vim curl" {
|
||||
t.Errorf("expected 'brew install git vim curl', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPkgInstallManyBrewSplitCasks verifies that casks and formulas are
|
||||
// emitted in separate calls (because --cask is mutually exclusive with
|
||||
// formula installs in one invocation).
|
||||
func TestPkgInstallManyBrewSplitCasks(t *testing.T) {
|
||||
defer resetMocks()
|
||||
pkgMgr = "brew"
|
||||
|
||||
var calls [][]string
|
||||
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||
calls = append(calls, append([]string(nil), argv...))
|
||||
return CmdResult{ExitCode: 0}
|
||||
}
|
||||
|
||||
// "docker" is in brewCasks; the rest are formulas.
|
||||
pkgInstallMany([]string{"git", "docker", "vim"})
|
||||
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("expected 2 calls (1 formula batch + 1 cask batch), got %d: %v", len(calls), calls)
|
||||
}
|
||||
formula := strings.Join(calls[0], " ")
|
||||
cask := strings.Join(calls[1], " ")
|
||||
if formula != "brew install git vim" {
|
||||
t.Errorf("expected 'brew install git vim', got %q", formula)
|
||||
}
|
||||
if cask != "brew install --cask docker" {
|
||||
t.Errorf("expected 'brew install --cask docker', got %q", cask)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPkgInstallManyBrewFallback: batched formula install fails; we retry
|
||||
// per-package and identify the broken one.
|
||||
func TestPkgInstallManyBrewFallback(t *testing.T) {
|
||||
defer resetMocks()
|
||||
pkgMgr = "brew"
|
||||
|
||||
calls := 0
|
||||
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||
calls++
|
||||
// First call is the batch — fail it.
|
||||
if calls == 1 {
|
||||
return CmdResult{ExitCode: 1}
|
||||
}
|
||||
// Per-package retries: only "broken" fails.
|
||||
if argv[len(argv)-1] == "broken" {
|
||||
return CmdResult{ExitCode: 1}
|
||||
}
|
||||
return CmdResult{ExitCode: 0}
|
||||
}
|
||||
|
||||
failed := pkgInstallMany([]string{"git", "broken", "curl"})
|
||||
if len(failed) != 1 || failed[0] != "broken" {
|
||||
t.Errorf("expected only 'broken' to fail, got %v", failed)
|
||||
}
|
||||
// 1 batch + 3 per-package retries = 4 calls.
|
||||
if calls != 4 {
|
||||
t.Errorf("expected 4 total calls, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallFlatpakBatched: a single batched flatpak install for the
|
||||
// happy path.
|
||||
func TestInstallFlatpakBatched(t *testing.T) {
|
||||
defer resetMocks()
|
||||
|
||||
hasCmd = func(name string) bool { return name == "flatpak" }
|
||||
var calls [][]string
|
||||
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||
calls = append(calls, append([]string(nil), argv...))
|
||||
return CmdResult{ExitCode: 0}
|
||||
}
|
||||
|
||||
installFlatpakPackages([]string{"a.app", "b.app", "c.app"})
|
||||
|
||||
// Expect: remote-add (1) + single batched install (1) = 2 calls.
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("expected 2 calls (remote-add + batched install), got %d: %v", len(calls), calls)
|
||||
}
|
||||
if calls[1][1] != "install" {
|
||||
t.Errorf("expected install as second call, got %v", calls[1])
|
||||
}
|
||||
for _, app := range []string{"a.app", "b.app", "c.app"} {
|
||||
found := false
|
||||
for _, a := range calls[1] {
|
||||
if a == app {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected %s in batched call, got %v", app, calls[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallFlatpakBatchFallback: failed batch retries per-package.
|
||||
func TestInstallFlatpakBatchFallback(t *testing.T) {
|
||||
defer resetMocks()
|
||||
|
||||
hasCmd = func(name string) bool { return name == "flatpak" }
|
||||
calls := 0
|
||||
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||
calls++
|
||||
// First call: remote-add (always OK)
|
||||
// Second call: batched install (fail)
|
||||
// Following calls: per-package retries (OK)
|
||||
if calls == 2 {
|
||||
return CmdResult{ExitCode: 1}
|
||||
}
|
||||
return CmdResult{ExitCode: 0}
|
||||
}
|
||||
|
||||
installFlatpakPackages([]string{"a.app", "b.app"})
|
||||
|
||||
// 1 (remote-add) + 1 (failed batch) + 2 (per-package retries) = 4 calls.
|
||||
if calls != 4 {
|
||||
t.Errorf("expected 4 calls (remote-add + batch + 2 retries), got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckSystemPackagesParallelOrdering: ordering preserved despite
|
||||
// concurrent probes.
|
||||
func TestCheckSystemPackagesParallelOrdering(t *testing.T) {
|
||||
defer resetMocks()
|
||||
pkgMgr = "dnf"
|
||||
|
||||
// odd-indexed packages "installed", even-indexed "not installed"
|
||||
probe = func(argv []string, _ time.Duration) (CmdResult, bool) {
|
||||
pkg := argv[len(argv)-1]
|
||||
// pkg-0..pkg-7
|
||||
idx := pkg[len(pkg)-1] - '0'
|
||||
if idx%2 == 1 {
|
||||
return CmdResult{ExitCode: 0}, true // installed
|
||||
}
|
||||
return CmdResult{ExitCode: 1}, true // not installed
|
||||
}
|
||||
|
||||
names := []string{"pkg-0", "pkg-1", "pkg-2", "pkg-3", "pkg-4", "pkg-5", "pkg-6", "pkg-7"}
|
||||
res := checkSystemPackages(names)
|
||||
|
||||
wantToInstall := []string{"pkg-0", "pkg-2", "pkg-4", "pkg-6"}
|
||||
wantAlready := []string{"pkg-1", "pkg-3", "pkg-5", "pkg-7"}
|
||||
if !equalStringSlices(res.toInstallRegular, wantToInstall) {
|
||||
t.Errorf("toInstall: want %v, got %v", wantToInstall, res.toInstallRegular)
|
||||
}
|
||||
if !equalStringSlices(res.alreadyInstalled, wantAlready) {
|
||||
t.Errorf("alreadyInstalled: want %v, got %v", wantAlready, res.alreadyInstalled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCustomPackagesParallel(t *testing.T) {
|
||||
defer resetMocks()
|
||||
|
||||
// pkg with InstallPath /tmp/foo-N; "installed" iff N is odd.
|
||||
osStat = func(name string) (os.FileInfo, error) {
|
||||
// Map: name like /tmp/foo-1 → installed; /tmp/foo-0 → not.
|
||||
idx := name[len(name)-1] - '0'
|
||||
if idx%2 == 1 {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
pkgs := []*CustomPackage{
|
||||
{Name: "p0", InstallPath: "/tmp/foo-0"},
|
||||
{Name: "p1", InstallPath: "/tmp/foo-1"},
|
||||
{Name: "p2", InstallPath: "/tmp/foo-2"},
|
||||
{Name: "p3", InstallPath: "/tmp/foo-3"},
|
||||
}
|
||||
res := checkCustomPackages(pkgs)
|
||||
|
||||
if len(res.toInstall) != 2 || res.toInstall[0].Name != "p0" || res.toInstall[1].Name != "p2" {
|
||||
t.Errorf("toInstall: want p0,p2 in order, got %v", names(res.toInstall))
|
||||
}
|
||||
if len(res.alreadyInstalled) != 2 || res.alreadyInstalled[0].pkg.Name != "p1" || res.alreadyInstalled[1].pkg.Name != "p3" {
|
||||
t.Errorf("already: want p1,p3 in order, got %v", customNames(res.alreadyInstalled))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallNpmToolsBatchSinglePnpmCall(t *testing.T) {
|
||||
defer resetMocks()
|
||||
|
||||
// Pretend ~/.nvm exists so NVM check passes.
|
||||
osStat = func(name string) (os.FileInfo, error) {
|
||||
if strings.HasSuffix(name, ".nvm") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
var shellCalls []string
|
||||
runShell = func(cmd string, _ CmdOpts) CmdResult {
|
||||
shellCalls = append(shellCalls, cmd)
|
||||
return CmdResult{ExitCode: 0}
|
||||
}
|
||||
|
||||
pkgs := []*CustomPackage{
|
||||
{Name: "claude"},
|
||||
{Name: "codex"},
|
||||
{Name: "copilot"},
|
||||
}
|
||||
installNpmToolsBatch(pkgs)
|
||||
|
||||
// Expect exactly one pnpm add -g call containing all three packages.
|
||||
addCalls := 0
|
||||
for _, c := range shellCalls {
|
||||
if strings.Contains(c, "pnpm add -g") {
|
||||
addCalls++
|
||||
if !strings.Contains(c, "@anthropic-ai/claude-code") ||
|
||||
!strings.Contains(c, "@openai/codex") ||
|
||||
!strings.Contains(c, "@github/copilot") {
|
||||
t.Errorf("expected all three npm names in batched call, got: %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
if addCalls != 1 {
|
||||
t.Errorf("expected exactly 1 batched pnpm add call, got %d (all calls: %v)", addCalls, shellCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallNpmToolsBatchFallback(t *testing.T) {
|
||||
defer resetMocks()
|
||||
osStat = func(name string) (os.FileInfo, error) {
|
||||
if strings.HasSuffix(name, ".nvm") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
calls := 0
|
||||
runShell = func(cmd string, _ CmdOpts) CmdResult {
|
||||
calls++
|
||||
// Fail the first (batched) pnpm add call; succeed thereafter.
|
||||
if calls == 1 && strings.Contains(cmd, "pnpm add -g") {
|
||||
return CmdResult{ExitCode: 1}
|
||||
}
|
||||
return CmdResult{ExitCode: 0}
|
||||
}
|
||||
|
||||
pkgs := []*CustomPackage{
|
||||
{Name: "claude"},
|
||||
{Name: "codex"},
|
||||
}
|
||||
installNpmToolsBatch(pkgs)
|
||||
|
||||
// 1 ensureNodeLTS + 1 batch + 2 per-package retries (each may emit
|
||||
// 2 shell calls: ensureNodeLTS again + add). Just sanity-check that
|
||||
// retries happened.
|
||||
if calls < 3 {
|
||||
t.Errorf("expected at least 3 shell calls after batch failure, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallCustomPackagesWavesIndependentFirst(t *testing.T) {
|
||||
defer resetMocks()
|
||||
|
||||
// Make hasCmd / osStat permissive. ~/.nvm must "exist" so the
|
||||
// npm-batch path doesn't bail out at its precondition check.
|
||||
hasCmd = func(name string) bool { return true }
|
||||
osStat = func(name string) (os.FileInfo, error) {
|
||||
if strings.HasSuffix(name, ".nvm") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
osReadFile = func(name string) ([]byte, error) {
|
||||
return []byte{}, os.ErrNotExist
|
||||
}
|
||||
|
||||
var orderMu sync.Mutex
|
||||
var order []string
|
||||
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||
orderMu.Lock()
|
||||
order = append(order, strings.Join(argv, " "))
|
||||
orderMu.Unlock()
|
||||
return CmdResult{ExitCode: 0}
|
||||
}
|
||||
runShell = func(cmd string, _ CmdOpts) CmdResult {
|
||||
orderMu.Lock()
|
||||
order = append(order, cmd)
|
||||
orderMu.Unlock()
|
||||
return CmdResult{ExitCode: 0}
|
||||
}
|
||||
download = func(_, _ string) bool { return true }
|
||||
fetchJSON = func(_ string, _ any) bool { return false }
|
||||
fetchText = func(_ string) string { return "" }
|
||||
|
||||
// Mix of independent + node-dependent. We just verify dispatch order:
|
||||
// the npm-batched call must appear after some Wave A activity.
|
||||
pkgs := []*CustomPackage{
|
||||
{Name: "agy"},
|
||||
{Name: "oh-my-zsh"},
|
||||
{Name: "claude"},
|
||||
{Name: "codex"},
|
||||
}
|
||||
installCustomPackages(pkgs)
|
||||
|
||||
// The pnpm add -g call must exist and appear after agy/oh-my-zsh
|
||||
// install attempts.
|
||||
var firstBatchIdx, firstWaveAIdx int = -1, -1
|
||||
for i, c := range order {
|
||||
if strings.Contains(c, "pnpm add -g @anthropic-ai/claude-code") {
|
||||
firstBatchIdx = i
|
||||
}
|
||||
if (strings.Contains(c, "antigravity.google") || strings.Contains(c, "ohmyzsh")) && firstWaveAIdx == -1 {
|
||||
firstWaveAIdx = i
|
||||
}
|
||||
}
|
||||
if firstWaveAIdx == -1 {
|
||||
t.Errorf("expected to see Wave A activity (agy/oh-my-zsh), got order: %v", order)
|
||||
}
|
||||
if firstBatchIdx == -1 {
|
||||
t.Errorf("expected to see batched pnpm add call, got order: %v", order)
|
||||
}
|
||||
if firstWaveAIdx > firstBatchIdx {
|
||||
t.Errorf("expected Wave A activity to begin before Wave B batch, got waveA@%d batch@%d", firstWaveAIdx, firstBatchIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLatestAllRunsInParallel(t *testing.T) {
|
||||
defer resetMocks()
|
||||
|
||||
// Register a custom resolver that records start order.
|
||||
var mu sync.Mutex
|
||||
var starts []string
|
||||
|
||||
latestResolvers["test-fast"] = func(p *CustomPackage) (string, string, bool) {
|
||||
mu.Lock()
|
||||
starts = append(starts, p.Name)
|
||||
mu.Unlock()
|
||||
return p.Version, p.SHA256, true
|
||||
}
|
||||
defer delete(latestResolvers, "test-fast")
|
||||
|
||||
pkgs := []*CustomPackage{
|
||||
{Name: "x", Version: "1", SHA256: "a", FetchLatest: "test-fast"},
|
||||
{Name: "y", Version: "2", SHA256: "b", FetchLatest: "test-fast"},
|
||||
{Name: "z", Version: "3", SHA256: "c", FetchLatest: "test-fast"},
|
||||
}
|
||||
resolveLatestAll(pkgs)
|
||||
|
||||
if len(starts) != 3 {
|
||||
t.Errorf("expected all 3 resolvers invoked, got %d: %v", len(starts), starts)
|
||||
}
|
||||
}
|
||||
|
||||
// ── small helpers/fakes ────────────────────────────────────────────────
|
||||
|
||||
func equalStringSlices(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func names(pkgs []*CustomPackage) []string {
|
||||
out := make([]string, len(pkgs))
|
||||
for i, p := range pkgs {
|
||||
out[i] = p.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func customNames(s []customStatus) []string {
|
||||
out := make([]string, len(s))
|
||||
for i, st := range s {
|
||||
out[i] = st.pkg.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,224 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type systemCheckResult struct {
|
||||
toInstallRegular []string
|
||||
toInstallSpecial []string
|
||||
alreadyInstalled []string
|
||||
skipped []string
|
||||
remapped []remap // for display only
|
||||
}
|
||||
|
||||
type remap struct {
|
||||
From string
|
||||
To []string
|
||||
}
|
||||
|
||||
type flatpakCheckResult struct {
|
||||
toInstall []string
|
||||
alreadyInstalled []string
|
||||
}
|
||||
|
||||
type customCheckResult struct {
|
||||
toInstall []*CustomPackage
|
||||
alreadyInstalled []customStatus
|
||||
}
|
||||
|
||||
type customStatus struct {
|
||||
pkg *CustomPackage
|
||||
path string
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
var remapped []remap
|
||||
for _, n := range names {
|
||||
if ov, ok := overrides[n]; ok && !ov.Skip {
|
||||
remapped = append(remapped, remap{From: n, To: ov.Replacement})
|
||||
}
|
||||
}
|
||||
|
||||
specials := specialPkgs()
|
||||
var special, regular []string
|
||||
for _, p := range resolved {
|
||||
if specials[p] {
|
||||
special = append(special, p)
|
||||
} else {
|
||||
regular = append(regular, p)
|
||||
}
|
||||
}
|
||||
|
||||
alreadyR, toR := parallelPartition(regular, isSystemPkgInstalled)
|
||||
alreadyS, toS := parallelPartition(special, isSpecialPkgInstalled)
|
||||
return systemCheckResult{
|
||||
toInstallRegular: toR,
|
||||
toInstallSpecial: toS,
|
||||
alreadyInstalled: append(alreadyR, alreadyS...),
|
||||
skipped: skipped,
|
||||
remapped: remapped,
|
||||
}
|
||||
}
|
||||
|
||||
func checkFlatpakPackages(ids []string) flatpakCheckResult {
|
||||
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 i, p := range pkgs {
|
||||
if results[i].installed {
|
||||
already = append(already, customStatus{pkg: p, path: results[i].path})
|
||||
} else {
|
||||
to = append(to, p)
|
||||
}
|
||||
}
|
||||
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, " ")
|
||||
}
|
||||
return strings.Join(items[:limit], " ") + fmt.Sprintf(" … +%d more", len(items)-limit)
|
||||
}
|
||||
|
||||
func printCheckSummary(sys systemCheckResult, flat flatpakCheckResult, cust customCheckResult, only string) int {
|
||||
total := 0
|
||||
|
||||
if only == "" || only == "system" {
|
||||
toR := sys.toInstallRegular
|
||||
toS := sys.toInstallSpecial
|
||||
ok := sys.alreadyInstalled
|
||||
fmt.Println("\nSystem packages:")
|
||||
if len(ok) > 0 {
|
||||
fmt.Printf(" [OK] %3d already installed\n", len(ok))
|
||||
}
|
||||
n := len(toR) + len(toS)
|
||||
if n > 0 {
|
||||
combined := append([]string{}, toR...)
|
||||
combined = append(combined, toS...)
|
||||
fmt.Printf(" [INSTALL] %3d to install: %s\n", n, fmtList(combined, 6))
|
||||
}
|
||||
if len(sys.skipped) > 0 {
|
||||
fmt.Printf(" [SKIP] %3d overridden (→ skip): %s\n", len(sys.skipped), fmtList(sys.skipped, 6))
|
||||
}
|
||||
if len(sys.remapped) > 0 {
|
||||
var parts []string
|
||||
for _, r := range sys.remapped {
|
||||
parts = append(parts, fmt.Sprintf("%s→%s", r.From, strings.Join(r.To, ",")))
|
||||
}
|
||||
fmt.Printf(" [REMAP] remapped: %s\n", strings.Join(parts, " "))
|
||||
}
|
||||
total += n
|
||||
}
|
||||
|
||||
if (only == "" || only == "flatpak") && (len(flat.toInstall) > 0 || len(flat.alreadyInstalled) > 0) {
|
||||
fmt.Println("\nFlatpak packages:")
|
||||
if len(flat.alreadyInstalled) > 0 {
|
||||
fmt.Printf(" [OK] %3d already installed\n", len(flat.alreadyInstalled))
|
||||
}
|
||||
if len(flat.toInstall) > 0 {
|
||||
fmt.Printf(" [INSTALL] %3d to install: %s\n", len(flat.toInstall), fmtList(flat.toInstall, 6))
|
||||
}
|
||||
total += len(flat.toInstall)
|
||||
}
|
||||
|
||||
if only == "" || only == "custom" {
|
||||
fmt.Println("\nCustom packages:")
|
||||
for _, s := range cust.alreadyInstalled {
|
||||
suffix := ""
|
||||
if s.path != "" {
|
||||
suffix = fmt.Sprintf(" (%s)", s.path)
|
||||
}
|
||||
fmt.Printf(" [OK] %s%s\n", s.pkg.displayName(), suffix)
|
||||
}
|
||||
for _, p := range cust.toInstall {
|
||||
_, path := isCustomPkgInstalled(p)
|
||||
suffix := ""
|
||||
if path != "" {
|
||||
suffix = fmt.Sprintf(" → %s", path)
|
||||
}
|
||||
fmt.Printf(" [INSTALL] %s%s\n", p.displayName(), suffix)
|
||||
}
|
||||
total += len(cust.toInstall)
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
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, 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 "all":
|
||||
targets = []string{"debian:latest", "archlinux:latest", "fedora:latest"}
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unsupported OS: %s. Supported: debian, arch, fedora, 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") {
|
||||
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"})
|
||||
} 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"})
|
||||
} 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"})
|
||||
} else {
|
||||
return fmt.Errorf("unsupported target OS: %s", target)
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
// 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 against the full package set (no scope flags).
|
||||
// We pipe 'y' to satisfy the "Proceed? [y/N]" prompt.
|
||||
fmt.Printf("[%s] Executing bootstrap_environment...\n", target)
|
||||
testContainer = testContainer.WithExec([]string{"sh", "-c", "echo y | bootstrap_environment --gui"})
|
||||
|
||||
// 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)
|
||||
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!")
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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"
|
||||
)
|
||||
+1168
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
package main
|
||||
|
||||
// Last wave of coverage tests targeting setupFirecrackerVM (testable
|
||||
// early-return branches), the remaining install handler edge cases, and
|
||||
// a few stragglers.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── setupFirecrackerVM: early returns ───────────────────────────────────
|
||||
|
||||
func TestSetupFirecrackerVMNotMac(t *testing.T) {
|
||||
defer resetMocks()
|
||||
isMacOS = false
|
||||
setupFirecrackerVM() // should no-op
|
||||
}
|
||||
|
||||
func TestSetupFirecrackerVMBackendEmpty(t *testing.T) {
|
||||
defer resetMocks()
|
||||
isMacOS = true
|
||||
archName = "aarch64"
|
||||
// Apple M2 on macOS 14 → selectVMBackend returns "" → setup skips.
|
||||
probe = func(argv []string, _ time.Duration) (CmdResult, bool) {
|
||||
if argv[0] == "sw_vers" {
|
||||
return CmdResult{ExitCode: 0, Stdout: []byte("14.0")}, true
|
||||
}
|
||||
return CmdResult{ExitCode: 0, Stdout: []byte("Apple M2")}, true
|
||||
}
|
||||
captureStdout(t, func() {
|
||||
setupFirecrackerVM()
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetupFirecrackerVMSshKeygenFails(t *testing.T) {
|
||||
defer resetMocks()
|
||||
isMacOS = true
|
||||
archName = "x86_64"
|
||||
osStat = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist }
|
||||
osMkdirAll = func(_ string, _ os.FileMode) error { return nil }
|
||||
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||
if argv[0] == "ssh-keygen" {
|
||||
return CmdResult{ExitCode: 1}
|
||||
}
|
||||
return CmdResult{ExitCode: 0}
|
||||
}
|
||||
captureStdout(t, func() {
|
||||
setupFirecrackerVM()
|
||||
})
|
||||
if !hasIssueContaining("ssh-keygen failed") {
|
||||
t.Error("expected ssh-keygen failure error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupFirecrackerVMFedoraImageLookupFails(t *testing.T) {
|
||||
defer resetMocks()
|
||||
isMacOS = true
|
||||
archName = "x86_64"
|
||||
osStat = func(name string) (os.FileInfo, error) {
|
||||
// Key exists; qcow2 missing.
|
||||
if strings.HasSuffix(name, "id_ed25519") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
osMkdirAll = func(_ string, _ os.FileMode) error { return nil }
|
||||
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||
fetchText = func(_ string) string { return "" }
|
||||
captureStdout(t, func() {
|
||||
setupFirecrackerVM()
|
||||
})
|
||||
if !hasIssueContaining("Could not resolve latest Fedora") {
|
||||
t.Error("expected Fedora lookup failure error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupFirecrackerVMPubKeyReadFails(t *testing.T) {
|
||||
defer resetMocks()
|
||||
isMacOS = true
|
||||
archName = "x86_64"
|
||||
osStat = func(_ string) (os.FileInfo, error) { return nil, nil } // key + qcow2 exist
|
||||
osMkdirAll = func(_ string, _ os.FileMode) error { return nil }
|
||||
osReadFile = func(_ string) ([]byte, error) { return nil, os.ErrNotExist }
|
||||
captureStdout(t, func() {
|
||||
setupFirecrackerVM()
|
||||
})
|
||||
if !hasIssueContaining("could not read public key") {
|
||||
t.Error("expected pub-key read failure")
|
||||
}
|
||||
}
|
||||
|
||||
// ── installFirecracker: archive contains non-firecracker file ──────────
|
||||
|
||||
func TestInstallFirecrackerSkipsNonMatchingFiles(t *testing.T) {
|
||||
defer resetMocks()
|
||||
tmp := t.TempDir()
|
||||
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
||||
if argv[0] == "tar" {
|
||||
// Drop a file that doesn't start with "firecracker" — should be skipped.
|
||||
_ = os.WriteFile(filepath.Join(tmp, "README"), []byte("x"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(tmp, "firecracker-v1"), []byte("x"), 0o755)
|
||||
}
|
||||
return CmdResult{ExitCode: 0}
|
||||
}
|
||||
installFirecracker(filepath.Join(tmp, "fc.tgz"), tmp)
|
||||
}
|
||||
|
||||
// ── installZig: existing glob match in /usr/local needs a writable parent ──
|
||||
|
||||
// We can't write to /usr/local in tests, but we can verify the symlink
|
||||
// path runs through end-to-end with a no-op runCmd. The Glob returns []
|
||||
// in tests, so the loop body stays uncovered.
|
||||
|
||||
// ── resolveLatestGo: version trimmed to empty (release tag was just "go") ──
|
||||
|
||||
func TestResolveLatestGoEmptyTrimmedVersion(t *testing.T) {
|
||||
defer resetMocks()
|
||||
fetchJSON = func(_ string, v any) bool {
|
||||
// Release with version "go" → trim → empty.
|
||||
data := `[{"version":"go","files":[]}]`
|
||||
_ = v
|
||||
// Marshal manually since we don't import json here; use the helper
|
||||
// via reflection-free path: use the canonical mock from elsewhere.
|
||||
return jsonUnmarshal([]byte(data), v)
|
||||
}
|
||||
if _, _, ok := resolveLatestGo(nil); ok {
|
||||
t.Error("expected resolveLatestGo false when version is empty after trim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLatestFirecrackerEmptyTagTrim(t *testing.T) {
|
||||
defer resetMocks()
|
||||
isMacOS = false
|
||||
fetchJSON = func(_ string, v any) bool {
|
||||
v.(*ghRelease).TagName = "v" // → trimmed to ""
|
||||
return true
|
||||
}
|
||||
if _, _, ok := resolveLatestFirecracker(nil); ok {
|
||||
t.Error("expected false when trimmed tag is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// ── runMain: empty package lists short-circuit ──────────────────────────
|
||||
|
||||
func TestRunMainEmptyOnlyValid(t *testing.T) {
|
||||
defer resetMocks()
|
||||
pkgMgr = "dnf"
|
||||
osStat = func(_ string) (os.FileInfo, error) { return nil, nil }
|
||||
probe = func(_ []string, _ time.Duration) (CmdResult, bool) { return CmdResult{ExitCode: 0}, true }
|
||||
hasCmd = func(_ string) bool { return true }
|
||||
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||
osExit = func(_ int) {}
|
||||
captureStdout(t, func() {
|
||||
// "" only flag (default) with everything reported as installed.
|
||||
runMain([]string{"bootstrap_environment"})
|
||||
})
|
||||
}
|
||||
|
||||
func jsonUnmarshal(data []byte, v any) bool {
|
||||
return json.Unmarshal(data, v) == nil
|
||||
}
|
||||
|
||||
// ── extra runMain branches ──────────────────────────────────────────────
|
||||
|
||||
func TestRunMainSystemInstallPath(t *testing.T) {
|
||||
defer resetMocks()
|
||||
pkgMgr = "dnf"
|
||||
osStat = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist }
|
||||
probe = func(_ []string, _ time.Duration) (CmdResult, bool) { return CmdResult{ExitCode: 1}, true }
|
||||
hasCmd = func(_ string) bool { return true }
|
||||
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||
stdin = strings.NewReader("y\n")
|
||||
defer func() { stdin = os.Stdin }()
|
||||
osExit = func(_ int) {}
|
||||
captureStdout(t, func() {
|
||||
runMain([]string{"bootstrap_environment", "--only", "system"})
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunMainCustomInstallPath(t *testing.T) {
|
||||
defer resetMocks()
|
||||
pkgMgr = "dnf"
|
||||
// Custom pkgs all need install (not present). The osStat mock runs
|
||||
// from multiple goroutines via parallel Wave A, so it must be
|
||||
// goroutine-safe (no shared mutable state outside of read-only env
|
||||
// inspection).
|
||||
osStat = func(name string) (os.FileInfo, error) {
|
||||
// ~/.nvm exists so the npm batch path runs.
|
||||
if strings.HasSuffix(name, ".nvm") || strings.HasSuffix(name, ".pyenv") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
probe = func(_ []string, _ time.Duration) (CmdResult, bool) { return CmdResult{ExitCode: 1}, true }
|
||||
hasCmd = func(_ string) bool { return true }
|
||||
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0, Stdout: []byte("v20\n")} }
|
||||
download = func(_, dest string) bool {
|
||||
return os.WriteFile(dest, []byte("x"), 0o644) == nil
|
||||
}
|
||||
fetchJSON = func(_ string, _ any) bool { return false }
|
||||
fetchText = func(_ string) string { return "" }
|
||||
stdin = strings.NewReader("y\n")
|
||||
defer func() { stdin = os.Stdin }()
|
||||
osExit = func(_ int) {}
|
||||
captureStdout(t, func() {
|
||||
runMain([]string{"bootstrap_environment", "--only", "custom"})
|
||||
})
|
||||
}
|
||||
|
||||
// ── runShellReal: probe times out via tiny timeout ──────────────────────
|
||||
|
||||
func TestRunShellRealTimeout(t *testing.T) {
|
||||
r := runShellReal("sleep 1", CmdOpts{Timeout: 10 * time.Millisecond})
|
||||
if r.ExitCode != 124 {
|
||||
t.Errorf("expected timeout (124), got %d", r.ExitCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ── runCmdReal: command times out ───────────────────────────────────────
|
||||
|
||||
func TestRunCmdRealTimeout(t *testing.T) {
|
||||
r := runCmdReal([]string{"sleep", "1"}, CmdOpts{Timeout: 10 * time.Millisecond})
|
||||
if r.ExitCode != 124 {
|
||||
t.Errorf("expected timeout (124), got %d", r.ExitCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ── runCmdReal: cwd + input passing ────────────────────────────────────
|
||||
|
||||
func TestRunCmdRealCwdAndInput(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
r := runCmdReal([]string{"sh", "-c", "cat > out.txt; pwd"},
|
||||
CmdOpts{Cwd: tmp, Input: []byte("data"), Capture: true})
|
||||
if !r.OK() {
|
||||
t.Fatalf("expected OK, got: %v / %s", r.Err, r.Stderr)
|
||||
}
|
||||
if !strings.Contains(string(r.Stdout), tmp) {
|
||||
t.Errorf("expected stdout to contain cwd %s, got: %s", tmp, r.Stdout)
|
||||
}
|
||||
if data, err := os.ReadFile(filepath.Join(tmp, "out.txt")); err != nil || string(data) != "data" {
|
||||
t.Errorf("expected stdin data to be written, got: %q (err=%v)", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── runShellReal: cwd + input ──────────────────────────────────────────
|
||||
|
||||
func TestRunShellRealCwdAndInput(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
r := runShellReal("cat > shell-out.txt; pwd",
|
||||
CmdOpts{Cwd: tmp, Input: []byte("shelldata"), Capture: true})
|
||||
if !r.OK() {
|
||||
t.Fatalf("expected OK, got %v", r.Err)
|
||||
}
|
||||
if data, err := os.ReadFile(filepath.Join(tmp, "shell-out.txt")); err != nil || string(data) != "shelldata" {
|
||||
t.Errorf("expected stdin data written via shell, got %q err=%v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── installPip apt-get fallback secondary failure ──────────────────────
|
||||
|
||||
func TestInstallPipAptFallbackFails(t *testing.T) {
|
||||
defer resetMocks()
|
||||
pkgMgr = "apt-get"
|
||||
hasCmd = func(_ string) bool { return true }
|
||||
probe = func(_ []string, _ time.Duration) (CmdResult, bool) { return CmdResult{ExitCode: 0}, true }
|
||||
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 1} } // everything fails
|
||||
installPip()
|
||||
if !hasIssueContaining("python3-pip failed to install via apt-get") {
|
||||
t.Error("expected apt python3-pip failure")
|
||||
}
|
||||
}
|
||||
|
||||
// ── ensureXcodeCLT non-macOS quick exit (already added but exercise the cov path) ──
|
||||
|
||||
// ── invokingUser: SUDO_USER unset, user.Current succeeds ──
|
||||
|
||||
func TestInvokingUserNoSudoCurrentUser(t *testing.T) {
|
||||
t.Setenv("SUDO_USER", "")
|
||||
if invokingUser() == "" {
|
||||
t.Error("expected invokingUser to fall back to user.Current()")
|
||||
}
|
||||
}
|
||||
|
||||
// ── installSystemPackages: tmpdir works for specials ──────────────────
|
||||
|
||||
func TestInstallSystemPackagesWithSpecialReal(t *testing.T) {
|
||||
defer resetMocks()
|
||||
pkgMgr = "dnf"
|
||||
hasCmd = func(_ string) bool { return true }
|
||||
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||
captureStdout(t, func() {
|
||||
installSystemPackages([]string{"git"}, []string{"pipx"})
|
||||
})
|
||||
}
|
||||
|
||||
// ── npmInstalled: home-dir failure path ────────────────────────────────
|
||||
|
||||
// The os.UserHomeDir call only returns an error when HOME is unset on Unix
|
||||
// AND no /etc/passwd entry exists. Hard to trigger reliably across CI; the
|
||||
// branch is mostly defensive. Skip explicit coverage.
|
||||
|
||||
// ── runOneCustomInstall: install path missing, with name != pip ────────
|
||||
|
||||
func TestRunOneCustomInstallNoCheckPath(t *testing.T) {
|
||||
defer resetMocks()
|
||||
// Pretend nothing is installed and use a package that has no install path
|
||||
// AND no URL — should warn twice.
|
||||
osStat = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist }
|
||||
hasCmd = func(_ string) bool { return false }
|
||||
runOneCustomInstall(&CustomPackage{Name: "unknownpkg-2"})
|
||||
}
|
||||
|
||||
// ── repos: apt-get docker setup (no existing file) ────────────────────
|
||||
// Without docker installed this exercises the gpg+keyring branch via mocks.
|
||||
|
||||
func TestSetupDockerRepoApt(t *testing.T) {
|
||||
defer resetMocks()
|
||||
pkgMgr = "apt-get"
|
||||
osStat = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist }
|
||||
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||
osReadFile = func(_ string) ([]byte, error) {
|
||||
return []byte("ID=ubuntu\n"), nil
|
||||
}
|
||||
captureStdout(t, func() {
|
||||
setupDockerRepo()
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetupChromeRepoApt(t *testing.T) {
|
||||
defer resetMocks()
|
||||
pkgMgr = "apt-get"
|
||||
archName = "x86_64"
|
||||
osStat = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist }
|
||||
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||
captureStdout(t, func() {
|
||||
setupChromeRepo()
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetupVivaldiRepoApt(t *testing.T) {
|
||||
defer resetMocks()
|
||||
pkgMgr = "apt-get"
|
||||
archName = "x86_64"
|
||||
osStat = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist }
|
||||
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
|
||||
captureStdout(t, func() {
|
||||
setupVivaldiRepo()
|
||||
})
|
||||
}
|
||||
|
||||
// ── installFirecrackerZshFunction: existing block gets replaced ───────
|
||||
|
||||
func TestInstallFirecrackerZshFunctionReplaceExisting(t *testing.T) {
|
||||
defer resetMocks()
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("HOME", tmp)
|
||||
osReadFile = func(_ string) ([]byte, error) {
|
||||
return []byte("# >>> firecracker-vm wrapper >>>\nold body\n# <<< firecracker-vm wrapper <<<\n\nelse"), nil
|
||||
}
|
||||
written := ""
|
||||
osWriteFile = func(_ string, data []byte, _ os.FileMode) error {
|
||||
written = string(data)
|
||||
return nil
|
||||
}
|
||||
newBlock := "# >>> firecracker-vm wrapper >>>\nnew body\n# <<< firecracker-vm wrapper <<<\n"
|
||||
installFirecrackerZshFunction(newBlock)
|
||||
if !strings.Contains(written, "new body") {
|
||||
t.Errorf("expected new body in output, got: %q", written)
|
||||
}
|
||||
if strings.Contains(written, "old body") {
|
||||
t.Errorf("expected old body to be removed, got: %q", written)
|
||||
}
|
||||
}
|
||||
|
||||
// ── latestFedoraCloudImage: missing checksum entry ────────────────────
|
||||
|
||||
func TestLatestFedoraCloudImageMissingFiles(t *testing.T) {
|
||||
defer resetMocks()
|
||||
archName = "x86_64"
|
||||
calls := 0
|
||||
fetchText = func(_ string) string {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return `href="40/"`
|
||||
}
|
||||
// images dir has no matching qcow / checksum.
|
||||
return `href="not-fedora.iso"`
|
||||
}
|
||||
if _, _, _, ok := latestFedoraCloudImage(); ok {
|
||||
t.Error("expected false when matches not found")
|
||||
}
|
||||
}
|
||||
+1475
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,776 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// resolveURL returns the formatted download URL or "" if no template is set.
|
||||
func (p *CustomPackage) resolveURL() string {
|
||||
if p.URLTemplate == "" {
|
||||
return ""
|
||||
}
|
||||
return formatURL(p.URLTemplate, p.Version)
|
||||
}
|
||||
|
||||
func (p *CustomPackage) resolveSHA256URL() string {
|
||||
if p.SHA256URLTemplate == "" {
|
||||
return ""
|
||||
}
|
||||
return formatURL(p.SHA256URLTemplate, p.Version)
|
||||
}
|
||||
|
||||
func (p *CustomPackage) resolvedSHA256() string {
|
||||
if p.SHA256 != "" {
|
||||
return strings.ToLower(p.SHA256)
|
||||
}
|
||||
if p.SHA256Map != nil {
|
||||
key := osName + "-" + archName
|
||||
if v, ok := p.SHA256Map[key]; ok {
|
||||
return strings.ToLower(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *CustomPackage) displayName() string {
|
||||
if p.Version != "" {
|
||||
return p.Name + "-" + p.Version
|
||||
}
|
||||
return p.Name
|
||||
}
|
||||
|
||||
var defaultInstallPaths = map[string]string{
|
||||
"go": "/usr/local/go",
|
||||
"firecracker": "/usr/local/bin/firecracker",
|
||||
"zig": "/usr/local/bin/zig",
|
||||
"nvm": "~/.nvm",
|
||||
"pyenv": "~/.pyenv",
|
||||
"neovim": "/usr/local/bin/nvim",
|
||||
"oh-my-zsh": "~/.oh-my-zsh",
|
||||
"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 {
|
||||
if strings.HasPrefix(p, "~") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
return filepath.Join(home, strings.TrimPrefix(p, "~"))
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func defaultInstallPath(pkg *CustomPackage) string {
|
||||
if p, ok := defaultInstallPaths[strings.ToLower(pkg.Name)]; ok {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pipInstalled() bool {
|
||||
if !hasCmd("python3") {
|
||||
return false
|
||||
}
|
||||
r, ok := probe([]string{"python3", "-m", "pip", "--version"}, 0)
|
||||
return ok && r.ExitCode == 0
|
||||
}
|
||||
|
||||
// isCustomPkgInstalled returns (installed, checkPath). pip ships inside the
|
||||
// Python distribution rather than at a fixed path, so it's detected with
|
||||
// `python3 -m pip --version`.
|
||||
func 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) {
|
||||
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)
|
||||
}
|
||||
if raw == "" {
|
||||
return false, ""
|
||||
}
|
||||
check := expandHome(raw)
|
||||
if _, err := osStat(check); err == nil {
|
||||
return true, check
|
||||
}
|
||||
return false, check
|
||||
}
|
||||
|
||||
// verifyArchive validates a downloaded archive against either a pinned
|
||||
// sha256 or a .minisig signature. Returns true when verified or nothing to
|
||||
// verify (latter case logs a warning).
|
||||
func verifyArchive(archive string, pkg *CustomPackage) bool {
|
||||
if expected := pkg.resolvedSHA256(); expected != "" {
|
||||
actual, err := sha256Of(archive)
|
||||
if err != nil {
|
||||
errLog(fmt.Sprintf("hash failed for %s: %v", pkg.Name, err))
|
||||
return false
|
||||
}
|
||||
if actual != expected {
|
||||
errLog(fmt.Sprintf("SHA256 mismatch for %s: expected %s, got %s", pkg.Name, expected, actual))
|
||||
return false
|
||||
}
|
||||
taskPrintln(" SHA256 OK")
|
||||
return true
|
||||
}
|
||||
if sigURL := pkg.resolveSHA256URL(); sigURL != "" {
|
||||
sigPath := filepath.Join(filepath.Dir(archive), filepath.Base(sigURL))
|
||||
if !download(sigURL, sigPath) {
|
||||
return false
|
||||
}
|
||||
if !hasCmd("minisign") {
|
||||
warn(fmt.Sprintf("minisign not installed — skipping signature verification for %s", pkg.Name))
|
||||
return true
|
||||
}
|
||||
cmd := []string{"minisign", "-Vm", archive, "-x", sigPath}
|
||||
if pkg.MinisignKey != "" {
|
||||
cmd = append(cmd, "-P", pkg.MinisignKey)
|
||||
}
|
||||
if !runCmd(cmd, CmdOpts{Out: taskOut()}).OK() {
|
||||
errLog(fmt.Sprintf("minisign verification failed for %s", pkg.Name))
|
||||
return false
|
||||
}
|
||||
taskPrintln(" minisign OK")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func urlArchOK(pkg *CustomPackage) bool {
|
||||
url := pkg.resolveURL()
|
||||
if url == "" {
|
||||
return true
|
||||
}
|
||||
if archMatches(url, archName) {
|
||||
return true
|
||||
}
|
||||
if hasOtherArchToken(url) {
|
||||
warn(fmt.Sprintf("%s: URL targets %s but host is %s. Update packages.go with a matching URL/SHA256.",
|
||||
pkg.Name, otherArch(), archName))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ── per-package install handlers ────────────────────────────────────────
|
||||
|
||||
func installGo(archive string) {
|
||||
out := taskOut()
|
||||
goRoot := "/usr/local/go"
|
||||
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, Out: out})
|
||||
appendProfileLine("local_go", "export PATH=$PATH:/usr/local/go/bin")
|
||||
taskPrintf(" Go installed to %s\n", goRoot)
|
||||
}
|
||||
|
||||
func installFirecracker(archive, tmp string) {
|
||||
out := taskOut()
|
||||
if !runCmd([]string{"tar", "-C", tmp, "-xzf", archive}, CmdOpts{Out: out}).OK() {
|
||||
errLog("firecracker tar extraction failed")
|
||||
return
|
||||
}
|
||||
var binary string
|
||||
filepath.Walk(tmp, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
name := info.Name()
|
||||
if strings.HasSuffix(name, ".tgz") || strings.HasSuffix(name, ".tar.gz") {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasPrefix(name, "firecracker-v") || strings.Contains(name, "debug") {
|
||||
return nil
|
||||
}
|
||||
if binary == "" {
|
||||
binary = path
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if binary == "" {
|
||||
errLog("firecracker binary not found in archive")
|
||||
return
|
||||
}
|
||||
dest := "/usr/local/bin/firecracker"
|
||||
runCmd([]string{"cp", binary, dest}, CmdOpts{AsSudo: true, 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 := osStat(zigDir); err == nil {
|
||||
runCmd([]string{"rm", "-rf", zigDir}, CmdOpts{AsSudo: true, Out: out})
|
||||
}
|
||||
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, Out: out})
|
||||
break
|
||||
}
|
||||
}
|
||||
symlink := "/usr/local/bin/zig"
|
||||
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
|
||||
}
|
||||
archTok := archNvim[archName]
|
||||
osTok := osNvim[osName]
|
||||
assetName := fmt.Sprintf("nvim-%s-%s.tar.gz", osTok, archTok)
|
||||
|
||||
var asset *ghAsset
|
||||
for i := range rel.Assets {
|
||||
if rel.Assets[i].Name == assetName {
|
||||
asset = &rel.Assets[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if asset == nil {
|
||||
errLog(fmt.Sprintf("Neovim asset %s not found", assetName))
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(asset.Digest, "sha256:") {
|
||||
errLog("Neovim asset digest missing or invalid")
|
||||
return
|
||||
}
|
||||
expected := strings.TrimPrefix(asset.Digest, "sha256:")
|
||||
dest := filepath.Join(tmp, assetName)
|
||||
if !download(asset.BrowserDownloadURL, dest) {
|
||||
return
|
||||
}
|
||||
actual, err := sha256Of(dest)
|
||||
if err != nil {
|
||||
errLog(fmt.Sprintf("Neovim hash failed: %v", err))
|
||||
return
|
||||
}
|
||||
if actual != expected {
|
||||
errLog(fmt.Sprintf("Neovim SHA256 mismatch: expected %s, got %s", expected, actual))
|
||||
return
|
||||
}
|
||||
taskPrintln(" SHA256 OK")
|
||||
|
||||
installDir := fmt.Sprintf("/opt/nvim-%s-%s", osTok, archTok)
|
||||
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, Out: out})
|
||||
symlink := "/usr/local/bin/nvim"
|
||||
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 ────────────────────────────────────────────
|
||||
|
||||
func resolveLatestGo(_ *CustomPackage) (string, string, bool) {
|
||||
var raw json.RawMessage
|
||||
if !fetchJSON("https://go.dev/dl/?mode=json", &raw) {
|
||||
return "", "", false
|
||||
}
|
||||
// API returns an array; first element is the latest stable release.
|
||||
type goFile struct {
|
||||
Filename string `json:"filename"`
|
||||
Kind string `json:"kind"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
type goRelease struct {
|
||||
Version string `json:"version"`
|
||||
Files []goFile `json:"files"`
|
||||
}
|
||||
var releases []goRelease
|
||||
if err := json.Unmarshal(raw, &releases); err != nil || len(releases) == 0 {
|
||||
var single goRelease
|
||||
if err := json.Unmarshal(raw, &single); err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
releases = []goRelease{single}
|
||||
}
|
||||
latest := releases[0]
|
||||
version := strings.TrimPrefix(latest.Version, "go")
|
||||
if version == "" {
|
||||
return "", "", false
|
||||
}
|
||||
archiveName := fmt.Sprintf("go%s.%s-%s.tar.gz", version, osGo[osName], archGo[archName])
|
||||
for _, f := range latest.Files {
|
||||
if f.Filename == archiveName && f.Kind == "archive" && f.SHA256 != "" {
|
||||
return version, f.SHA256, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func resolveLatestFirecracker(_ *CustomPackage) (string, string, bool) {
|
||||
if isMacOS {
|
||||
return "", "", false
|
||||
}
|
||||
var rel ghRelease
|
||||
if !fetchJSON("https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest", &rel) {
|
||||
return "", "", false
|
||||
}
|
||||
version := strings.TrimPrefix(rel.TagName, "v")
|
||||
if version == "" {
|
||||
return "", "", false
|
||||
}
|
||||
archiveName := fmt.Sprintf("firecracker-v%s-%s.tgz", version, archName)
|
||||
shaAssetName := archiveName + ".sha256.txt"
|
||||
for _, a := range rel.Assets {
|
||||
if a.Name == shaAssetName {
|
||||
sha := fetchText(a.BrowserDownloadURL)
|
||||
if sha == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return version, strings.Fields(sha)[0], true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
var zigVersionRe = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
|
||||
|
||||
func resolveLatestZig(_ *CustomPackage) (string, string, bool) {
|
||||
var data map[string]map[string]any
|
||||
if !fetchJSON("https://ziglang.org/download/index.json", &data) {
|
||||
return "", "", false
|
||||
}
|
||||
var stable []string
|
||||
for k := range data {
|
||||
if k != "master" && zigVersionRe.MatchString(k) {
|
||||
stable = append(stable, k)
|
||||
}
|
||||
}
|
||||
if len(stable) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
sort.Slice(stable, func(i, j int) bool {
|
||||
return cmpSemver(stable[i], stable[j]) < 0
|
||||
})
|
||||
version := stable[len(stable)-1]
|
||||
key := archName + "-" + osZig[osName]
|
||||
entry, ok := data[version][key].(map[string]any)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
sha, _ := entry["shasum"].(string)
|
||||
if sha == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return version, sha, true
|
||||
}
|
||||
|
||||
func cmpSemver(a, b string) int {
|
||||
pa := strings.Split(a, ".")
|
||||
pb := strings.Split(b, ".")
|
||||
for i := 0; i < len(pa) && i < len(pb); i++ {
|
||||
ai, _ := strconv.Atoi(pa[i])
|
||||
bi, _ := strconv.Atoi(pb[i])
|
||||
if ai != bi {
|
||||
if ai < bi {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return len(pa) - len(pb)
|
||||
}
|
||||
|
||||
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.
|
||||
// On any failure, warns and leaves the pinned values in place.
|
||||
func resolveLatest(pkg *CustomPackage) {
|
||||
resolver, ok := latestResolvers[pkg.FetchLatest]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
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",
|
||||
pkg.Name, r, pkg.Version))
|
||||
}
|
||||
}()
|
||||
version, sha, found := resolver(pkg)
|
||||
if !found {
|
||||
warn(fmt.Sprintf("%s: could not resolve latest version; falling back to pinned version %s",
|
||||
pkg.Name, pkg.Version))
|
||||
return
|
||||
}
|
||||
if version == pkg.Version {
|
||||
taskPrintf(" Pinned version %s is already the latest.\n", pkg.Version)
|
||||
return
|
||||
}
|
||||
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 ===")
|
||||
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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OS / architecture detection.
|
||||
//
|
||||
// Vendors disagree on canonical OS/arch tokens used in download URLs, so we
|
||||
// keep our own normalized values ("linux"/"macos", "x86_64"/"aarch64") and
|
||||
// translate at URL-construction time.
|
||||
|
||||
var (
|
||||
osName string // "linux" or "macos"
|
||||
archName string // "x86_64" or "aarch64"
|
||||
isMacOS bool
|
||||
isRHELFamily bool
|
||||
isArchFamily bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
osName = detectOS()
|
||||
archName = detectArch()
|
||||
isMacOS = osName == "macos"
|
||||
}
|
||||
|
||||
func detectOS() string {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
return "linux"
|
||||
case "darwin":
|
||||
return "macos"
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unsupported OS: %s (supports Linux, Darwin)\n", runtime.GOOS)
|
||||
osExit(1)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func detectArch() string {
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
return "x86_64"
|
||||
case "arm64":
|
||||
return "aarch64"
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unsupported architecture: %s (supports x86_64, aarch64)\n", runtime.GOARCH)
|
||||
osExit(1)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
archGo = map[string]string{"x86_64": "amd64", "aarch64": "arm64"}
|
||||
archMinikube = map[string]string{"x86_64": "amd64", "aarch64": "arm64"}
|
||||
archDeb = map[string]string{"x86_64": "amd64", "aarch64": "arm64"}
|
||||
archNvim = map[string]string{"x86_64": "x86_64", "aarch64": "arm64"}
|
||||
archPulumi = map[string]string{"x86_64": "x64", "aarch64": "arm64"}
|
||||
|
||||
osGo = map[string]string{"linux": "linux", "macos": "darwin"}
|
||||
osZig = map[string]string{"linux": "linux", "macos": "macos"}
|
||||
osNvim = map[string]string{"linux": "linux", "macos": "macos"}
|
||||
|
||||
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"},
|
||||
}
|
||||
)
|
||||
|
||||
// formatURL interpolates {version}, {arch}, {arch_go}, {os}, {os_go},
|
||||
// {os_zig}, {os_nvim} into a download URL template.
|
||||
func formatURL(template, version string) string {
|
||||
r := strings.NewReplacer(
|
||||
"{version}", version,
|
||||
"{arch}", archName,
|
||||
"{arch_go}", archGo[archName],
|
||||
"{os}", osName,
|
||||
"{os_go}", osGo[osName],
|
||||
"{os_zig}", osZig[osName],
|
||||
"{os_nvim}", osNvim[osName],
|
||||
"{os_rustup}", osRustup[osName],
|
||||
"{arch_rustup}", archRustup[archName],
|
||||
"{os_trivy}", osTrivy[osName],
|
||||
"{arch_trivy}", archTrivy[archName],
|
||||
"{arch_gitleaks}", archGitleaks[archName],
|
||||
)
|
||||
return r.Replace(template)
|
||||
}
|
||||
|
||||
func otherArch() string {
|
||||
if archName == "x86_64" {
|
||||
return "aarch64"
|
||||
}
|
||||
return "x86_64"
|
||||
}
|
||||
|
||||
func archMatches(name, arch string) bool {
|
||||
n := strings.ToLower(name)
|
||||
for _, tok := range archTokens[arch] {
|
||||
if strings.Contains(n, tok) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasOtherArchToken(name string) bool {
|
||||
n := strings.ToLower(name)
|
||||
for _, tok := range archTokens[otherArch()] {
|
||||
if strings.Contains(n, tok) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// osReleaseField returns the value of /etc/os-release's NAME=value pair,
|
||||
// stripped of surrounding quotes. Returns "" if the file is missing or the
|
||||
// field is absent.
|
||||
func osReleaseField(field string) string {
|
||||
data, err := osReadFile(osReleasePath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
prefix := field + "="
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
return strings.Trim(strings.TrimPrefix(line, prefix), `"`)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func detectRHELFamily() bool {
|
||||
data, err := osReadFile(osReleasePath)
|
||||
if err != nil {
|
||||
return pkgMgr == "dnf"
|
||||
}
|
||||
tokens := []string{}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasPrefix(line, "ID=") || strings.HasPrefix(line, "ID_LIKE=") {
|
||||
_, val, _ := strings.Cut(line, "=")
|
||||
val = strings.Trim(val, `"`)
|
||||
tokens = append(tokens, strings.Fields(val)...)
|
||||
}
|
||||
}
|
||||
rhel := map[string]bool{"rhel": true, "fedora": true, "centos": true, "rocky": true, "almalinux": true}
|
||||
for _, t := range tokens {
|
||||
if rhel[t] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func detectArchFamily() bool {
|
||||
data, err := osReadFile(osReleasePath)
|
||||
if err != nil {
|
||||
return pkgMgr == "pacman"
|
||||
}
|
||||
tokens := []string{}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasPrefix(line, "ID=") || strings.HasPrefix(line, "ID_LIKE=") {
|
||||
_, val, _ := strings.Cut(line, "=")
|
||||
val = strings.Trim(val, `"`)
|
||||
tokens = append(tokens, strings.Fields(val)...)
|
||||
}
|
||||
}
|
||||
arch := map[string]bool{"arch": true, "manjaro": true, "endeavouros": true, "artix": true}
|
||||
for _, t := range tokens {
|
||||
if arch[t] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+135
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Default per-call cap for runCmd and runShell. Generous enough for heavy
|
||||
// installs (apt, brew, large downloads) but bounded so a stuck command can't
|
||||
// hang the bootstrap forever. Override per-call for genuinely longer
|
||||
// operations (e.g. pyenv compiles).
|
||||
const defaultSubprocessTimeout = 30 * time.Minute
|
||||
|
||||
// CmdOpts captures the optional knobs on runCmd / runShell.
|
||||
//
|
||||
// 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)
|
||||
Input []byte
|
||||
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.
|
||||
type CmdResult struct {
|
||||
ExitCode int
|
||||
Stdout []byte
|
||||
Stderr []byte
|
||||
Err error
|
||||
}
|
||||
|
||||
func (r CmdResult) OK() bool { return r.Err == nil && r.ExitCode == 0 }
|
||||
|
||||
// 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...)
|
||||
}
|
||||
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()
|
||||
|
||||
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||
if opts.Cwd != "" {
|
||||
cmd.Dir = opts.Cwd
|
||||
}
|
||||
if opts.Input != nil {
|
||||
cmd.Stdin = bytes.NewReader(opts.Input)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if opts.Capture {
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
} else {
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
}
|
||||
|
||||
err := cmd.Run()
|
||||
res := CmdResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
|
||||
if 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))
|
||||
res.ExitCode = 124
|
||||
res.Err = ctx.Err()
|
||||
return res
|
||||
}
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
res.ExitCode = exitErr.ExitCode()
|
||||
res.Err = err
|
||||
return res
|
||||
}
|
||||
warn(fmt.Sprintf("error launching %q: %v", argv[0], err))
|
||||
res.ExitCode = 1
|
||||
res.Err = err
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// runShellReal executes a single shell string via /bin/sh -c (matching the Python
|
||||
// version's subprocess.run(..., shell=True)).
|
||||
func runShellReal(cmd string, opts CmdOpts) CmdResult {
|
||||
if opts.Timeout == 0 {
|
||||
opts.Timeout = defaultSubprocessTimeout
|
||||
}
|
||||
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()
|
||||
|
||||
c := exec.CommandContext(ctx, "/bin/sh", "-c", cmd)
|
||||
if opts.Cwd != "" {
|
||||
c.Dir = opts.Cwd
|
||||
}
|
||||
if opts.Input != nil {
|
||||
c.Stdin = bytes.NewReader(opts.Input)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if opts.Capture {
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
} else {
|
||||
c.Stdout = os.Stdout
|
||||
c.Stderr = os.Stderr
|
||||
}
|
||||
|
||||
err := c.Run()
|
||||
res := CmdResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
|
||||
if 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))
|
||||
res.ExitCode = 124
|
||||
res.Err = ctx.Err()
|
||||
return res
|
||||
}
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
res.ExitCode = exitErr.ExitCode()
|
||||
res.Err = err
|
||||
return res
|
||||
}
|
||||
warn(fmt.Sprintf("OSError in shell command: %v", err))
|
||||
res.ExitCode = 1
|
||||
res.Err = err
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 probeReal(argv []string, timeout time.Duration) (CmdResult, bool) {
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
|
||||
res := CmdResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
warn(fmt.Sprintf("%q probe timed out", argv[0]))
|
||||
return res, false
|
||||
}
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
res.ExitCode = exitErr.ExitCode()
|
||||
return res, true
|
||||
}
|
||||
warn(fmt.Sprintf("%q probe failed: %v", argv[0], err))
|
||||
return res, false
|
||||
}
|
||||
return res, true
|
||||
}
|
||||
+151
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func installFlatpakPackages(toInstall []string) {
|
||||
fmt.Println("\n=== Flatpak Packages ===")
|
||||
|
||||
if !hasCmd("flatpak") {
|
||||
fmt.Println(" flatpak is not installed.")
|
||||
if !askYN(" Install flatpak now? [y/N] ") {
|
||||
warn("flatpak not installed — skipping Flatpak section")
|
||||
return
|
||||
}
|
||||
res := pkgInstall("flatpak")
|
||||
if !res.OK() || !hasCmd("flatpak") {
|
||||
errLog("flatpak installation failed — skipping Flatpak section")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
runCmd([]string{
|
||||
"flatpak", "remote-add", "--if-not-exists", "flathub",
|
||||
"https://dl.flathub.org/repo/flathub.flatpakrepo",
|
||||
}, CmdOpts{AsSudo: true})
|
||||
|
||||
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{})
|
||||
if !res.OK() {
|
||||
errLog(fmt.Sprintf("Flatpak failed to install: %s", pkgID))
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
"""Package definitions consumed by bootstrap_environment.py.
|
||||
|
||||
System and Flatpak packages are flat lists of names.
|
||||
|
||||
Custom packages declare a URL template plus an optional ``fetch_latest`` hint.
|
||||
At install time the bootstrap script will attempt to look up the most recent
|
||||
release and fall back to the pinned (version, sha256) tuple on failure.
|
||||
|
||||
URL templates use ``str.format`` with the following substitutions:
|
||||
{version} pkg.version (or the latest resolved version)
|
||||
{arch} "x86_64" or "aarch64"
|
||||
{arch_go} Go-style: "amd64" or "arm64"
|
||||
{os} "linux" or "macos"
|
||||
{os_go} Go-style: "linux" or "darwin"
|
||||
{os_zig} Zig-style: "linux" or "macos"
|
||||
{os_nvim} Neovim-style: "linux" or "macos"
|
||||
"""
|
||||
|
||||
SYSTEM_PACKAGES: list[str] = [
|
||||
"ansible",
|
||||
"ansible-core",
|
||||
"aria2",
|
||||
"bashtop",
|
||||
"build-essential",
|
||||
"buildah",
|
||||
"containerd.io",
|
||||
"docker-buildx-plugin",
|
||||
"docker-ce-cli",
|
||||
"docker-ce-rootless-extras",
|
||||
"docker-ce",
|
||||
"docker-compose-plugin",
|
||||
"dotnet-sdk-10.0",
|
||||
"ffmpeg-free",
|
||||
"gcc",
|
||||
"gh",
|
||||
"git",
|
||||
"github-desktop",
|
||||
"google-chrome-stable",
|
||||
"lua",
|
||||
"minisign",
|
||||
"minikube",
|
||||
"obs-studio",
|
||||
"obsidian",
|
||||
"pipx",
|
||||
"poetry",
|
||||
"podman",
|
||||
"qemu",
|
||||
"restic",
|
||||
"rg",
|
||||
"shutter",
|
||||
"temurin-25-jdk",
|
||||
"vagrant",
|
||||
"virt-manager",
|
||||
"vivaldi-stable",
|
||||
"webcamoid",
|
||||
"wireshark",
|
||||
"yt-dlp",
|
||||
"zoom",
|
||||
"zsh",
|
||||
"bzip2",
|
||||
"bzip2-devel",
|
||||
"gdbm-libs",
|
||||
"libffi-devel",
|
||||
"libnsl2",
|
||||
"libuuid-devel",
|
||||
"libzstd-devel",
|
||||
"make",
|
||||
"openssl-devel",
|
||||
"patch",
|
||||
"readline-devel",
|
||||
"sqlite",
|
||||
"sqlite-devel",
|
||||
"tk-devel",
|
||||
"xz-devel",
|
||||
"zlib-devel",
|
||||
]
|
||||
|
||||
FLATPAK_PACKAGES: list[str] = [
|
||||
"com.obsproject.Studio",
|
||||
"fr.handbrake.ghb",
|
||||
"io.github.webcamoid.Webcamoid",
|
||||
"one.ablaze.floorp",
|
||||
"com.vivaldi.Vivaldi",
|
||||
"org.darktable.Darktable",
|
||||
]
|
||||
|
||||
CUSTOM_PACKAGES: list[dict] = [
|
||||
{
|
||||
"name": "go",
|
||||
"version": "1.26.3",
|
||||
"url_template": "https://go.dev/dl/go{version}.{os_go}-{arch_go}.tar.gz",
|
||||
"sha256": "2b2cfc7148493da5e73981bffbf3353af381d5f93e789c82c79aff64962eb556",
|
||||
"fetch_latest": "go",
|
||||
},
|
||||
{"name": "neovim"},
|
||||
{
|
||||
"name": "firecracker",
|
||||
"version": "1.15.1",
|
||||
"url_template": (
|
||||
"https://github.com/firecracker-microvm/firecracker/releases/download/"
|
||||
"v{version}/firecracker-v{version}-{arch}.tgz"
|
||||
),
|
||||
"sha256": "d4a32ab2322d887ca1bc4a4e7afa9cc35393e6362dfc2b3becb389d362e4275a",
|
||||
"fetch_latest": "firecracker",
|
||||
},
|
||||
{
|
||||
"name": "zig",
|
||||
"version": "0.16.0",
|
||||
"url_template": "https://ziglang.org/download/{version}/zig-{arch}-{os_zig}-{version}.tar.xz",
|
||||
"sha256_url_template": (
|
||||
"https://ziglang.org/download/{version}/zig-{arch}-{os_zig}-{version}.tar.xz.minisig"
|
||||
),
|
||||
"minisign_key": "RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U",
|
||||
"fetch_latest": "zig",
|
||||
},
|
||||
{"name": "nvm"},
|
||||
{"name": "pyenv"},
|
||||
{"name": "pip"},
|
||||
{"name": "oh-my-zsh"},
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
module github.com/JMR-dev/bootstrap_dev_env
|
||||
|
||||
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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -0,0 +1,48 @@
|
||||
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
|
||||
// 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")
|
||||
}
|
||||
|
||||
// Reset global state variables to safe defaults
|
||||
isMacOS = false
|
||||
pkgMgr = "dnf"
|
||||
isRHELFamily = true
|
||||
isArchFamily = false
|
||||
osName = "linux"
|
||||
archName = "x86_64"
|
||||
|
||||
osReleasePath = "/etc/os-release"
|
||||
passwdPath = "/etc/passwd"
|
||||
|
||||
issuesMu.Lock()
|
||||
issues = nil
|
||||
notices = nil
|
||||
errorCount = 0
|
||||
issuesMu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Issue log accumulated over the run; written to bootstrap_run.log at the end
|
||||
// when there's something to report.
|
||||
|
||||
var (
|
||||
issuesMu sync.Mutex
|
||||
issues []string
|
||||
notices []string
|
||||
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()
|
||||
// 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()
|
||||
notices = append(notices, msg)
|
||||
}
|
||||
|
||||
func runLogPath() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "bootstrap_run.log"
|
||||
}
|
||||
return filepath.Join(filepath.Dir(exe), "bootstrap_run.log")
|
||||
}
|
||||
|
||||
func writeRunLog() {
|
||||
issuesMu.Lock()
|
||||
defer issuesMu.Unlock()
|
||||
if len(issues) == 0 {
|
||||
fmt.Println("\nNo issues — log file not written.")
|
||||
return
|
||||
}
|
||||
path := runLogPath()
|
||||
ts := time.Now().Format("2006-01-02 15:04:05")
|
||||
lines := []string{fmt.Sprintf("# Bootstrap run — %s", ts), ""}
|
||||
lines = append(lines, issues...)
|
||||
content := strings.Join(lines, "\n") + "\n"
|
||||
if err := osWriteFile(path, []byte(content), 0o644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to write run log: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("\n%d issue(s) logged to: %s\n", len(issues), path)
|
||||
}
|
||||
|
||||
func printNotices() {
|
||||
issuesMu.Lock()
|
||||
defer issuesMu.Unlock()
|
||||
if len(notices) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Println("\nNotices:")
|
||||
for _, n := range notices {
|
||||
fmt.Printf(" • %s\n", n)
|
||||
}
|
||||
}
|
||||
+134
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── Xcode + Homebrew prereqs ────────────────────────────────────────────
|
||||
|
||||
func brewPrefix() string {
|
||||
if archName == "aarch64" {
|
||||
return "/opt/homebrew"
|
||||
}
|
||||
return "/usr/local"
|
||||
}
|
||||
|
||||
func ensureXcodeCLT() {
|
||||
if !isMacOS {
|
||||
return
|
||||
}
|
||||
res, ok := probe([]string{"xcode-select", "-p"}, 10*time.Second)
|
||||
if ok && res.ExitCode == 0 {
|
||||
fmt.Printf("[Xcode CLT] Already installed at %s\n", strings.TrimSpace(string(res.Stdout)))
|
||||
return
|
||||
}
|
||||
fmt.Println("[Xcode CLT] Installing Xcode Command Line Tools ...")
|
||||
fmt.Println(" A GUI dialog will appear — click 'Install' to proceed.")
|
||||
runCmd([]string{"xcode-select", "--install"}, CmdOpts{Timeout: 30 * time.Second})
|
||||
fmt.Println(" Waiting for installation to complete ...")
|
||||
for {
|
||||
r, ok := probe([]string{"xcode-select", "-p"}, 10*time.Second)
|
||||
if ok && r.ExitCode == 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
fmt.Println("[Xcode CLT] Installation complete.")
|
||||
}
|
||||
|
||||
func ensureHomebrew() {
|
||||
if !isMacOS {
|
||||
return
|
||||
}
|
||||
if hasCmd("brew") {
|
||||
path, _ := exec.LookPath("brew")
|
||||
fmt.Printf("[Homebrew] Already installed at %s\n", path)
|
||||
return
|
||||
}
|
||||
fmt.Println("[Homebrew] Installing Homebrew ...")
|
||||
installer := `NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"`
|
||||
if !runShell(installer, CmdOpts{}).OK() {
|
||||
fmt.Fprintln(os.Stderr, "Homebrew installation failed")
|
||||
osExit(1)
|
||||
}
|
||||
|
||||
brewBinDir := filepath.Join(brewPrefix(), "bin")
|
||||
brewPath := filepath.Join(brewBinDir, "brew")
|
||||
if _, err := osStat(brewPath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Homebrew installed but brew not found at %s\n", brewPath)
|
||||
osExit(1)
|
||||
}
|
||||
|
||||
os.Setenv("PATH", brewBinDir+":"+os.Getenv("PATH"))
|
||||
|
||||
shellenvLine := fmt.Sprintf(`eval "$(%s shellenv)"`, brewPath)
|
||||
runCmd([]string{
|
||||
"bash", "-c",
|
||||
fmt.Sprintf("grep -qxF %q /etc/zprofile 2>/dev/null || echo %q >> /etc/zprofile",
|
||||
shellenvLine, shellenvLine),
|
||||
}, CmdOpts{AsSudo: true})
|
||||
fmt.Printf("[Homebrew] Installed at %s; added shellenv to /etc/zprofile\n", brewPrefix())
|
||||
}
|
||||
|
||||
// ── macOS firecracker VM bridge ─────────────────────────────────────────
|
||||
//
|
||||
// Firecracker is Linux-only (needs KVM). On macOS we provision a Fedora cloud
|
||||
// VM via QEMU/HVF, install firecracker inside it, and expose a `firecracker`
|
||||
// zsh function on the host that proxies invocations over SSH.
|
||||
|
||||
const (
|
||||
vmSSHPort = 2222
|
||||
vmUser = "fc"
|
||||
vmQcow2Name = "fedora.qcow2"
|
||||
vmSeedISOName = "seed.iso"
|
||||
vmPIDName = "vm.pid"
|
||||
vmKeyName = "id_ed25519"
|
||||
firecrackerFnBeg = "# >>> firecracker-vm wrapper >>>"
|
||||
firecrackerFnEnd = "# <<< firecracker-vm wrapper <<<"
|
||||
)
|
||||
|
||||
func vmDir() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".firecracker-vm")
|
||||
}
|
||||
|
||||
func macosMajor() int {
|
||||
if !isMacOS {
|
||||
return 0
|
||||
}
|
||||
r, ok := probe([]string{"sw_vers", "-productVersion"}, 10*time.Second)
|
||||
if !ok || r.ExitCode != 0 {
|
||||
return 0
|
||||
}
|
||||
v := strings.TrimSpace(string(r.Stdout))
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
parts := strings.Split(v, ".")
|
||||
n, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func appleSiliconGeneration() int {
|
||||
if !isMacOS || archName != "aarch64" {
|
||||
return 0
|
||||
}
|
||||
r, ok := probe([]string{"sysctl", "-n", "machdep.cpu.brand_string"}, 10*time.Second)
|
||||
if !ok || r.ExitCode != 0 {
|
||||
return 0
|
||||
}
|
||||
brand := strings.TrimSpace(string(r.Stdout))
|
||||
idx := strings.Index(brand, "Apple M")
|
||||
if idx < 0 {
|
||||
return 0
|
||||
}
|
||||
rest := brand[idx+len("Apple M"):]
|
||||
var digits []byte
|
||||
for i := 0; i < len(rest) && rest[i] >= '0' && rest[i] <= '9'; i++ {
|
||||
digits = append(digits, rest[i])
|
||||
}
|
||||
if len(digits) == 0 {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(string(digits))
|
||||
return n
|
||||
}
|
||||
|
||||
func selectVMBackend() string {
|
||||
if !isMacOS {
|
||||
return ""
|
||||
}
|
||||
if archName == "x86_64" {
|
||||
fmt.Println("\n[firecracker VM] Intel Mac — using VirtualBox (supports nested VT-x for in-guest KVM).")
|
||||
return "virtualbox"
|
||||
}
|
||||
gen := appleSiliconGeneration()
|
||||
macos := macosMajor()
|
||||
if gen >= 3 && macos >= 15 {
|
||||
fmt.Printf("\n[firecracker VM] Apple Silicon M%d on macOS %d — using QEMU/HVF with nested virtualization (-cpu host,el2=on).\n", gen, macos)
|
||||
return "qemu"
|
||||
}
|
||||
chip := "Apple Silicon"
|
||||
if gen != 0 {
|
||||
chip = fmt.Sprintf("Apple M%d", gen)
|
||||
}
|
||||
osStr := "this macOS"
|
||||
if macos != 0 {
|
||||
osStr = fmt.Sprintf("macOS %d", macos)
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println("[firecracker VM] Skipping firecracker VM provisioning.")
|
||||
fmt.Printf(" Detected %s on %s. HVF only exposes nested\n", chip, osStr)
|
||||
fmt.Println(" virtualization on M3+ chips running macOS 15 Sequoia or later,")
|
||||
fmt.Println(" and VirtualBox does not support Apple Silicon hosts, so there")
|
||||
fmt.Println(" is no local hypervisor that can run firecracker microVMs here.")
|
||||
fmt.Println(" To use firecracker, provision a Linux cloud VM (e.g. AWS EC2,")
|
||||
fmt.Println(" GCP) and run firecracker there over SSH.")
|
||||
return ""
|
||||
}
|
||||
|
||||
func latestFedoraCloudImage() (filename, qcowURL, checksumURL string, ok bool) {
|
||||
base := "https://dl.fedoraproject.org/pub/fedora/linux/releases/"
|
||||
listing := fetchText(base)
|
||||
if listing == "" {
|
||||
return
|
||||
}
|
||||
verRe := regexp.MustCompile(`href="(\d+)/?"`)
|
||||
seen := map[int]bool{}
|
||||
var versions []int
|
||||
for _, m := range verRe.FindAllStringSubmatch(listing, -1) {
|
||||
v, err := strconv.Atoi(m[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !seen[v] {
|
||||
seen[v] = true
|
||||
versions = append(versions, v)
|
||||
}
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(versions)))
|
||||
for _, ver := range versions {
|
||||
imagesURL := fmt.Sprintf("%s%d/Cloud/%s/images/", base, ver, archName)
|
||||
idx := fetchText(imagesURL)
|
||||
if idx == "" {
|
||||
continue
|
||||
}
|
||||
qcowRe := regexp.MustCompile(fmt.Sprintf(`href="(Fedora-Cloud-Base[A-Za-z0-9_-]*-%d-[\d.]+\.%s\.qcow2)"`, ver, archName))
|
||||
ckRe := regexp.MustCompile(`href="([^"]*CHECKSUM)"`)
|
||||
qm := qcowRe.FindStringSubmatch(idx)
|
||||
cm := ckRe.FindStringSubmatch(idx)
|
||||
if qm == nil || cm == nil {
|
||||
continue
|
||||
}
|
||||
return qm[1], imagesURL + qm[1], imagesURL + cm[1], true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func verifyFedoraQcow2(qcow2, checksumURL string) bool {
|
||||
text := fetchText(checksumURL)
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
name := filepath.Base(qcow2)
|
||||
re := regexp.MustCompile(fmt.Sprintf(`SHA256 \(%s\) = ([0-9a-fA-F]+)`, regexp.QuoteMeta(name)))
|
||||
m := re.FindStringSubmatch(text)
|
||||
if m == nil {
|
||||
errLog(fmt.Sprintf("No SHA256 entry for %s in checksum file", name))
|
||||
return false
|
||||
}
|
||||
expected := strings.ToLower(m[1])
|
||||
fmt.Println(" Verifying SHA256 (this can take a minute) ...")
|
||||
actual, err := sha256Of(qcow2)
|
||||
if err != nil {
|
||||
errLog(fmt.Sprintf("Fedora image hash failed: %v", err))
|
||||
return false
|
||||
}
|
||||
if strings.ToLower(actual) != expected {
|
||||
errLog(fmt.Sprintf("Fedora image SHA256 mismatch (got %s, expected %s)", actual, expected))
|
||||
return false
|
||||
}
|
||||
fmt.Println(" SHA256 OK")
|
||||
return true
|
||||
}
|
||||
|
||||
func downloadFedoraImage(qcowURL, dest string) bool {
|
||||
if !hasCmd("curl") {
|
||||
return download(qcowURL, dest)
|
||||
}
|
||||
fmt.Printf(" Downloading %s ...\n", filepath.Base(dest))
|
||||
return runCmd([]string{"curl", "-L", "--fail", "-#", "-o", dest, qcowURL}, CmdOpts{}).OK()
|
||||
}
|
||||
|
||||
const firecrackerUserdataTmpl = `#cloud-config
|
||||
hostname: firecracker-vm
|
||||
users:
|
||||
- name: %s
|
||||
sudo: ALL=(ALL) NOPASSWD:ALL
|
||||
shell: /bin/bash
|
||||
ssh_authorized_keys:
|
||||
- %s
|
||||
ssh_pwauth: false
|
||||
packages:
|
||||
- curl
|
||||
- tar
|
||||
- qemu-kvm
|
||||
write_files:
|
||||
- path: /usr/local/sbin/install-firecracker.sh
|
||||
permissions: '0755'
|
||||
content: |
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ARCH=$(uname -m)
|
||||
TAG=$(curl -fsSL https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest \
|
||||
| grep -oE '"tag_name":[[:space:]]*"v[^"]+"' | head -1 \
|
||||
| sed -E 's/.*"v([^"]+)"/\1/')
|
||||
cd /tmp
|
||||
curl -fsSL -o fc.tgz \
|
||||
"https://github.com/firecracker-microvm/firecracker/releases/download/v${TAG}/firecracker-v${TAG}-${ARCH}.tgz"
|
||||
tar -xzf fc.tgz
|
||||
BIN=$(find . -maxdepth 3 -type f -name "firecracker-v${TAG}-${ARCH}" ! -name '*.debug' | head -1)
|
||||
install -m 0755 "$BIN" /usr/local/bin/firecracker
|
||||
touch /var/lib/firecracker-ready
|
||||
runcmd:
|
||||
- /usr/local/sbin/install-firecracker.sh
|
||||
`
|
||||
|
||||
func writeCloudInitSeed(seedDir, pubkey string) error {
|
||||
if err := osMkdirAll(seedDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
userData := fmt.Sprintf(firecrackerUserdataTmpl, vmUser, strings.TrimSpace(pubkey))
|
||||
if err := osWriteFile(filepath.Join(seedDir, "user-data"), []byte(userData), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return osWriteFile(filepath.Join(seedDir, "meta-data"),
|
||||
[]byte("instance-id: firecracker-vm\nlocal-hostname: firecracker-vm\n"), 0o644)
|
||||
}
|
||||
|
||||
func buildSeedISO(seedDir, isoPath string) bool {
|
||||
osRemove(isoPath)
|
||||
return runCmd([]string{
|
||||
"hdiutil", "makehybrid", "-iso", "-joliet",
|
||||
"-default-volume-name", "cidata",
|
||||
"-o", isoPath, seedDir,
|
||||
}, CmdOpts{}).OK()
|
||||
}
|
||||
|
||||
func writeQEMUStartScript() string {
|
||||
dir := vmDir()
|
||||
brewShare := filepath.Join(brewPrefix(), "share", "qemu")
|
||||
scriptPath := filepath.Join(dir, "vm-start.sh")
|
||||
edkCode := filepath.Join(brewShare, "edk2-aarch64-code.fd")
|
||||
edkVarsTemplate := filepath.Join(brewShare, "edk2-arm-vars.fd")
|
||||
|
||||
qemuBlock := fmt.Sprintf(`# Ensure a writable NVRAM file exists (UEFI vars persist here).
|
||||
if [[ ! -f edk2-aarch64-vars.fd ]]; then
|
||||
if [[ -f "%s" ]]; then
|
||||
cp "%s" edk2-aarch64-vars.fd
|
||||
else
|
||||
truncate -s 64M edk2-aarch64-vars.fd
|
||||
fi
|
||||
fi
|
||||
|
||||
exec qemu-system-aarch64 \
|
||||
-machine virt,accel=hvf,highmem=on \
|
||||
-cpu host,el2=on \
|
||||
-smp 2 -m 2048 \
|
||||
-drive if=pflash,format=raw,readonly=on,file="%s" \
|
||||
-drive if=pflash,format=raw,file=edk2-aarch64-vars.fd \
|
||||
-drive file=%s,if=virtio,format=qcow2 \
|
||||
-drive file=%s,format=raw,if=virtio,readonly=on \
|
||||
-display none -serial file:vm.log \
|
||||
-netdev user,id=net0,hostfwd=tcp::%d-:22 \
|
||||
-device virtio-net-device,netdev=net0 \
|
||||
-daemonize -pidfile %s
|
||||
`, edkVarsTemplate, edkVarsTemplate, edkCode, vmQcow2Name, vmSeedISOName, vmSSHPort, vmPIDName)
|
||||
|
||||
script := fmt.Sprintf(`#!/usr/bin/env bash
|
||||
# Start the Fedora-on-QEMU VM that backs the host firecracker zsh function.
|
||||
set -euo pipefail
|
||||
cd "%s"
|
||||
if [[ -f %s ]] && kill -0 "$(cat %s)" 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
rm -f %s
|
||||
%s`, dir, vmPIDName, vmPIDName, vmPIDName, qemuBlock)
|
||||
|
||||
osWriteFile(scriptPath, []byte(script), 0o755)
|
||||
return scriptPath
|
||||
}
|
||||
|
||||
func sshToVM(privKey string, remote []string, timeout time.Duration) CmdResult {
|
||||
if timeout == 0 {
|
||||
timeout = 3 * time.Second
|
||||
}
|
||||
args := []string{
|
||||
"-q",
|
||||
"-i", privKey,
|
||||
"-p", strconv.Itoa(vmSSHPort),
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", fmt.Sprintf("ConnectTimeout=%d", int(timeout.Seconds())),
|
||||
"-o", "LogLevel=ERROR",
|
||||
fmt.Sprintf("%s@127.0.0.1", vmUser),
|
||||
}
|
||||
args = append(args, remote...)
|
||||
r, ok := probe(append([]string{"ssh"}, args...), timeout+30*time.Second)
|
||||
if !ok {
|
||||
return CmdResult{ExitCode: 124}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func waitForVMSSH(privKey string, timeout time.Duration) bool {
|
||||
fmt.Printf(" Waiting for VM SSH on port %d (up to %s) ...\n", vmSSHPort, timeout)
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if sshToVM(privKey, []string{"true"}, 0).OK() {
|
||||
fmt.Println(" VM SSH ready.")
|
||||
return true
|
||||
}
|
||||
rem := time.Until(deadline)
|
||||
if rem <= 0 {
|
||||
break
|
||||
}
|
||||
if rem > 5*time.Second {
|
||||
rem = 5 * time.Second
|
||||
}
|
||||
time.Sleep(rem)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func waitForFirecrackerInVM(privKey string, timeout time.Duration) bool {
|
||||
fmt.Printf(" Waiting for cloud-init to install firecracker inside the VM (up to %s) ...\n", timeout)
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if sshToVM(privKey, []string{"test", "-f", "/var/lib/firecracker-ready"}, 0).OK() {
|
||||
fmt.Println(" firecracker is installed inside the VM.")
|
||||
return true
|
||||
}
|
||||
rem := time.Until(deadline)
|
||||
if rem <= 0 {
|
||||
break
|
||||
}
|
||||
if rem > 10*time.Second {
|
||||
rem = 10 * time.Second
|
||||
}
|
||||
time.Sleep(rem)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func firecrackerZshFunction(privKey string) string {
|
||||
return fmt.Sprintf(`%s
|
||||
firecracker() {
|
||||
local vm_dir="%s"
|
||||
if [[ ! -f "$vm_dir/%s" ]]; then
|
||||
echo "firecracker: Fedora VM not provisioned (expected $vm_dir/%s)." >&2
|
||||
return 1
|
||||
fi
|
||||
if ! "$vm_dir/vm-start.sh"; then
|
||||
echo "firecracker: failed to start backing VM (see $vm_dir/vm.log)." >&2
|
||||
return 1
|
||||
fi
|
||||
local i
|
||||
for i in $(seq 1 60); do
|
||||
ssh -q -i "%s" -p %d \
|
||||
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
-o ConnectTimeout=2 -o LogLevel=ERROR \
|
||||
%s@127.0.0.1 true && break
|
||||
sleep 1
|
||||
done
|
||||
local args=() a
|
||||
for a in "$@"; do args+=("$(printf %%q "$a")"); done
|
||||
ssh -t -q -i "%s" -p %d \
|
||||
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
-o LogLevel=ERROR \
|
||||
%s@127.0.0.1 "sudo /usr/local/bin/firecracker ${args[*]}"
|
||||
}
|
||||
%s
|
||||
`,
|
||||
firecrackerFnBeg,
|
||||
vmDir(),
|
||||
vmQcow2Name, vmQcow2Name,
|
||||
privKey, vmSSHPort, vmUser,
|
||||
privKey, vmSSHPort, vmUser,
|
||||
firecrackerFnEnd,
|
||||
)
|
||||
}
|
||||
|
||||
func installFirecrackerZshFunction(content string) {
|
||||
home, _ := os.UserHomeDir()
|
||||
zshrc := filepath.Join(home, ".zshrc")
|
||||
existing := ""
|
||||
if b, err := osReadFile(zshrc); err == nil {
|
||||
existing = string(b)
|
||||
}
|
||||
pattern := regexp.MustCompile(`(?s)` + regexp.QuoteMeta(firecrackerFnBeg) + `.*?` + regexp.QuoteMeta(firecrackerFnEnd) + `\n?`)
|
||||
var newContent string
|
||||
if pattern.MatchString(existing) {
|
||||
newContent = pattern.ReplaceAllString(existing, content)
|
||||
} else {
|
||||
if existing != "" {
|
||||
newContent = strings.TrimRight(existing, "\n") + "\n\n" + content
|
||||
} else {
|
||||
newContent = content
|
||||
}
|
||||
}
|
||||
osWriteFile(zshrc, []byte(newContent), 0o644)
|
||||
fmt.Printf(" Wrote firecracker() function block to %s\n", zshrc)
|
||||
}
|
||||
|
||||
func ensureVirtualBox() bool {
|
||||
if hasCmd("VBoxManage") {
|
||||
return true
|
||||
}
|
||||
fmt.Println(" Installing VirtualBox via brew cask ...")
|
||||
if !runCmd([]string{"brew", "install", "--cask", "virtualbox"}, CmdOpts{}).OK() {
|
||||
errLog("VirtualBox cask install failed. macOS may require kernel-extension " +
|
||||
"approval in System Settings → Privacy & Security; once approved, re-run this script.")
|
||||
return false
|
||||
}
|
||||
if !hasCmd("VBoxManage") {
|
||||
errLog("VirtualBox installed but VBoxManage not in PATH. macOS may need a reboot or kext approval.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func provisionVirtualBoxVM(qcow2, seedISO string) string {
|
||||
if !ensureVirtualBox() {
|
||||
return ""
|
||||
}
|
||||
vmName := "firecracker-vm"
|
||||
dir := vmDir()
|
||||
vboxBase := filepath.Join(dir, "vbox")
|
||||
vdi := filepath.Join(dir, "fedora.vdi")
|
||||
|
||||
r, ok := probe([]string{"VBoxManage", "showvminfo", vmName}, 30*time.Second)
|
||||
exists := ok && r.ExitCode == 0
|
||||
|
||||
if !exists {
|
||||
if _, err := 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")
|
||||
return ""
|
||||
}
|
||||
runCmd([]string{"VBoxManage", "modifymedium", "disk", vdi, "--resize", "10240"}, CmdOpts{})
|
||||
}
|
||||
fmt.Printf(" Creating VirtualBox VM '%s' ...\n", vmName)
|
||||
osMkdirAll(vboxBase, 0o755)
|
||||
if !runCmd([]string{
|
||||
"VBoxManage", "createvm",
|
||||
"--name", vmName,
|
||||
"--ostype", "Fedora_64",
|
||||
"--basefolder", vboxBase,
|
||||
"--register",
|
||||
}, CmdOpts{}).OK() {
|
||||
errLog("VBoxManage createvm failed")
|
||||
return ""
|
||||
}
|
||||
runCmd([]string{
|
||||
"VBoxManage", "modifyvm", vmName,
|
||||
"--cpus", "2",
|
||||
"--memory", "2048",
|
||||
"--nested-hw-virt", "on",
|
||||
"--nic1", "nat",
|
||||
"--natpf1", fmt.Sprintf("ssh,tcp,,%d,,22", vmSSHPort),
|
||||
}, CmdOpts{})
|
||||
runCmd([]string{"VBoxManage", "storagectl", vmName, "--name", "SATA", "--add", "sata"}, CmdOpts{})
|
||||
runCmd([]string{
|
||||
"VBoxManage", "storageattach", vmName,
|
||||
"--storagectl", "SATA",
|
||||
"--port", "0", "--device", "0", "--type", "hdd",
|
||||
"--medium", vdi,
|
||||
}, CmdOpts{})
|
||||
runCmd([]string{"VBoxManage", "storagectl", vmName, "--name", "IDE", "--add", "ide"}, CmdOpts{})
|
||||
runCmd([]string{
|
||||
"VBoxManage", "storageattach", vmName,
|
||||
"--storagectl", "IDE",
|
||||
"--port", "0", "--device", "0", "--type", "dvddrive",
|
||||
"--medium", seedISO,
|
||||
}, CmdOpts{})
|
||||
} else {
|
||||
fmt.Printf(" VirtualBox VM '%s' already registered — reusing.\n", vmName)
|
||||
}
|
||||
|
||||
scriptPath := filepath.Join(dir, "vm-start.sh")
|
||||
script := fmt.Sprintf(`#!/usr/bin/env bash
|
||||
# Start the VirtualBox-backed Fedora VM that powers the host firecracker() fn.
|
||||
set -euo pipefail
|
||||
if VBoxManage list runningvms | grep -q '"%s"'; then
|
||||
exit 0
|
||||
fi
|
||||
exec VBoxManage startvm %s --type headless
|
||||
`, vmName, vmName)
|
||||
osWriteFile(scriptPath, []byte(script), 0o755)
|
||||
return scriptPath
|
||||
}
|
||||
|
||||
func setupFirecrackerVM() {
|
||||
if !isMacOS {
|
||||
return
|
||||
}
|
||||
backend := selectVMBackend()
|
||||
if backend == "" {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("\n=== macOS firecracker VM (Fedora) ===")
|
||||
dir := vmDir()
|
||||
osMkdirAll(dir, 0o755)
|
||||
|
||||
privKey := filepath.Join(dir, vmKeyName)
|
||||
pubKey := privKey + ".pub"
|
||||
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")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
qcow2 := filepath.Join(dir, vmQcow2Name)
|
||||
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 ...")
|
||||
filename, qcowURL, checksumURL, ok := latestFedoraCloudImage()
|
||||
if !ok {
|
||||
errLog("Could not resolve latest Fedora cloud image — aborting VM setup")
|
||||
return
|
||||
}
|
||||
fmt.Printf(" Latest: %s\n", filename)
|
||||
downloadDest := filepath.Join(dir, filename)
|
||||
if !downloadFedoraImage(qcowURL, downloadDest) {
|
||||
errLog("Fedora image download failed — aborting VM setup")
|
||||
return
|
||||
}
|
||||
if !verifyFedoraQcow2(downloadDest, checksumURL) {
|
||||
osRemove(downloadDest)
|
||||
return
|
||||
}
|
||||
osRename(downloadDest, qcow2)
|
||||
if hasCmd("qemu-img") {
|
||||
fmt.Println(" Resizing image to 10G ...")
|
||||
runCmd([]string{"qemu-img", "resize", qcow2, "10G"}, CmdOpts{})
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println(" Building cloud-init seed ISO ...")
|
||||
seedDir := filepath.Join(dir, "seed")
|
||||
pubKeyBytes, err := osReadFile(pubKey)
|
||||
if err != nil {
|
||||
errLog(fmt.Sprintf("could not read public key: %v", err))
|
||||
return
|
||||
}
|
||||
if err := writeCloudInitSeed(seedDir, string(pubKeyBytes)); err != nil {
|
||||
errLog(fmt.Sprintf("cloud-init seed write failed: %v", err))
|
||||
return
|
||||
}
|
||||
seedISO := filepath.Join(dir, vmSeedISOName)
|
||||
if !buildSeedISO(seedDir, seedISO) {
|
||||
errLog("hdiutil failed to build seed ISO — aborting VM setup")
|
||||
return
|
||||
}
|
||||
|
||||
var startScript string
|
||||
if backend == "qemu" {
|
||||
if !hasCmd("qemu-system-aarch64") {
|
||||
errLog("qemu-system-aarch64 not found — install qemu via brew first.")
|
||||
return
|
||||
}
|
||||
fmt.Println(" Writing QEMU start script ...")
|
||||
startScript = writeQEMUStartScript()
|
||||
} else {
|
||||
startScript = provisionVirtualBoxVM(qcow2, seedISO)
|
||||
if startScript == "" {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf(" Booting VM via %s ...\n", startScript)
|
||||
if !runCmd([]string{startScript}, CmdOpts{}).OK() {
|
||||
errLog(fmt.Sprintf("VM start failed — see %s", filepath.Join(dir, "vm.log")))
|
||||
return
|
||||
}
|
||||
|
||||
if !waitForVMSSH(privKey, 5*time.Minute) {
|
||||
errLog(fmt.Sprintf("VM SSH never came up — see %s", filepath.Join(dir, "vm.log")))
|
||||
return
|
||||
}
|
||||
|
||||
if !waitForFirecrackerInVM(privKey, 15*time.Minute) {
|
||||
warn("firecracker did not appear in the VM within the timeout; " +
|
||||
"cloud-init may still be running. Check `sudo cloud-init status` " +
|
||||
"inside the VM (ssh -i ~/.firecracker-vm/id_ed25519 -p 2222 fc@127.0.0.1).")
|
||||
}
|
||||
|
||||
fmt.Println(" Installing firecracker() wrapper into ~/.zshrc ...")
|
||||
installFirecrackerZshFunction(firecrackerZshFunction(privKey))
|
||||
|
||||
fmt.Printf(" firecracker VM ready (backend: %s).\n", backend)
|
||||
fmt.Printf(" Start manually with: %s\n", startScript)
|
||||
if backend == "qemu" {
|
||||
fmt.Println(" Nested virt is on (el2=on); the guest's KVM can launch firecracker microVMs.")
|
||||
} else {
|
||||
fmt.Println(" Nested VT-x is on; the guest's KVM can launch firecracker microVMs.")
|
||||
}
|
||||
}
|
||||
+707
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// bootstrap_environment ports the Python bootstrap script to Go.
|
||||
//
|
||||
// Sections handled:
|
||||
//
|
||||
// System Packages — installed via dnf, apt-get, pacman, or brew (macOS)
|
||||
// Flatpak Packages — installed via flatpak from Flathub (Linux only;
|
||||
// skipped by default and skipped entirely on macOS; use --gui)
|
||||
// Custom Packages — downloaded, verified, extracted
|
||||
// macOS firecracker VM — provisions a Fedora cloud image under a hypervisor
|
||||
// that supports nested virtualization. Suppress with --no-vm.
|
||||
//
|
||||
// OS detection is automatic. On macOS the first actions are to install the
|
||||
// Xcode Command Line Tools and Homebrew, which is then used as the system
|
||||
// package manager.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// Linux: sudo bootstrap_environment [--only system|flatpak|custom] [--gui]
|
||||
// macOS: bootstrap_environment [--only system|custom] [--gui] [--no-vm]
|
||||
// (do NOT use sudo on macOS — Homebrew refuses to run as root)
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func main() {
|
||||
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).")
|
||||
_ = 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)
|
||||
osExit(2)
|
||||
return
|
||||
}
|
||||
|
||||
initPkgMgr()
|
||||
|
||||
systemPkgs := append([]string(nil), SystemPackages...)
|
||||
flatpakPkgs := append([]string(nil), FlatpakPackages...)
|
||||
custom := customPackages()
|
||||
customPtrs := make([]*CustomPackage, 0, len(custom))
|
||||
for i := range custom {
|
||||
// Drop firecracker on macOS — it's provisioned inside the Fedora VM
|
||||
// (see setupFirecrackerVM), not on the host.
|
||||
if isMacOS && strings.ToLower(custom[i].Name) == "firecracker" {
|
||||
continue
|
||||
}
|
||||
if *noAI {
|
||||
name := strings.ToLower(custom[i].Name)
|
||||
if name == "agy" || name == "claude" || name == "codex" || name == "copilot" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
customPtrs = append(customPtrs, &custom[i])
|
||||
}
|
||||
|
||||
fmt.Printf("OS: %s\n", osName)
|
||||
fmt.Printf("Architecture: %s\n", archName)
|
||||
fmt.Printf("Package manager: %s\n", pkgMgr)
|
||||
if !*gui {
|
||||
fmt.Println("Mode: headless (default) — skipping GUI apps and Flatpak")
|
||||
}
|
||||
|
||||
if isMacOS {
|
||||
// Refuse to run as root before doing anything (brew won't run as root).
|
||||
checkSudo()
|
||||
ensureXcodeCLT()
|
||||
ensureHomebrew()
|
||||
}
|
||||
|
||||
fmt.Println("Checking installed packages ...")
|
||||
|
||||
if !*gui {
|
||||
var skippedGUI, kept []string
|
||||
for _, p := range systemPkgs {
|
||||
if guiSystemPkgs[p] {
|
||||
skippedGUI = append(skippedGUI, p)
|
||||
} else {
|
||||
kept = append(kept, p)
|
||||
}
|
||||
}
|
||||
systemPkgs = kept
|
||||
if len(skippedGUI) > 0 {
|
||||
fmt.Printf(" [HEADLESS] Skipping GUI system packages: %s\n", fmtList(skippedGUI, 6))
|
||||
}
|
||||
flatpakPkgs = nil
|
||||
}
|
||||
|
||||
doFlatpak := (*only == "" || *only == "flatpak") && *gui && !isMacOS
|
||||
|
||||
sysCheck, flatCheck, custCheck := checkAllInParallel(
|
||||
*only == "" || *only == "system", systemPkgs,
|
||||
doFlatpak, flatpakPkgs,
|
||||
*only == "" || *only == "custom", customPtrs,
|
||||
)
|
||||
|
||||
total := printCheckSummary(sysCheck, flatCheck, custCheck, *only)
|
||||
|
||||
if total == 0 {
|
||||
fmt.Println("\nAll packages already installed.")
|
||||
writeRunLog()
|
||||
return
|
||||
}
|
||||
|
||||
if !askYN(fmt.Sprintf("\n%d item(s) to install. Proceed? [y/N] ", total)) {
|
||||
fmt.Fprintln(os.Stderr, "Aborted.")
|
||||
osExit(1)
|
||||
return
|
||||
}
|
||||
|
||||
checkSudo()
|
||||
promptGitHubToken()
|
||||
|
||||
if *only == "" || *only == "system" {
|
||||
installSystemPackages(sysCheck.toInstallRegular, sysCheck.toInstallSpecial)
|
||||
ensureZshDefault()
|
||||
}
|
||||
|
||||
if doFlatpak {
|
||||
installFlatpakPackages(flatCheck.toInstall)
|
||||
}
|
||||
|
||||
var pyenvWG interface{ Wait() }
|
||||
if *only == "" || *only == "custom" {
|
||||
installCustomPackages(custCheck.toInstall)
|
||||
ensureNodeLTS()
|
||||
if wg := ensurePythonLatest(); wg != nil {
|
||||
pyenvWG = wg
|
||||
}
|
||||
}
|
||||
|
||||
if *only == "" {
|
||||
checkAndSetupSSH()
|
||||
cloneNvimConfig()
|
||||
if isMacOS && !*noVM {
|
||||
setupFirecrackerVM()
|
||||
}
|
||||
ensureLibreOfficeAutoSave()
|
||||
}
|
||||
|
||||
if pyenvWG != nil {
|
||||
fmt.Println("\n[pyenv] Waiting for background Python install to finish ...")
|
||||
pyenvWG.Wait()
|
||||
}
|
||||
|
||||
writeRunLog()
|
||||
printNotices()
|
||||
fmt.Println("\nDone.")
|
||||
|
||||
if hasErrors() {
|
||||
osExit(1)
|
||||
return
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
zshrc := filepath.Join(home, ".zshrc")
|
||||
if hasCmd("zsh") {
|
||||
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.")
|
||||
osExit(1)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
if !hasCmd("sudo") {
|
||||
fmt.Fprintln(os.Stderr, "sudo is required but not installed.")
|
||||
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.")
|
||||
osExit(1)
|
||||
return
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const httpClientTimeout = 30 * time.Minute
|
||||
|
||||
var httpClient = &http.Client{Timeout: httpClientTimeout}
|
||||
|
||||
// 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))
|
||||
return false
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
errLog(fmt.Sprintf("Download failed for %s: %v", url, err))
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
errLog(fmt.Sprintf("Download failed for %s: HTTP %d", url, resp.StatusCode))
|
||||
return false
|
||||
}
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
errLog(fmt.Sprintf("Download failed for %s: %v", url, err))
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
errLog(fmt.Sprintf("Download failed for %s: %v", url, err))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// fetchJSONReal GETs url with the GitHub API Accept header and decodes the body
|
||||
// into v. Returns true on success.
|
||||
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))
|
||||
return false
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
if tok := os.Getenv("GITHUB_TOKEN"); tok != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
errLog(fmt.Sprintf("API request failed for %s: %v", url, err))
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
errLog(fmt.Sprintf("API request failed for %s: HTTP %d", url, resp.StatusCode))
|
||||
return false
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
|
||||
errLog(fmt.Sprintf("API request failed for %s: %v", url, err))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 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))
|
||||
return ""
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
errLog(fmt.Sprintf("Fetch failed for %s: %v", url, err))
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
errLog(fmt.Sprintf("Fetch failed for %s: HTTP %d", url, resp.StatusCode))
|
||||
return ""
|
||||
}
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
errLog(fmt.Sprintf("Fetch failed for %s: %v", url, err))
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func sha256Of(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
+359
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
package main
|
||||
|
||||
// Package definitions consumed by the bootstrap entry point.
|
||||
//
|
||||
// SystemPackages and FlatpakPackages are flat name lists. CustomPackages
|
||||
// declare a URL template plus an optional FetchLatest hint; at install time
|
||||
// the resolver attempts to look up the most recent release and falls back to
|
||||
// the pinned (Version, sha256) tuple on failure.
|
||||
//
|
||||
// URL templates use the substitutions described in formatURL.
|
||||
|
||||
var SystemPackages = []string{
|
||||
"aria2",
|
||||
"age",
|
||||
"ansible",
|
||||
"ansible-core",
|
||||
"btm",
|
||||
"build-essential",
|
||||
"buildah",
|
||||
"containerd.io",
|
||||
"docker-buildx-plugin",
|
||||
"docker-ce-cli",
|
||||
"docker-ce-rootless-extras",
|
||||
"docker-ce",
|
||||
"docker-compose-plugin",
|
||||
"dotnet-sdk-10.0",
|
||||
"ffmpeg-free",
|
||||
"gcc",
|
||||
"gh",
|
||||
"git",
|
||||
"github-desktop",
|
||||
"google-chrome-stable",
|
||||
"helm",
|
||||
"jq",
|
||||
"kubectl",
|
||||
"lazygit",
|
||||
"lua",
|
||||
"minisign",
|
||||
"minikube",
|
||||
"nmap",
|
||||
"obs-studio",
|
||||
"obsidian",
|
||||
"pipx",
|
||||
"poetry",
|
||||
"pulumi",
|
||||
"podman",
|
||||
"qemu",
|
||||
"restic",
|
||||
"rg",
|
||||
"semgrep",
|
||||
"shutter",
|
||||
"sops",
|
||||
"temurin-25-jdk",
|
||||
"tmux",
|
||||
"vagrant",
|
||||
"virt-manager",
|
||||
"vivaldi-stable",
|
||||
"webcamoid",
|
||||
"wireshark",
|
||||
"yt-dlp",
|
||||
"zoom",
|
||||
"zsh",
|
||||
"fzf",
|
||||
"fd",
|
||||
"bzip2",
|
||||
"bzip2-devel",
|
||||
"curl",
|
||||
"gdbm-libs",
|
||||
"libffi-devel",
|
||||
"libnsl2",
|
||||
"libuuid-devel",
|
||||
"libxml2-devel",
|
||||
"libzstd-devel",
|
||||
"make",
|
||||
"ncurses-devel",
|
||||
"openssl-devel",
|
||||
"patch",
|
||||
"readline-devel",
|
||||
"sqlite",
|
||||
"sqlite-devel",
|
||||
"tk-devel",
|
||||
"xmlsec1-devel",
|
||||
"xz",
|
||||
"xz-devel",
|
||||
"zlib-devel",
|
||||
}
|
||||
|
||||
var FlatpakPackages = []string{
|
||||
"com.obsproject.Studio",
|
||||
"fr.handbrake.ghb",
|
||||
"io.github.webcamoid.Webcamoid",
|
||||
"one.ablaze.floorp",
|
||||
"com.vivaldi.Vivaldi",
|
||||
"org.darktable.Darktable",
|
||||
}
|
||||
|
||||
// CustomPackage describes a third-party tarball/binary we fetch directly
|
||||
// (i.e. not via the host package manager).
|
||||
type CustomPackage struct {
|
||||
Name string
|
||||
Version string // pinned fallback version
|
||||
URLTemplate string // see formatURL for substitutions
|
||||
SHA256 string // single-arch hex digest (set by resolveLatest)
|
||||
SHA256Map map[string]string // per-platform pinned digests: {"os-arch": hex}
|
||||
SHA256URLTemplate string // template for a .minisig URL
|
||||
MinisignKey string // base64 public key for minisign verification
|
||||
FetchLatest string // latest-version resolver hint ("go", "firecracker", "zig")
|
||||
InstallPath string // override the default install-check path
|
||||
}
|
||||
|
||||
func customPackages() []CustomPackage {
|
||||
return []CustomPackage{
|
||||
{
|
||||
Name: "go",
|
||||
Version: "1.26.3",
|
||||
URLTemplate: "https://go.dev/dl/go{version}.{os_go}-{arch_go}.tar.gz",
|
||||
SHA256Map: map[string]string{
|
||||
"linux-x86_64": "2b2cfc7148493da5e73981bffbf3353af381d5f93e789c82c79aff64962eb556",
|
||||
"linux-aarch64": "9d89a3ea57d141c2b22d70083f2c8459ba3890f2d9e818e7e933b75614936565",
|
||||
"macos-x86_64": "278d580b32e299fe4a9c990fcf2d02acfe538c7e551a6ee18f9c7164573d2c63",
|
||||
"macos-aarch64": "875cf54a15311eee2c99b9dd67c68c4a49351d489ab622bf2cfd28c8f2078d3c",
|
||||
},
|
||||
FetchLatest: "go",
|
||||
},
|
||||
{Name: "neovim"},
|
||||
{
|
||||
Name: "firecracker",
|
||||
Version: "1.15.1",
|
||||
URLTemplate: "https://github.com/firecracker-microvm/firecracker/releases/download/" +
|
||||
"v{version}/firecracker-v{version}-{arch}.tgz",
|
||||
SHA256Map: map[string]string{
|
||||
"linux-x86_64": "d4a32ab2322d887ca1bc4a4e7afa9cc35393e6362dfc2b3becb389d362e4275a",
|
||||
"linux-aarch64": "00654ac1e702a22744121ea9f10a4f792ebd7c3a744cba587dfac9fcb79b41a5",
|
||||
},
|
||||
FetchLatest: "firecracker",
|
||||
},
|
||||
{
|
||||
Name: "zig",
|
||||
Version: "0.16.0",
|
||||
URLTemplate: "https://ziglang.org/download/{version}/zig-{arch}-{os_zig}-{version}.tar.xz",
|
||||
SHA256URLTemplate: "https://ziglang.org/download/{version}/zig-{arch}-{os_zig}-{version}.tar.xz.minisig",
|
||||
MinisignKey: "RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U",
|
||||
FetchLatest: "zig",
|
||||
},
|
||||
{Name: "nvm"},
|
||||
{Name: "pyenv"},
|
||||
{Name: "pip"},
|
||||
{Name: "oh-my-zsh"},
|
||||
{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
@@ -0,0 +1,170 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// cpuWorkers returns the parallelism level for install/check work.
|
||||
// Defaults to runtime.NumCPU(); overridable via BOOTSTRAP_PARALLELISM
|
||||
// (e.g. for tests / constrained hosts) and clamped to >=1.
|
||||
func cpuWorkers() int {
|
||||
if v := os.Getenv("BOOTSTRAP_PARALLELISM"); v != "" {
|
||||
var n int
|
||||
_, _ = fmt.Sscanf(v, "%d", &n)
|
||||
if n >= 1 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
n := runtime.NumCPU()
|
||||
if n < 1 {
|
||||
return 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// httpWorkersCap is the polite ceiling for HTTP-bound concurrency when no
|
||||
// GitHub token has been provided (GitHub anon rate-limits at 60/hour).
|
||||
// Authenticated requests get 5000/hour so we lift the cap when a token is
|
||||
// available — see githubTokenSet.
|
||||
const httpWorkersCap = 8
|
||||
|
||||
var githubTokenSet bool
|
||||
|
||||
// httpWorkers caps cpuWorkers() to httpWorkersCap unless a GitHub token has
|
||||
// been supplied (in which case we use the full processor count).
|
||||
func httpWorkers() int {
|
||||
n := cpuWorkers()
|
||||
if githubTokenSet {
|
||||
return n
|
||||
}
|
||||
if n > httpWorkersCap {
|
||||
return httpWorkersCap
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// parallelDo runs fn(i, items[i]) over items with at most maxWorkers
|
||||
// goroutines in flight. Returns once every task has finished. Order of
|
||||
// completion is not guaranteed; fn is responsible for its own synchronization
|
||||
// when writing shared state.
|
||||
func parallelDo[T any](items []T, maxWorkers int, fn func(i int, item T)) {
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
if maxWorkers < 1 {
|
||||
maxWorkers = 1
|
||||
}
|
||||
if maxWorkers > len(items) {
|
||||
maxWorkers = len(items)
|
||||
}
|
||||
sem := make(chan struct{}, maxWorkers)
|
||||
var wg sync.WaitGroup
|
||||
for i, item := range items {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int, item T) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
fn(i, item)
|
||||
}(i, item)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// taskOutput is the per-call sink for status text and subprocess output.
|
||||
//
|
||||
// Two modes:
|
||||
//
|
||||
// Sequential (label==""): Printf goes straight to os.Stdout, and Writer()
|
||||
// returns nil so runCmd falls back to its default streamed-to-stdout mode.
|
||||
// Behavior matches the pre-parallelism code exactly.
|
||||
//
|
||||
// Captured (label!=""): Printf and runCmd output both land in an internal
|
||||
// buffer; Flush() prints the whole block at once with a " [label] " prefix
|
||||
// on every line. Used by parallel install workers so concurrent output
|
||||
// doesn't interleave.
|
||||
type taskOutput struct {
|
||||
label string
|
||||
buf bytes.Buffer
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newSerialOutput() *taskOutput { return &taskOutput{} }
|
||||
func newCapturedOutput(label string) *taskOutput {
|
||||
return &taskOutput{label: label}
|
||||
}
|
||||
|
||||
// Printf writes to the task's destination.
|
||||
func (t *taskOutput) Printf(format string, args ...any) {
|
||||
if t.label == "" {
|
||||
fmt.Printf(format, args...)
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
fmt.Fprintf(&t.buf, format, args...)
|
||||
}
|
||||
|
||||
// Println writes a line to the task's destination.
|
||||
func (t *taskOutput) Println(args ...any) {
|
||||
if t.label == "" {
|
||||
fmt.Println(args...)
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
fmt.Fprintln(&t.buf, args...)
|
||||
}
|
||||
|
||||
// Writer returns the io.Writer that runCmd/runShell should target via
|
||||
// CmdOpts.Out. Returns nil in sequential mode (preserves streamed stdout).
|
||||
func (t *taskOutput) Writer() io.Writer {
|
||||
if t.label == "" {
|
||||
return nil
|
||||
}
|
||||
return &lockingWriter{mu: &t.mu, w: &t.buf}
|
||||
}
|
||||
|
||||
// Flush emits the captured buffer to w with the task label prefixed onto
|
||||
// every line. Idempotent and a no-op in sequential mode.
|
||||
func (t *taskOutput) Flush(w io.Writer) {
|
||||
if t.label == "" {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.buf.Len() == 0 {
|
||||
return
|
||||
}
|
||||
if w == nil {
|
||||
w = os.Stdout
|
||||
}
|
||||
prefix := fmt.Sprintf(" [%s] ", t.label)
|
||||
lines := bytes.Split(t.buf.Bytes(), []byte{'\n'})
|
||||
for i, line := range lines {
|
||||
if i == len(lines)-1 && len(line) == 0 {
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(w, "%s%s\n", prefix, line)
|
||||
}
|
||||
t.buf.Reset()
|
||||
}
|
||||
|
||||
// lockingWriter is a thin io.Writer that holds the taskOutput mutex while
|
||||
// writing, so runCmd / runShell can stream into the buffer concurrently with
|
||||
// status Printf calls on the same task without corrupting the buffer.
|
||||
type lockingWriter struct {
|
||||
mu *sync.Mutex
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
func (l *lockingWriter) Write(p []byte) (int, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.w.Write(p)
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCpuWorkers(t *testing.T) {
|
||||
t.Setenv("BOOTSTRAP_PARALLELISM", "4")
|
||||
if n := cpuWorkers(); n != 4 {
|
||||
t.Errorf("expected 4 workers via env, got %d", n)
|
||||
}
|
||||
t.Setenv("BOOTSTRAP_PARALLELISM", "")
|
||||
if n := cpuWorkers(); n < 1 {
|
||||
t.Errorf("expected at least 1 worker, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHttpWorkersRespectsCap(t *testing.T) {
|
||||
t.Setenv("BOOTSTRAP_PARALLELISM", "32")
|
||||
defer func() { githubTokenSet = false }()
|
||||
|
||||
githubTokenSet = false
|
||||
if n := httpWorkers(); n != httpWorkersCap {
|
||||
t.Errorf("expected http workers capped at %d without token, got %d", httpWorkersCap, n)
|
||||
}
|
||||
|
||||
githubTokenSet = true
|
||||
if n := httpWorkers(); n != 32 {
|
||||
t.Errorf("expected http workers uncapped to 32 with token, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelDoConcurrency(t *testing.T) {
|
||||
const items = 16
|
||||
var inFlight, peak int32
|
||||
work := make([]int, items)
|
||||
for i := range work {
|
||||
work[i] = i
|
||||
}
|
||||
|
||||
parallelDo(work, 4, func(_ int, _ int) {
|
||||
now := atomic.AddInt32(&inFlight, 1)
|
||||
for {
|
||||
cur := atomic.LoadInt32(&peak)
|
||||
if now <= cur || atomic.CompareAndSwapInt32(&peak, cur, now) {
|
||||
break
|
||||
}
|
||||
}
|
||||
// brief busy spin to keep multiple workers overlapping
|
||||
for i := 0; i < 50000; i++ {
|
||||
_ = i * i
|
||||
}
|
||||
atomic.AddInt32(&inFlight, -1)
|
||||
})
|
||||
if peak < 2 {
|
||||
t.Errorf("expected at least 2 concurrent workers, observed peak %d", peak)
|
||||
}
|
||||
if peak > 4 {
|
||||
t.Errorf("worker cap violated: peak %d > 4", peak)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelDoEmpty(t *testing.T) {
|
||||
called := false
|
||||
parallelDo([]int{}, 4, func(_ int, _ int) { called = true })
|
||||
if called {
|
||||
t.Error("expected fn to not be invoked on empty input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelDoAllItemsProcessed(t *testing.T) {
|
||||
items := []int{1, 2, 3, 4, 5, 6, 7, 8}
|
||||
var sum int64
|
||||
parallelDo(items, 3, func(_ int, v int) {
|
||||
atomic.AddInt64(&sum, int64(v))
|
||||
})
|
||||
if sum != 36 {
|
||||
t.Errorf("expected sum 36, got %d", sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskOutputSerial(t *testing.T) {
|
||||
t.Cleanup(resetMocks)
|
||||
tOut := newSerialOutput()
|
||||
// Serial: writes go straight to stdout; Writer() returns nil.
|
||||
if tOut.Writer() != nil {
|
||||
t.Error("expected Writer() to be nil in serial mode")
|
||||
}
|
||||
// flushing serial mode is a no-op
|
||||
var buf bytes.Buffer
|
||||
tOut.Flush(&buf)
|
||||
if buf.Len() != 0 {
|
||||
t.Error("expected Flush to be no-op in serial mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskOutputCaptured(t *testing.T) {
|
||||
tOut := newCapturedOutput("mypkg")
|
||||
tOut.Printf("first %s\n", "line")
|
||||
tOut.Println("second line")
|
||||
tOut.Printf("third line")
|
||||
|
||||
var buf bytes.Buffer
|
||||
tOut.Flush(&buf)
|
||||
got := buf.String()
|
||||
|
||||
expected := " [mypkg] first line\n [mypkg] second line\n [mypkg] third line\n"
|
||||
if got != expected {
|
||||
t.Errorf("captured output mismatch:\nexpected:\n%q\ngot:\n%q", expected, got)
|
||||
}
|
||||
|
||||
// Flush is idempotent (second call writes nothing).
|
||||
buf.Reset()
|
||||
tOut.Flush(&buf)
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected second Flush to be empty, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTaskOutputRoutesPrints(t *testing.T) {
|
||||
tOut := newCapturedOutput("worker")
|
||||
withTaskOutput(tOut, func() {
|
||||
taskPrintf("hello %d\n", 7)
|
||||
taskPrintln("world")
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
tOut.Flush(&buf)
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "[worker] hello 7") || !strings.Contains(got, "[worker] world") {
|
||||
t.Errorf("expected routed output with prefix, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTaskOutputUnsetsAfter(t *testing.T) {
|
||||
tOut := newCapturedOutput("worker")
|
||||
withTaskOutput(tOut, func() {
|
||||
if currentTask() == nil {
|
||||
t.Error("expected active task inside withTaskOutput")
|
||||
}
|
||||
})
|
||||
if currentTask() != nil {
|
||||
t.Error("expected no active task after withTaskOutput returns")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelTaskOutputIsolation(t *testing.T) {
|
||||
// Each goroutine should see only its own task output, even though
|
||||
// they all share package-global state.
|
||||
const n = 8
|
||||
var wg sync.WaitGroup
|
||||
results := make([]string, n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
label := "g" + string(rune('a'+i))
|
||||
tOut := newCapturedOutput(label)
|
||||
withTaskOutput(tOut, func() {
|
||||
taskPrintf("from %s\n", label)
|
||||
})
|
||||
var buf bytes.Buffer
|
||||
tOut.Flush(&buf)
|
||||
results[i] = buf.String()
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i, got := range results {
|
||||
label := "g" + string(rune('a'+i))
|
||||
want := " [" + label + "] from " + label + "\n"
|
||||
if got != want {
|
||||
t.Errorf("goroutine %d: expected %q, got %q", i, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdOptsOutRoutesRunCmd(t *testing.T) {
|
||||
defer resetMocks()
|
||||
|
||||
var buf bytes.Buffer
|
||||
// Pick a command guaranteed to exist and produce output.
|
||||
result := runCmdReal([]string{"echo", "hello-out"}, CmdOpts{Out: &buf})
|
||||
if !result.OK() {
|
||||
t.Fatalf("echo failed: %v", result.Err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "$ echo hello-out") {
|
||||
t.Errorf("expected command echo in Out, got: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "hello-out") {
|
||||
t.Errorf("expected stdout in Out, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdOptsOutRoutesRunShell(t *testing.T) {
|
||||
defer resetMocks()
|
||||
|
||||
var buf bytes.Buffer
|
||||
result := runShellReal("echo shell-out", CmdOpts{Out: &buf})
|
||||
if !result.OK() {
|
||||
t.Fatalf("shell failed: %v", result.Err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "$ echo shell-out") {
|
||||
t.Errorf("expected command echo in Out, got: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "shell-out") {
|
||||
t.Errorf("expected stdout in Out, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueLogRoutesViaTaskOutput(t *testing.T) {
|
||||
defer resetMocks()
|
||||
// Suppress fallback stdout for the non-task branch.
|
||||
issueLogWriter = &bytes.Buffer{}
|
||||
|
||||
tOut := newCapturedOutput("isolated")
|
||||
withTaskOutput(tOut, func() {
|
||||
warn("a warning")
|
||||
errLog("an error")
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
tOut.Flush(&buf)
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "[isolated] [WARN] a warning") {
|
||||
t.Errorf("expected routed WARN line, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "[isolated] [ERROR] an error") {
|
||||
t.Errorf("expected routed ERROR line, got: %q", out)
|
||||
}
|
||||
// Issues should still flow into the global issues slice.
|
||||
if !hasErrors() {
|
||||
t.Error("expected errLog to register a global error even when routed via task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelPartitionOrdering(t *testing.T) {
|
||||
items := []int{1, 2, 3, 4, 5, 6, 7, 8}
|
||||
even, odd := parallelPartition(items, func(v int) bool { return v%2 == 0 })
|
||||
|
||||
wantEven := []int{2, 4, 6, 8}
|
||||
wantOdd := []int{1, 3, 5, 7}
|
||||
if !equalIntSlices(even, wantEven) {
|
||||
t.Errorf("evens: want %v, got %v", wantEven, even)
|
||||
}
|
||||
if !equalIntSlices(odd, wantOdd) {
|
||||
t.Errorf("odds: want %v, got %v", wantOdd, odd)
|
||||
}
|
||||
}
|
||||
|
||||
func equalIntSlices(a, b []int) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestGoidUnique(t *testing.T) {
|
||||
mainID := goid()
|
||||
if mainID == 0 {
|
||||
t.Error("goid returned 0 for main goroutine")
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
var childID uint64
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
childID = goid()
|
||||
}()
|
||||
wg.Wait()
|
||||
if childID == 0 {
|
||||
t.Error("goid returned 0 for child goroutine")
|
||||
}
|
||||
if childID == mainID {
|
||||
t.Errorf("expected child goroutine id %d to differ from main id %d", childID, mainID)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify BOOTSTRAP_PARALLELISM=0 falls back to NumCPU rather than 0 workers.
|
||||
func TestCpuWorkersInvalidEnv(t *testing.T) {
|
||||
t.Setenv("BOOTSTRAP_PARALLELISM", "0")
|
||||
if n := cpuWorkers(); n < 1 {
|
||||
t.Errorf("expected fallback to runtime.NumCPU for invalid env, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCpuWorkersBadEnv(t *testing.T) {
|
||||
t.Setenv("BOOTSTRAP_PARALLELISM", "notanumber")
|
||||
if n := cpuWorkers(); n < 1 {
|
||||
t.Errorf("expected fallback for non-numeric env, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the parallelDo guard returns when len(items) == 0 even with
|
||||
// maxWorkers larger than 1. Smoke test for the early return.
|
||||
func TestParallelDoBigWorkersSmallInput(t *testing.T) {
|
||||
items := []string{"only"}
|
||||
called := 0
|
||||
var mu sync.Mutex
|
||||
parallelDo(items, 32, func(_ int, _ string) {
|
||||
mu.Lock()
|
||||
called++
|
||||
mu.Unlock()
|
||||
})
|
||||
if called != 1 {
|
||||
t.Errorf("expected exactly 1 invocation, got %d", called)
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm runCmd inside withTaskOutput auto-routes via Out without callers
|
||||
// having to set it explicitly — that's the contract that lets install
|
||||
// handlers rely on taskOut().
|
||||
func TestRunCmdInsideWithTaskOutput(t *testing.T) {
|
||||
defer resetMocks()
|
||||
tOut := newCapturedOutput("autoroute")
|
||||
|
||||
withTaskOutput(tOut, func() {
|
||||
runCmdReal([]string{"echo", "auto"}, CmdOpts{Out: taskOut()})
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
tOut.Flush(&buf)
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "[autoroute] $ echo auto") {
|
||||
t.Errorf("expected routed echo command, got: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "[autoroute] auto") {
|
||||
t.Errorf("expected routed echo stdout, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: when no token is in env and the user prompt is suppressed,
|
||||
// httpWorkers stays capped. Just exercise the path; ensures no panic.
|
||||
func TestPromptGitHubTokenNoOp(t *testing.T) {
|
||||
defer func() { githubTokenSet = false }()
|
||||
githubTokenSet = false
|
||||
os.Unsetenv("GITHUB_TOKEN")
|
||||
// We can't easily prompt in a test; just confirm cap is in effect.
|
||||
n := httpWorkers()
|
||||
if n > httpWorkersCap {
|
||||
t.Errorf("expected http workers <= %d without token, got %d", httpWorkersCap, n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var pkgMgr string // "dnf", "apt-get", "pacman", "brew"
|
||||
|
||||
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
|
||||
// call that actually invokes brew.
|
||||
return "brew"
|
||||
}
|
||||
for _, mgr := range []string{"dnf", "apt-get", "pacman"} {
|
||||
if hasCmd(mgr) {
|
||||
return mgr
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "No supported package manager found (expected dnf, apt-get, pacman, or brew on macOS).")
|
||||
osExit(1)
|
||||
return ""
|
||||
}
|
||||
|
||||
// pkgOverrides maps a (PKG_MGR, generic_name) pair to a distro-specific
|
||||
// replacement. An empty []string{} means "skip with a warning".
|
||||
//
|
||||
// Use overrideEntry to distinguish "skip" (Skip=true) from "replace with
|
||||
// these packages" (Replacement=[...]).
|
||||
type overrideEntry struct {
|
||||
Skip bool
|
||||
Replacement []string
|
||||
}
|
||||
|
||||
func skipOverride() overrideEntry { return overrideEntry{Skip: true} }
|
||||
func replace(names ...string) overrideEntry {
|
||||
return overrideEntry{Replacement: names}
|
||||
}
|
||||
|
||||
var packageOverrides = map[string]map[string]overrideEntry{
|
||||
"dnf": {
|
||||
"build-essential": replace("gcc", "gcc-c++", "make"),
|
||||
"rg": replace("ripgrep"),
|
||||
"docker-compose": skipOverride(),
|
||||
"webcamoid": skipOverride(),
|
||||
},
|
||||
"apt-get": {
|
||||
"fd": replace("fd-find"),
|
||||
"ffmpeg-free": replace("ffmpeg"),
|
||||
"lua": replace("lua5.4"),
|
||||
"qemu": replace("qemu-system"),
|
||||
"rg": replace("ripgrep"),
|
||||
"bzip2-devel": replace("libbz2-dev"),
|
||||
"gdbm-libs": replace("libgdbm-dev"),
|
||||
"libffi-devel": replace("libffi-dev"),
|
||||
"libnsl2": replace("libnsl-dev"),
|
||||
"libuuid-devel": replace("uuid-dev"),
|
||||
"libxml2-devel": replace("libxml2-dev"),
|
||||
"libzstd-devel": replace("libzstd-dev"),
|
||||
"ncurses-devel": replace("libncursesw5-dev"),
|
||||
"openssl-devel": replace("libssl-dev"),
|
||||
"readline-devel": replace("libreadline-dev"),
|
||||
"sqlite": replace("sqlite3"),
|
||||
"sqlite-devel": replace("libsqlite3-dev"),
|
||||
"tk-devel": replace("tk-dev"),
|
||||
"xmlsec1-devel": replace("libxmlsec1-dev"),
|
||||
"xz": replace("xz-utils"),
|
||||
"xz-devel": replace("liblzma-dev"),
|
||||
"zlib-devel": replace("zlib1g-dev"),
|
||||
},
|
||||
"pacman": {
|
||||
"build-essential": replace("base-devel"),
|
||||
"ansible-core": skipOverride(), // bundled with ansible
|
||||
"containerd.io": replace("containerd"),
|
||||
"docker-ce": replace("docker"),
|
||||
"docker-ce-cli": skipOverride(), // covered by docker
|
||||
"docker-ce-rootless-extras": 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"),
|
||||
"ffmpeg-free": replace("ffmpeg"),
|
||||
"gh": replace("github-cli"),
|
||||
"github-desktop": skipOverride(), // AUR-only
|
||||
"google-chrome-stable": skipOverride(), // AUR-only
|
||||
"lua": replace("lua"),
|
||||
"obs-studio": replace("obs-studio"),
|
||||
"obsidian": skipOverride(), // AUR-only; provided via Flatpak when --gui
|
||||
"pulumi": skipOverride(), // AUR-only; installed via custom path
|
||||
"qemu": replace("qemu-full"),
|
||||
"rg": replace("ripgrep"),
|
||||
"shutter": skipOverride(), // AUR-only
|
||||
"temurin-25-jdk": replace("jdk-openjdk"),
|
||||
"vagrant": skipOverride(), // AUR-only
|
||||
"vivaldi-stable": replace("vivaldi"),
|
||||
"webcamoid": skipOverride(), // AUR-only; provided via Flatpak when --gui
|
||||
"wireshark": replace("wireshark-qt"),
|
||||
"yt-dlp": replace("yt-dlp"),
|
||||
"zoom": skipOverride(), // AUR-only
|
||||
"bzip2-devel": skipOverride(),
|
||||
"gdbm-libs": replace("gdbm"),
|
||||
"libffi-devel": replace("libffi"),
|
||||
"libnsl2": replace("libnsl"),
|
||||
"libuuid-devel": replace("util-linux-libs"),
|
||||
"libxml2-devel": replace("libxml2"),
|
||||
"libzstd-devel": replace("zstd"),
|
||||
"ncurses-devel": replace("ncurses"),
|
||||
"openssl-devel": replace("openssl"),
|
||||
"readline-devel": replace("readline"),
|
||||
"sqlite-devel": skipOverride(),
|
||||
"tk-devel": replace("tk"),
|
||||
"xmlsec1-devel": replace("xmlsec"),
|
||||
"xz-devel": skipOverride(),
|
||||
"zlib-devel": replace("zlib"),
|
||||
},
|
||||
"brew": {
|
||||
"build-essential": skipOverride(),
|
||||
"gcc": skipOverride(),
|
||||
"make": skipOverride(),
|
||||
"patch": skipOverride(),
|
||||
"zsh": skipOverride(),
|
||||
"ansible-core": skipOverride(),
|
||||
"containerd.io": skipOverride(),
|
||||
"docker-buildx-plugin": skipOverride(),
|
||||
"docker-ce-cli": skipOverride(),
|
||||
"docker-ce-rootless-extras": skipOverride(),
|
||||
"docker-ce": replace("docker"),
|
||||
"docker-compose-plugin": replace("docker-compose"),
|
||||
"dotnet-sdk-10.0": replace("dotnet"),
|
||||
"ffmpeg-free": replace("ffmpeg"),
|
||||
"github-desktop": replace("github"),
|
||||
"google-chrome-stable": replace("google-chrome"),
|
||||
"obs-studio": replace("obs"),
|
||||
"rg": replace("ripgrep"),
|
||||
"temurin-25-jdk": replace("temurin"),
|
||||
"vivaldi-stable": replace("vivaldi"),
|
||||
"buildah": skipOverride(),
|
||||
"shutter": skipOverride(),
|
||||
"virt-manager": skipOverride(),
|
||||
"webcamoid": skipOverride(),
|
||||
"bzip2-devel": skipOverride(),
|
||||
"curl": skipOverride(),
|
||||
"gdbm-libs": replace("gdbm"),
|
||||
"libffi-devel": replace("libffi"),
|
||||
"libnsl2": skipOverride(),
|
||||
"libuuid-devel": skipOverride(),
|
||||
"libxml2-devel": replace("libxml2"),
|
||||
"libzstd-devel": replace("zstd"),
|
||||
"ncurses-devel": skipOverride(),
|
||||
"openssl-devel": replace("openssl@3"),
|
||||
"readline-devel": replace("readline"),
|
||||
"sqlite-devel": skipOverride(),
|
||||
"tk-devel": replace("tcl-tk"),
|
||||
"xmlsec1-devel": replace("libxmlsec1"),
|
||||
"xz": replace("xz"),
|
||||
"xz-devel": skipOverride(),
|
||||
"zlib-devel": skipOverride(),
|
||||
},
|
||||
}
|
||||
|
||||
// brewCasks: brew packages that must be installed with `brew install --cask`.
|
||||
// Names are post-override.
|
||||
var brewCasks = map[string]bool{
|
||||
"docker": true,
|
||||
"github": true,
|
||||
"google-chrome": true,
|
||||
"obs": true,
|
||||
"obsidian": true,
|
||||
"temurin": true,
|
||||
"vagrant": true,
|
||||
"vivaldi": true,
|
||||
"zoom": true,
|
||||
}
|
||||
|
||||
// resolveSystemPkgs applies distro overrides. Returns (resolved, skipped).
|
||||
func resolveSystemPkgs(names []string) ([]string, []string) {
|
||||
overrides := packageOverrides[pkgMgr]
|
||||
var resolved, skipped []string
|
||||
for _, pkg := range names {
|
||||
ov, ok := overrides[pkg]
|
||||
if !ok {
|
||||
resolved = append(resolved, pkg)
|
||||
continue
|
||||
}
|
||||
if ov.Skip {
|
||||
skipped = append(skipped, pkg)
|
||||
continue
|
||||
}
|
||||
resolved = append(resolved, ov.Replacement...)
|
||||
}
|
||||
return resolved, skipped
|
||||
}
|
||||
|
||||
// isSystemPkgInstalled queries the host package manager.
|
||||
func isSystemPkgInstalled(pkg string) bool {
|
||||
switch pkgMgr {
|
||||
case "dnf":
|
||||
r, ok := probe([]string{"rpm", "-q", pkg}, 0)
|
||||
return ok && r.ExitCode == 0
|
||||
case "apt-get":
|
||||
r, ok := probe([]string{"dpkg-query", "-W", "-f=${Status}", pkg}, 0)
|
||||
return ok && strings.Contains(string(r.Stdout), "install ok installed")
|
||||
case "pacman":
|
||||
r, ok := probe([]string{"pacman", "-Qi", pkg}, 0)
|
||||
return ok && r.ExitCode == 0
|
||||
case "brew":
|
||||
if !hasCmd("brew") {
|
||||
return false
|
||||
}
|
||||
for _, kind := range []string{"--formula", "--cask"} {
|
||||
r, ok := probe([]string{"brew", "list", kind, pkg}, 60*time.Second)
|
||||
if ok && r.ExitCode == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isFlatpakInstalled(appID string) bool {
|
||||
if !hasCmd("flatpak") {
|
||||
return false
|
||||
}
|
||||
r, ok := probe([]string{"flatpak", "info", appID}, 0)
|
||||
return ok && r.ExitCode == 0
|
||||
}
|
||||
+218
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── pyenv / Python ──────────────────────────────────────────────────────
|
||||
|
||||
func installPyenv() {
|
||||
taskPrintln(" Installing pyenv via curl ...")
|
||||
if !runShell("curl https://pyenv.run | bash", CmdOpts{Out: taskOut()}).OK() {
|
||||
errLog("pyenv installation failed")
|
||||
return
|
||||
}
|
||||
taskPrintln(" pyenv installed to ~/.pyenv")
|
||||
}
|
||||
|
||||
func python3DecimalOK() bool {
|
||||
if !hasCmd("python3") {
|
||||
return false
|
||||
}
|
||||
r, ok := probe([]string{"python3", "-c", "from decimal import Decimal"}, 10*time.Second)
|
||||
return ok && r.ExitCode == 0
|
||||
}
|
||||
|
||||
func fixPython3Decimal() bool {
|
||||
switch pkgMgr {
|
||||
case "apt-get":
|
||||
runCmd([]string{"apt-get", "install", "-y", "python3-full"}, CmdOpts{AsSudo: true})
|
||||
case "dnf":
|
||||
runCmd([]string{"dnf", "install", "-y", "python3-libs"}, CmdOpts{AsSudo: true})
|
||||
case "pacman":
|
||||
runCmd([]string{"pacman", "-S", "--noconfirm", "--needed", "python"}, CmdOpts{AsSudo: true})
|
||||
}
|
||||
return python3DecimalOK()
|
||||
}
|
||||
|
||||
func installPip() {
|
||||
out := taskOut()
|
||||
if !hasCmd("python3") {
|
||||
errLog("python3 is not installed — cannot install pip")
|
||||
return
|
||||
}
|
||||
if !python3DecimalOK() {
|
||||
warn("Python 3 _decimal C extension failed to import — attempting fix ...")
|
||||
if fixPython3Decimal() {
|
||||
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), " +
|
||||
"sudo dnf install python3-libs (Fedora/RHEL), or " +
|
||||
"sudo pacman -S python (Arch)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
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, 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, Out: out}).OK() {
|
||||
errLog("python-pip failed to install via pacman — skipping pip bootstrap")
|
||||
return
|
||||
}
|
||||
default:
|
||||
errLog("python3 -m ensurepip failed (system Python may need a distro 'python3-pip' package)")
|
||||
return
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func latestStablePython(pyenvBin string) string {
|
||||
r, ok := probe([]string{pyenvBin, "install", "--list"}, 2*time.Minute)
|
||||
if !ok {
|
||||
errLog("pyenv install --list failed")
|
||||
return ""
|
||||
}
|
||||
if r.ExitCode != 0 {
|
||||
errLog("pyenv install --list failed")
|
||||
return ""
|
||||
}
|
||||
stableRe := regexp.MustCompile(`^\s*(\d+)\.(\d+)\.(\d+)\s*$`)
|
||||
type ver struct{ a, b, c int }
|
||||
var versions []ver
|
||||
for _, line := range strings.Split(string(r.Stdout), "\n") {
|
||||
m := stableRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
a, _ := strconv.Atoi(m[1])
|
||||
b, _ := strconv.Atoi(m[2])
|
||||
c, _ := strconv.Atoi(m[3])
|
||||
if a >= 3 {
|
||||
versions = append(versions, ver{a, b, c})
|
||||
}
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Slice(versions, func(i, j int) bool {
|
||||
if versions[i].a != versions[j].a {
|
||||
return versions[i].a < versions[j].a
|
||||
}
|
||||
if versions[i].b != versions[j].b {
|
||||
return versions[i].b < versions[j].b
|
||||
}
|
||||
return versions[i].c < versions[j].c
|
||||
})
|
||||
v := versions[len(versions)-1]
|
||||
return fmt.Sprintf("%d.%d.%d", v.a, v.b, v.c)
|
||||
}
|
||||
|
||||
// ensurePythonLatest installs the latest stable Python via pyenv if not
|
||||
// present, then sets it as global. Returns a wait group if an install was
|
||||
// kicked off in the background; the caller must call .Wait() before exiting.
|
||||
func ensurePythonLatest() *sync.WaitGroup {
|
||||
home, _ := os.UserHomeDir()
|
||||
pyenvDir := filepath.Join(home, ".pyenv")
|
||||
if _, err := osStat(pyenvDir); err != nil {
|
||||
return nil
|
||||
}
|
||||
pyenvBin := filepath.Join(pyenvDir, "bin", "pyenv")
|
||||
if _, err := osStat(pyenvBin); err != nil {
|
||||
warn(fmt.Sprintf("pyenv binary not found at %s", pyenvBin))
|
||||
return nil
|
||||
}
|
||||
|
||||
latest := latestStablePython(pyenvBin)
|
||||
if latest == "" {
|
||||
errLog("Could not determine latest stable Python from pyenv")
|
||||
return nil
|
||||
}
|
||||
|
||||
r, ok := probe([]string{pyenvBin, "versions", "--bare"}, 30*time.Second)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
installed := strings.Fields(string(r.Stdout))
|
||||
for _, v := range installed {
|
||||
if v == latest {
|
||||
fmt.Printf("\n[pyenv] Python %s already installed.\n", latest)
|
||||
fmt.Printf("[pyenv] Setting Python %s as global default ...\n", latest)
|
||||
if !runCmd([]string{pyenvBin, "global", latest}, CmdOpts{}).OK() {
|
||||
errLog(fmt.Sprintf("pyenv global %s failed", latest))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n[pyenv] Backgrounding install of Python %s (compile may take several minutes) ...\n", latest)
|
||||
start := time.Now()
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
r := runCmd([]string{pyenvBin, "install", "--skip-existing", latest},
|
||||
CmdOpts{Timeout: 60 * time.Minute, Capture: true})
|
||||
elapsed := int(time.Since(start).Seconds())
|
||||
if !r.OK() {
|
||||
errLog(fmt.Sprintf("pyenv install %s failed after %ds", latest, elapsed))
|
||||
if len(r.Stderr) > 0 {
|
||||
lines := strings.Split(string(r.Stderr), "\n")
|
||||
if len(lines) > 20 {
|
||||
lines = lines[len(lines)-20:]
|
||||
}
|
||||
fmt.Printf("\n[pyenv stderr tail]\n%s\n", strings.Join(lines, "\n"))
|
||||
}
|
||||
return
|
||||
}
|
||||
if !runCmd([]string{pyenvBin, "global", latest},
|
||||
CmdOpts{Timeout: time.Minute}).OK() {
|
||||
errLog(fmt.Sprintf("pyenv global %s failed", latest))
|
||||
return
|
||||
}
|
||||
fmt.Printf("\n[pyenv] Python %s installed and set as global default (%ds).\n", latest, elapsed)
|
||||
}()
|
||||
return &wg
|
||||
}
|
||||
|
||||
// ── nvm / Node ──────────────────────────────────────────────────────────
|
||||
|
||||
func installNVM() {
|
||||
var rel ghRelease
|
||||
if !fetchJSON("https://api.github.com/repos/nvm-sh/nvm/releases/latest", &rel) {
|
||||
return
|
||||
}
|
||||
version := rel.TagName
|
||||
if version == "" {
|
||||
errLog("NVM tag_name missing")
|
||||
return
|
||||
}
|
||||
installURL := fmt.Sprintf("https://raw.githubusercontent.com/nvm-sh/nvm/%s/install.sh", version)
|
||||
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
|
||||
}
|
||||
taskPrintf(" NVM %s installed to ~/.nvm\n", version)
|
||||
}
|
||||
|
||||
func ensureNodeLTS() {
|
||||
home, _ := os.UserHomeDir()
|
||||
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"`,
|
||||
CmdOpts{Capture: true})
|
||||
installed := strings.TrimSpace(string(check.Stdout))
|
||||
if installed != "" && installed != "N/A" {
|
||||
fmt.Printf("\n[NVM] Node LTS (%s) already installed.\n", installed)
|
||||
} else {
|
||||
fmt.Println("\n[NVM] Installing Node.js LTS ...")
|
||||
if !runShell(`bash -c "source ~/.nvm/nvm.sh && nvm install --lts"`, CmdOpts{}).OK() {
|
||||
errLog("Node.js LTS install via nvm failed")
|
||||
return
|
||||
}
|
||||
fmt.Println(" Node.js LTS installed.")
|
||||
}
|
||||
|
||||
fmt.Println("[NVM] Setting Node LTS as default ...")
|
||||
if !runShell(`bash -c "source ~/.nvm/nvm.sh && nvm alias default 'lts/*' && nvm use --lts"`, CmdOpts{}).OK() {
|
||||
errLog("Setting nvm default to LTS failed")
|
||||
}
|
||||
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
func installOhMyZsh() {
|
||||
if !hasCmd("zsh") {
|
||||
errLog("zsh is not installed — required by oh-my-zsh")
|
||||
return
|
||||
}
|
||||
if !hasCmd("git") {
|
||||
errLog("git is not installed — required by oh-my-zsh")
|
||||
return
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
target := filepath.Join(home, ".oh-my-zsh")
|
||||
if _, err := osStat(target); err == nil {
|
||||
taskPrintf(" oh-my-zsh already present at %s; updating theme only\n", target)
|
||||
} else {
|
||||
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{Out: taskOut()}).OK() {
|
||||
errLog("oh-my-zsh installer failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
zshrc := filepath.Join(home, ".zshrc")
|
||||
data, err := osReadFile(zshrc)
|
||||
if err != nil {
|
||||
warn("~/.zshrc not present after oh-my-zsh install; cannot set theme")
|
||||
return
|
||||
}
|
||||
text := string(data)
|
||||
re := regexp.MustCompile(`(?m)^\s*ZSH_THEME=.*$`)
|
||||
var newText string
|
||||
if re.MatchString(text) {
|
||||
newText = re.ReplaceAllString(text, `ZSH_THEME="gnzh"`)
|
||||
} else {
|
||||
newText = strings.TrimRight(text, "\n") + "\nZSH_THEME=\"gnzh\"\n"
|
||||
}
|
||||
if newText != text {
|
||||
if err := osWriteFile(zshrc, []byte(newText), 0o644); err != nil {
|
||||
errLog(fmt.Sprintf("could not write ~/.zshrc: %v", err))
|
||||
return
|
||||
}
|
||||
taskPrintln(` Set ZSH_THEME="gnzh" in ~/.zshrc`)
|
||||
} else {
|
||||
taskPrintln(` ~/.zshrc already has ZSH_THEME="gnzh"`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── default-shell + neovim config + gh auth ─────────────────────────────
|
||||
|
||||
func invokingUser() string {
|
||||
if u := os.Getenv("SUDO_USER"); u != "" {
|
||||
return u
|
||||
}
|
||||
if u, err := user.Current(); err == nil {
|
||||
return u.Username
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func ensureZshDefault() {
|
||||
if !hasCmd("zsh") {
|
||||
warn("zsh not installed — skipping default-shell change")
|
||||
return
|
||||
}
|
||||
zshPath := "/bin/zsh"
|
||||
if r, ok := probe([]string{"which", "zsh"}, 5*time.Second); ok && r.ExitCode == 0 {
|
||||
if p := strings.TrimSpace(string(r.Stdout)); p != "" {
|
||||
zshPath = p
|
||||
}
|
||||
}
|
||||
username := invokingUser()
|
||||
if username == "" {
|
||||
warn("could not determine invoking user; skipping default-shell change")
|
||||
return
|
||||
}
|
||||
u, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
warn(fmt.Sprintf("user %s not found in passwd; skipping default-shell change", username))
|
||||
return
|
||||
}
|
||||
current := userLoginShell(u.Uid)
|
||||
if current == zshPath {
|
||||
fmt.Printf("\n[zsh] %s's default shell is already %s.\n", username, zshPath)
|
||||
return
|
||||
}
|
||||
|
||||
family := "Debian-family"
|
||||
if isRHELFamily {
|
||||
family = "RHEL-family"
|
||||
} else if isArchFamily {
|
||||
family = "Arch-family"
|
||||
} else if isMacOS {
|
||||
family = "macOS"
|
||||
}
|
||||
fmt.Printf("\n[zsh] Setting default shell for %s to %s (%s) ...\n", username, zshPath, family)
|
||||
|
||||
var cmd []string
|
||||
if isRHELFamily {
|
||||
cmd = []string{"usermod", "-s", zshPath, username}
|
||||
} else {
|
||||
cmd = []string{"chsh", "-s", zshPath, username}
|
||||
}
|
||||
if !runCmd(cmd, CmdOpts{AsSudo: true}).OK() {
|
||||
errLog(fmt.Sprintf("Failed to set default shell to zsh for %s", username))
|
||||
} else {
|
||||
fmt.Println("[zsh] Default shell updated. Log out and back in for it to take effect.")
|
||||
}
|
||||
}
|
||||
|
||||
// userLoginShell returns the login shell for uid by parsing /etc/passwd. On
|
||||
// macOS the shell may be set by dscl; getent isn't available either, so we
|
||||
// just read passwd directly which works on every supported platform.
|
||||
func userLoginShell(uid string) string {
|
||||
data, err := osReadFile(passwdPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) < 7 {
|
||||
continue
|
||||
}
|
||||
if parts[2] == uid {
|
||||
return parts[6]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cloneNvimConfig() {
|
||||
home, _ := os.UserHomeDir()
|
||||
configDir := filepath.Join(home, ".config", "nvim")
|
||||
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", sshURL)
|
||||
|
||||
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 := osStat(backup); os.IsNotExist(err) {
|
||||
break
|
||||
}
|
||||
n++
|
||||
}
|
||||
fmt.Printf(" Renaming existing %s → %s ...\n", configDir, backup)
|
||||
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))
|
||||
}
|
||||
|
||||
osMkdirAll(filepath.Dir(configDir), 0o755)
|
||||
|
||||
repoName := strings.TrimSuffix(filepath.Base(sshURL), ".git")
|
||||
tempClone := filepath.Join(filepath.Dir(configDir), repoName)
|
||||
osRemoveAll(tempClone)
|
||||
|
||||
fmt.Printf(" Cloning to %s ...\n", configDir)
|
||||
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))
|
||||
osRename(tempClone, configDir)
|
||||
}
|
||||
fmt.Printf(" Neovim configuration ready at %s\n", configDir)
|
||||
}
|
||||
|
||||
func ghLoggedIn() bool {
|
||||
r, ok := probe([]string{"gh", "auth", "status"}, 30*time.Second)
|
||||
return ok && r.ExitCode == 0
|
||||
}
|
||||
|
||||
func checkAndSetupSSH() {
|
||||
if !hasCmd("gh") {
|
||||
fmt.Println("\n[GitHub CLI] gh not installed — skipping authentication.")
|
||||
return
|
||||
}
|
||||
if ghLoggedIn() {
|
||||
fmt.Println("\n[GitHub CLI] Already authenticated.")
|
||||
return
|
||||
}
|
||||
if !askYN("\n[GitHub CLI] Would you like to authenticate the GitHub CLI? [y/N] ") {
|
||||
return
|
||||
}
|
||||
res := runCmd([]string{"gh", "auth", "login"}, CmdOpts{Timeout: 15 * time.Minute})
|
||||
if !res.OK() {
|
||||
errLog("gh auth login failed — skipping key upload.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// askYN prompts on stdin. Returns true only for an exact "y" (case-insensitive).
|
||||
func askYN(prompt string) bool {
|
||||
fmt.Print(prompt)
|
||||
var buf [256]byte
|
||||
n, err := stdin.Read(buf[:])
|
||||
if err != nil && err != io.EOF {
|
||||
fmt.Println()
|
||||
return false
|
||||
}
|
||||
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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,246 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// repoFileExists returns true if any of the given paths exists.
|
||||
func repoFileExists(paths ...string) bool {
|
||||
for _, p := range paths {
|
||||
if _, err := osStat(p); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeDNFRepo(name, displayName, baseurl, gpgkey string) {
|
||||
content := fmt.Sprintf(
|
||||
"[%s]\nname=%s\nbaseurl=%s\nenabled=1\ngpgcheck=1\ngpgkey=%s\n",
|
||||
name, displayName, baseurl, gpgkey,
|
||||
)
|
||||
path := "/etc/yum.repos.d/" + name + ".repo"
|
||||
runCmd([]string{"tee", path}, CmdOpts{AsSudo: true, Input: []byte(content), Capture: true})
|
||||
}
|
||||
|
||||
func setupDockerRepo() {
|
||||
switch pkgMgr {
|
||||
case "dnf":
|
||||
if repoFileExists("/etc/yum.repos.d/docker-ce.repo") {
|
||||
return
|
||||
}
|
||||
runCmd([]string{"dnf", "config-manager", "addrepo", "--from-repofile",
|
||||
"https://download.docker.com/linux/fedora/docker-ce.repo"}, CmdOpts{AsSudo: true})
|
||||
case "apt-get":
|
||||
if repoFileExists("/etc/apt/sources.list.d/docker.list") {
|
||||
return
|
||||
}
|
||||
runCmd([]string{"apt-get", "update"}, CmdOpts{AsSudo: true})
|
||||
runCmd([]string{"apt-get", "install", "-y", "ca-certificates", "curl", "gnupg"}, CmdOpts{AsSudo: true})
|
||||
distroID := osReleaseField("ID")
|
||||
dockerDistro := "ubuntu"
|
||||
if distroID == "debian" || distroID == "ubuntu" {
|
||||
dockerDistro = distroID
|
||||
}
|
||||
runShell(
|
||||
"install -m 0755 -d /etc/apt/keyrings && "+
|
||||
"curl -fsSL https://download.docker.com/linux/"+dockerDistro+"/gpg | "+
|
||||
"sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg && "+
|
||||
"sudo chmod a+r /etc/apt/keyrings/docker.gpg",
|
||||
CmdOpts{},
|
||||
)
|
||||
codenameRes := runShell(". /etc/os-release && echo $VERSION_CODENAME",
|
||||
CmdOpts{Capture: true})
|
||||
codename := strings.TrimSpace(string(codenameRes.Stdout))
|
||||
debArch := archDeb[archName]
|
||||
runCmd(
|
||||
[]string{"tee", "/etc/apt/sources.list.d/docker.list"},
|
||||
CmdOpts{
|
||||
AsSudo: true,
|
||||
Input: []byte(fmt.Sprintf("deb [arch=%s signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/%s %s stable\n", debArch, dockerDistro, codename)),
|
||||
Capture: true,
|
||||
},
|
||||
)
|
||||
runCmd([]string{"apt-get", "update"}, CmdOpts{AsSudo: true})
|
||||
}
|
||||
// pacman: docker is in official repos — no extra repo needed.
|
||||
}
|
||||
|
||||
func setupGHRepo() {
|
||||
switch pkgMgr {
|
||||
case "dnf":
|
||||
if repoFileExists("/etc/yum.repos.d/gh-cli.repo") {
|
||||
return
|
||||
}
|
||||
runCmd([]string{"dnf", "config-manager", "addrepo", "--from-repofile",
|
||||
"https://cli.github.com/packages/rpm/gh-cli.repo"}, CmdOpts{AsSudo: true})
|
||||
case "apt-get":
|
||||
if repoFileExists("/etc/apt/sources.list.d/github-cli.list") {
|
||||
return
|
||||
}
|
||||
debArch := archDeb[archName]
|
||||
runShell(
|
||||
"curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | "+
|
||||
"sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && "+
|
||||
"sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg && "+
|
||||
fmt.Sprintf("echo 'deb [arch=%s signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main' | ", debArch)+
|
||||
"sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null",
|
||||
CmdOpts{},
|
||||
)
|
||||
runCmd([]string{"apt-get", "update"}, CmdOpts{AsSudo: true})
|
||||
}
|
||||
// pacman: github-cli is in community repo.
|
||||
}
|
||||
|
||||
func setupChromeRepo() {
|
||||
if archName != "x86_64" {
|
||||
warn("Google Chrome has no Linux build for this arch — skipping repo")
|
||||
return
|
||||
}
|
||||
switch pkgMgr {
|
||||
case "dnf":
|
||||
if repoFileExists("/etc/yum.repos.d/google-chrome.repo") {
|
||||
return
|
||||
}
|
||||
writeDNFRepo(
|
||||
"google-chrome", "Google Chrome",
|
||||
"https://dl.google.com/linux/chrome/rpm/stable/x86_64",
|
||||
"https://dl.google.com/linux/linux_signing_key.pub",
|
||||
)
|
||||
case "apt-get":
|
||||
if repoFileExists("/etc/apt/sources.list.d/google-chrome.list") {
|
||||
return
|
||||
}
|
||||
runShell(
|
||||
"curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | "+
|
||||
"sudo gpg --dearmor -o /etc/apt/keyrings/google-chrome.gpg && "+
|
||||
"echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/google-chrome.gpg] "+
|
||||
"https://dl.google.com/linux/chrome/deb/ stable main' | "+
|
||||
"sudo tee /etc/apt/sources.list.d/google-chrome.list > /dev/null && "+
|
||||
"sudo apt-get update",
|
||||
CmdOpts{},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func setupVivaldiRepo() {
|
||||
if archName != "x86_64" {
|
||||
warn("Vivaldi repo on this arch is not supported by this script — skipping")
|
||||
return
|
||||
}
|
||||
switch pkgMgr {
|
||||
case "dnf":
|
||||
if repoFileExists("/etc/yum.repos.d/vivaldi.repo") {
|
||||
return
|
||||
}
|
||||
writeDNFRepo(
|
||||
"vivaldi", "Vivaldi",
|
||||
"https://repo.vivaldi.com/archive/rpm/x86_64",
|
||||
"https://repo.vivaldi.com/archive/linux_signing_key.pub",
|
||||
)
|
||||
case "apt-get":
|
||||
if repoFileExists("/etc/apt/sources.list.d/vivaldi.list") {
|
||||
return
|
||||
}
|
||||
runShell(
|
||||
"curl -fsSL https://repo.vivaldi.com/archive/linux_signing_key.pub | "+
|
||||
"sudo gpg --dearmor -o /etc/apt/keyrings/vivaldi.gpg && "+
|
||||
"echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/vivaldi.gpg] "+
|
||||
"https://repo.vivaldi.com/archive/deb/ stable main' | "+
|
||||
"sudo tee /etc/apt/sources.list.d/vivaldi.list > /dev/null && "+
|
||||
"sudo apt-get update",
|
||||
CmdOpts{},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func setupTemurinRepo() {
|
||||
switch pkgMgr {
|
||||
case "dnf":
|
||||
if repoFileExists("/etc/yum.repos.d/adoptium.repo") {
|
||||
return
|
||||
}
|
||||
writeDNFRepo(
|
||||
"Adoptium", "Adoptium",
|
||||
"https://packages.adoptium.net/artifactory/rpm/fedora/$releasever/$basearch",
|
||||
"https://packages.adoptium.net/artifactory/api/gpg/key/public",
|
||||
)
|
||||
case "apt-get":
|
||||
if repoFileExists("/etc/apt/sources.list.d/adoptium.list") {
|
||||
return
|
||||
}
|
||||
runShell(
|
||||
"wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public | "+
|
||||
"sudo gpg --dearmor | sudo tee /etc/apt/keyrings/adoptium.gpg > /dev/null && "+
|
||||
`echo "deb [signed-by=/etc/apt/keyrings/adoptium.gpg] `+
|
||||
`https://packages.adoptium.net/artifactory/deb/ `+
|
||||
`$(awk -F= '/^VERSION_CODENAME/{print$2}' /etc/os-release) main" | `+
|
||||
"sudo tee /etc/apt/sources.list.d/adoptium.list > /dev/null && "+
|
||||
"sudo apt-get update",
|
||||
CmdOpts{},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func setupDotnetRepo() {
|
||||
// .NET is in Fedora repos directly — no extra repo needed.
|
||||
if pkgMgr != "apt-get" {
|
||||
return
|
||||
}
|
||||
if repoFileExists(
|
||||
"/etc/apt/sources.list.d/microsoft-prod.list",
|
||||
"/etc/apt/sources.list.d/dotnet.list",
|
||||
) {
|
||||
return
|
||||
}
|
||||
distroID := strings.Trim(osReleaseField("ID"), `"`)
|
||||
versionID := strings.Trim(osReleaseField("VERSION_ID"), `"`)
|
||||
debURL := fmt.Sprintf(
|
||||
"https://packages.microsoft.com/config/%s/%s/packages-microsoft-prod.deb",
|
||||
distroID, versionID,
|
||||
)
|
||||
runShell(
|
||||
fmt.Sprintf("curl -fsSL %s -o /tmp/packages-microsoft-prod.deb && "+
|
||||
"sudo dpkg -i /tmp/packages-microsoft-prod.deb && "+
|
||||
"sudo apt-get update", debURL),
|
||||
CmdOpts{},
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
func repoGroups() []repoGroup {
|
||||
mk := func(names ...string) map[string]bool {
|
||||
m := make(map[string]bool, len(names))
|
||||
for _, n := range names {
|
||||
m[n] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
return []repoGroup{
|
||||
{mk("containerd.io", "docker-buildx-plugin", "docker-ce-cli",
|
||||
"docker-ce-rootless-extras", "docker-ce", "docker-compose-plugin"), setupDockerRepo},
|
||||
{mk("gh"), setupGHRepo},
|
||||
{mk("google-chrome-stable"), setupChromeRepo},
|
||||
{mk("vivaldi-stable"), setupVivaldiRepo},
|
||||
{mk("temurin-25-jdk"), setupTemurinRepo},
|
||||
{mk("dotnet-sdk-10.0"), setupDotnetRepo},
|
||||
{mk("lazygit"), setupLazygitCoprRepo},
|
||||
}
|
||||
}
|
||||
+299
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Special packages: installed outside the regular package manager because
|
||||
// they're not in standard repos, or because they need extra setup. Linux only;
|
||||
// on macOS brew covers all of these.
|
||||
func specialPkgs() map[string]bool {
|
||||
if isMacOS {
|
||||
return map[string]bool{}
|
||||
}
|
||||
return map[string]bool{
|
||||
"github-desktop": true, "zoom": true, "obsidian": true,
|
||||
"minikube": true, "pipx": true,
|
||||
"poetry": true, "pulumi": true, "semgrep": true,
|
||||
}
|
||||
}
|
||||
|
||||
// guiSystemPkgs are skipped by default (headless mode) and included only
|
||||
// when --gui is passed.
|
||||
var guiSystemPkgs = map[string]bool{
|
||||
"github-desktop": true,
|
||||
"google-chrome-stable": true,
|
||||
"obs-studio": true,
|
||||
"obsidian": true,
|
||||
"shutter": true,
|
||||
"virt-manager": true,
|
||||
"vivaldi-stable": true,
|
||||
"webcamoid": true,
|
||||
"wireshark": true,
|
||||
"zoom": true,
|
||||
}
|
||||
|
||||
func isSpecialPkgInstalled(pkg string) bool {
|
||||
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 "pulumi":
|
||||
return exists("/opt/pulumi/pulumi") || hasCmd("pulumi")
|
||||
case "pipx":
|
||||
return hasCmd("pipx")
|
||||
case "poetry":
|
||||
return hasCmd("poetry")
|
||||
case "semgrep":
|
||||
return hasCmd("semgrep")
|
||||
}
|
||||
return isSystemPkgInstalled(pkg)
|
||||
}
|
||||
|
||||
// ── special installers ────────────────────────────────────────────────────
|
||||
|
||||
type ghAsset struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
type ghRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []ghAsset `json:"assets"`
|
||||
}
|
||||
|
||||
func installGitHubDesktop(tmp string) {
|
||||
var rel ghRelease
|
||||
if !fetchJSON("https://api.github.com/repos/shiftkey/desktop/releases/latest", &rel) {
|
||||
return
|
||||
}
|
||||
var suffix string
|
||||
switch pkgMgr {
|
||||
case "dnf":
|
||||
suffix = ".rpm"
|
||||
case "apt-get":
|
||||
suffix = ".deb"
|
||||
default:
|
||||
warn("github-desktop has no installer for this package manager — skipping")
|
||||
return
|
||||
}
|
||||
hostTokens := archTokens[archName]
|
||||
excludeTokens := archTokens[otherArch()]
|
||||
|
||||
matches := func(name string) bool {
|
||||
n := strings.ToLower(name)
|
||||
if !strings.HasSuffix(n, suffix) {
|
||||
return false
|
||||
}
|
||||
matched := false
|
||||
for _, t := range hostTokens {
|
||||
if strings.Contains(n, t) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return false
|
||||
}
|
||||
for _, t := range excludeTokens {
|
||||
inHost := false
|
||||
for _, h := range hostTokens {
|
||||
if h == t {
|
||||
inHost = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inHost && strings.Contains(n, t) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var asset *ghAsset
|
||||
for i := range rel.Assets {
|
||||
if matches(rel.Assets[i].Name) {
|
||||
asset = &rel.Assets[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if asset == nil {
|
||||
errLog(fmt.Sprintf("No GitHub Desktop %s asset found for %s", suffix, archName))
|
||||
return
|
||||
}
|
||||
dest := filepath.Join(tmp, asset.Name)
|
||||
if !download(asset.BrowserDownloadURL, dest) {
|
||||
return
|
||||
}
|
||||
installer := pkgMgr
|
||||
if pkgMgr == "apt-get" {
|
||||
installer = "apt-get"
|
||||
}
|
||||
runCmd([]string{installer, "install", "-y", dest}, CmdOpts{AsSudo: true})
|
||||
}
|
||||
|
||||
func installZoom(tmp string) {
|
||||
if archName != "x86_64" {
|
||||
warn("Zoom has no aarch64 Linux client — skipping")
|
||||
return
|
||||
}
|
||||
switch pkgMgr {
|
||||
case "dnf":
|
||||
dest := filepath.Join(tmp, "zoom.rpm")
|
||||
if !download("https://zoom.us/client/latest/zoom_x86_64.rpm", dest) {
|
||||
return
|
||||
}
|
||||
runCmd([]string{"dnf", "install", "-y", dest}, CmdOpts{AsSudo: true})
|
||||
case "apt-get":
|
||||
dest := filepath.Join(tmp, "zoom.deb")
|
||||
if !download("https://zoom.us/client/latest/zoom_amd64.deb", dest) {
|
||||
return
|
||||
}
|
||||
runCmd([]string{"apt-get", "install", "-y", dest}, CmdOpts{AsSudo: true})
|
||||
default:
|
||||
warn("zoom: no installer for this distro — skipping")
|
||||
}
|
||||
}
|
||||
|
||||
func installObsidian(tmp string) {
|
||||
var rel ghRelease
|
||||
if !fetchJSON("https://api.github.com/repos/obsidianmd/obsidian-releases/releases/latest", &rel) {
|
||||
return
|
||||
}
|
||||
hostTokens := archTokens[archName]
|
||||
otherTokens := archTokens[otherArch()]
|
||||
|
||||
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
|
||||
}
|
||||
// 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
|
||||
}
|
||||
for _, t := range otherTokens {
|
||||
inHost := false
|
||||
for _, h := range hostTokens {
|
||||
if h == t {
|
||||
inHost = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inHost && strings.Contains(n, t) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var asset *ghAsset
|
||||
for i := range rel.Assets {
|
||||
if matches(rel.Assets[i].Name) {
|
||||
asset = &rel.Assets[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if asset == nil {
|
||||
errLog(fmt.Sprintf("No Obsidian AppImage found for %s", archName))
|
||||
return
|
||||
}
|
||||
dest := filepath.Join(tmp, asset.Name)
|
||||
if !download(asset.BrowserDownloadURL, dest) {
|
||||
return
|
||||
}
|
||||
installPath := "/usr/local/bin/obsidian"
|
||||
runCmd([]string{"cp", dest, installPath}, CmdOpts{AsSudo: true})
|
||||
runCmd([]string{"chmod", "755", installPath}, CmdOpts{AsSudo: true})
|
||||
fmt.Printf(" Obsidian AppImage installed at %s\n", installPath)
|
||||
}
|
||||
|
||||
func installMinikube(tmp string) {
|
||||
archTok := archMinikube[archName]
|
||||
baseURL := fmt.Sprintf("https://storage.googleapis.com/minikube/releases/latest/minikube-linux-%s", archTok)
|
||||
dest := filepath.Join(tmp, "minikube")
|
||||
if !download(baseURL, dest) {
|
||||
return
|
||||
}
|
||||
fmt.Println(" Fetching SHA256 ...")
|
||||
shaText := fetchText(baseURL + ".sha256")
|
||||
if shaText == "" {
|
||||
return
|
||||
}
|
||||
expected := strings.Fields(shaText)[0]
|
||||
actual, err := sha256Of(dest)
|
||||
if err != nil {
|
||||
errLog(fmt.Sprintf("minikube hash failed: %v", err))
|
||||
return
|
||||
}
|
||||
if actual != expected {
|
||||
errLog(fmt.Sprintf("minikube SHA256 mismatch: expected %s, got %s", expected, actual))
|
||||
return
|
||||
}
|
||||
fmt.Println(" SHA256 OK")
|
||||
installPath := "/usr/local/bin/minikube"
|
||||
runCmd([]string{"cp", dest, installPath}, CmdOpts{AsSudo: true})
|
||||
runCmd([]string{"chmod", "755", installPath}, CmdOpts{AsSudo: true})
|
||||
fmt.Printf(" minikube installed to %s\n", installPath)
|
||||
}
|
||||
|
||||
func installPulumi(tmp string) {
|
||||
version := fetchText("https://www.pulumi.com/latest-version")
|
||||
if version == "" {
|
||||
errLog("Could not determine latest Pulumi version")
|
||||
return
|
||||
}
|
||||
osTok := osGo[osName]
|
||||
archTok := archPulumi[archName]
|
||||
tarball := fmt.Sprintf("pulumi-v%s-%s-%s.tar.gz", version, osTok, archTok)
|
||||
base := fmt.Sprintf("https://github.com/pulumi/pulumi/releases/download/v%s", version)
|
||||
dest := filepath.Join(tmp, tarball)
|
||||
if !download(base+"/"+tarball, dest) {
|
||||
return
|
||||
}
|
||||
|
||||
checksums := fetchText(fmt.Sprintf("%s/pulumi-%s-checksums.txt", base, version))
|
||||
if checksums == "" {
|
||||
errLog("Could not fetch Pulumi checksums")
|
||||
return
|
||||
}
|
||||
var expected string
|
||||
for _, line := range strings.Split(checksums, "\n") {
|
||||
if strings.HasSuffix(strings.TrimSpace(line), tarball) {
|
||||
expected = strings.Fields(line)[0]
|
||||
break
|
||||
}
|
||||
}
|
||||
if expected == "" {
|
||||
errLog(fmt.Sprintf("No checksum entry for %s", tarball))
|
||||
return
|
||||
}
|
||||
actual, err := sha256Of(dest)
|
||||
if err != nil {
|
||||
errLog(fmt.Sprintf("Pulumi hash failed: %v", err))
|
||||
return
|
||||
}
|
||||
if actual != expected {
|
||||
errLog(fmt.Sprintf("Pulumi SHA256 mismatch: expected %s, got %s", expected, actual))
|
||||
return
|
||||
}
|
||||
fmt.Println(" SHA256 OK")
|
||||
|
||||
installDir := "/opt/pulumi"
|
||||
fmt.Println(" Extracting Pulumi to /opt ...")
|
||||
runCmd([]string{"mkdir", "-p", "/opt"}, CmdOpts{AsSudo: true})
|
||||
runCmd([]string{"rm", "-rf", installDir}, CmdOpts{AsSudo: true})
|
||||
runCmd([]string{"tar", "-C", "/opt", "-xzf", dest}, CmdOpts{AsSudo: true})
|
||||
|
||||
appendProfileLine("pulumi", fmt.Sprintf(`export PATH="$PATH:%s"`, installDir))
|
||||
fmt.Printf(" Pulumi %s installed to %s\n", version, installDir)
|
||||
}
|
||||
|
||||
func installPipx(_ string) {
|
||||
if !hasCmd("python3") {
|
||||
errLog("Python 3 is not installed — cannot install pipx")
|
||||
return
|
||||
}
|
||||
pkgInstall("pipx")
|
||||
if hasCmd("pipx") {
|
||||
runCmd([]string{"pipx", "ensurepath"}, CmdOpts{})
|
||||
} else {
|
||||
errLog("pipx command not found after install")
|
||||
}
|
||||
}
|
||||
|
||||
func installPoetry(_ string) {
|
||||
if !hasCmd("pipx") {
|
||||
errLog("pipx is not installed — cannot install poetry")
|
||||
return
|
||||
}
|
||||
runCmd([]string{"pipx", "install", "poetry"}, CmdOpts{})
|
||||
}
|
||||
|
||||
func 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":
|
||||
installGitHubDesktop(tmp)
|
||||
case "zoom":
|
||||
installZoom(tmp)
|
||||
case "obsidian":
|
||||
installObsidian(tmp)
|
||||
case "minikube":
|
||||
installMinikube(tmp)
|
||||
case "pulumi":
|
||||
installPulumi(tmp)
|
||||
case "pipx":
|
||||
installPipx(tmp)
|
||||
case "poetry":
|
||||
installPoetry(tmp)
|
||||
case "semgrep":
|
||||
installSemgrep(tmp)
|
||||
}
|
||||
}
|
||||
|
||||
// pkgInstall invokes the host package manager to install a single name.
|
||||
// Centralized so pacman's "--noconfirm" doesn't leak everywhere.
|
||||
func pkgInstall(pkg string) CmdResult {
|
||||
switch pkgMgr {
|
||||
case "pacman":
|
||||
return runCmd([]string{"pacman", "-S", "--noconfirm", "--needed", pkg}, CmdOpts{AsSudo: true})
|
||||
case "brew":
|
||||
if brewCasks[pkg] {
|
||||
return runCmd([]string{"brew", "install", "--cask", pkg}, CmdOpts{})
|
||||
}
|
||||
return runCmd([]string{"brew", "install", pkg}, CmdOpts{})
|
||||
default:
|
||||
return runCmd([]string{pkgMgr, "install", "-y", pkg}, CmdOpts{AsSudo: true})
|
||||
}
|
||||
}
|
||||
|
||||
// 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" {
|
||||
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
|
||||
}
|
||||
|
||||
seenRepos := map[int]bool{}
|
||||
groups := repoGroups()
|
||||
for _, pkg := range regular {
|
||||
for i, g := range groups {
|
||||
if g.members[pkg] && !seenRepos[i] {
|
||||
fmt.Printf(" [REPO] Setting up repository for %s ...\n", pkg)
|
||||
g.setup()
|
||||
seenRepos[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
failed := pkgInstallMany(regular)
|
||||
for _, p := range failed {
|
||||
taskPrintf(" [WARN] System package failed to install: %s\n", p)
|
||||
}
|
||||
|
||||
if len(special) > 0 {
|
||||
tmp, err := os.MkdirTemp("", "bootstrap-special-")
|
||||
if err != nil {
|
||||
errLog(fmt.Sprintf("could not create temp dir for special packages: %v", err))
|
||||
return
|
||||
}
|
||||
defer 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,
|
||||
// idempotently. On Linux uses /etc/profile.d/<name>.sh; macOS uses /etc/zprofile.
|
||||
func appendProfileLine(scriptName, line string) {
|
||||
target := fmt.Sprintf("/etc/profile.d/%s.sh", scriptName)
|
||||
if isMacOS {
|
||||
target = "/etc/zprofile"
|
||||
}
|
||||
cmd := fmt.Sprintf("grep -qxF %q %s 2>/dev/null || echo %q >> %s", line, target, line, target)
|
||||
runCmd([]string{"bash", "-c", cmd}, CmdOpts{AsSudo: true})
|
||||
}
|
||||
+530
@@ -0,0 +1,530 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Goroutine-local task output routing.
|
||||
//
|
||||
// Why: install handlers in custom.go and post.go are deeply nested calls
|
||||
// that use fmt.Printf/Println directly and pass CmdOpts to runCmd. To route
|
||||
// their output into a per-task buffer (so parallel workers don't interleave
|
||||
// on os.Stdout), we'd otherwise need to thread an io.Writer through every
|
||||
// signature — ~30 call sites of churn including tests.
|
||||
//
|
||||
// Instead we keep a sync.Map keyed by goroutine id. The parallel orchestrator
|
||||
// associates a taskOutput with its worker goroutine before invoking the
|
||||
// handler; helpers below check the map and route output to the active task
|
||||
// when present, falling back to direct stdout otherwise. Sequential callers
|
||||
// observe no behavior change.
|
||||
//
|
||||
// goid() uses runtime.Stack — a small hack, but stable and idiomatic for
|
||||
// goroutine-local state where context.Context threading would dwarf the
|
||||
// surrounding work.
|
||||
|
||||
var activeTaskByGoroutine sync.Map // map[uint64]*taskOutput
|
||||
|
||||
func goid() uint64 {
|
||||
var buf [64]byte
|
||||
n := runtime.Stack(buf[:], false)
|
||||
s := string(buf[:n])
|
||||
s = strings.TrimPrefix(s, "goroutine ")
|
||||
end := strings.IndexByte(s, ' ')
|
||||
if end < 0 {
|
||||
return 0
|
||||
}
|
||||
id, _ := strconv.ParseUint(s[:end], 10, 64)
|
||||
return id
|
||||
}
|
||||
|
||||
// withTaskOutput pins tOut to the current goroutine for the duration of fn,
|
||||
// then unpins. Re-entrant calls overwrite the previous binding and restore
|
||||
// it on return. A nil tOut is treated as "no binding" (sequential mode).
|
||||
func withTaskOutput(tOut *taskOutput, fn func()) {
|
||||
if tOut == nil {
|
||||
fn()
|
||||
return
|
||||
}
|
||||
id := goid()
|
||||
prev, hadPrev := activeTaskByGoroutine.Load(id)
|
||||
activeTaskByGoroutine.Store(id, tOut)
|
||||
defer func() {
|
||||
if hadPrev {
|
||||
activeTaskByGoroutine.Store(id, prev)
|
||||
} else {
|
||||
activeTaskByGoroutine.Delete(id)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
|
||||
// currentTask returns the taskOutput pinned to the current goroutine, or
|
||||
// nil if none. Cheap enough to call per print (~microseconds).
|
||||
func currentTask() *taskOutput {
|
||||
v, ok := activeTaskByGoroutine.Load(goid())
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return v.(*taskOutput)
|
||||
}
|
||||
|
||||
// taskPrintf routes via the active task (if any) or directly to stdout.
|
||||
func taskPrintf(format string, args ...any) {
|
||||
if t := currentTask(); t != nil {
|
||||
t.Printf(format, args...)
|
||||
return
|
||||
}
|
||||
fmt.Printf(format, args...)
|
||||
}
|
||||
|
||||
// taskPrintln routes via the active task (if any) or directly to stdout.
|
||||
func taskPrintln(args ...any) {
|
||||
if t := currentTask(); t != nil {
|
||||
t.Println(args...)
|
||||
return
|
||||
}
|
||||
fmt.Println(args...)
|
||||
}
|
||||
|
||||
// taskOut returns the io.Writer that runCmd / runShell should target via
|
||||
// CmdOpts.Out for the active task. Returns nil when there is no active task,
|
||||
// which preserves runCmd's default streamed-to-stdout behavior.
|
||||
func taskOut() io.Writer {
|
||||
if t := currentTask(); t != nil {
|
||||
return t.Writer()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ok github.com/JMR-dev/bootstrap_dev_env 20.206s
|
||||
? github.com/JMR-dev/bootstrap_dev_env/ci [no test files]
|
||||
@@ -0,0 +1,16 @@
|
||||
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) {
|
||||
issueLogWriter = io.Discard
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
Reference in New Issue
Block a user