Compare commits

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-26 13:35:05 -05:00
JMR-dev 4d99bba06f added repo bootstrap tool 2026-05-26 13:21:31 -05:00
Jason Ross ee95457e7d Merge pull request #18 from JMR-dev/fix-add-unit-testing
added unit testing
2026-05-26 09:36:50 -05:00
JMR-dev 669565bb87 added unit testing 2026-05-26 09:36:05 -05:00
Jason Ross 39c23595ce Merge pull request #17 from JMR-dev/feat-add-AI-cli-packages
updates to add AI packages and an opt out of AI flag
2026-05-25 12:11:32 -05:00
JMR-dev 8f11c8d68b updates to add AI packages and an opt out of AI flag 2026-05-25 12:07:14 -05:00
Jason Ross d9e905ac20 Merge pull request #16 from JMR-dev/fix-gha-workflow
Workflow fix to git sha
2026-05-23 17:58:43 -05:00
JMR-dev 14bf9f3e56 Workflow fix to git sha 2026-05-23 17:57:50 -05:00
Jason Ross f6fdb3f6ae Merge pull request #15 from JMR-dev/claude/github-workflow-permissions-token-pNozt
ci: pass GITHUB_TOKEN explicitly to checkout and release steps
2026-05-22 17:55:29 -05:00
Claude d7d87acb7e ci: pass GITHUB_TOKEN explicitly to checkout and release steps
Add explicit token inputs to the checkout and softprops/action-gh-release
steps so the GitHub token is wired through instead of relying on implicit
defaults. The workflow-level contents: write permission already scopes
the token correctly for tag/release creation.
2026-05-22 22:07:50 +00:00
Jason Ross 817a0ad8a4 Merge pull request #14 from JMR-dev/claude/port-tool-golang-ziaMN
port: rewrite bootstrap tool in Go
2026-05-22 17:00:30 -05:00
Claude 03a90fffe6 ci: add release workflow that builds binaries and publishes SHA256SUMS
Pushing a tag matching v* (or running the workflow manually with an
existing tag) cross-compiles the four supported targets via
`make build-all`, generates a SHA256SUMS file, and publishes a GitHub
release with all binaries attached.
2026-05-22 21:55:15 +00:00
Claude 658460fe4c port: rewrite bootstrap tool in Go
Replace the Python bootstrap script with a Go implementation that
cross-compiles to native binaries for Linux and macOS on x86_64 and
aarch64. The Go port preserves all sections of the original (system
packages, optional Flatpak GUI apps, custom downloads, and the macOS
firecracker VM bridge) and adds first-class pacman support so the tool
works on Arch-family distros alongside Debian, RHEL, and macOS.

A Makefile produces a host binary via `make build` and the full
four-target matrix under dist/ via `make build-all`.
2026-05-22 21:48:57 +00:00
Jason Ross c2ca21b444 Merge pull request #13 from JMR-dev/claude/pip-decimal-import-error-eVPBv
fix: detect and repair broken _decimal C extension before pip install
2026-05-22 14:21:35 -05:00
Claude 26bd80c97d fix: detect and repair broken _decimal C extension before pip install
When python3-full is not installed on Debian/Ubuntu (common with Python
3.13), the _decimal C extension fails to import, crashing any pip
invocation with RuntimeError.  Add _python3_decimal_ok() to probe for
this, and _fix_python3_decimal() to install python3-full (apt) or
python3-libs (dnf).  _install_pip() now calls these before attempting
any ensurepip/pip operations so the error is fixed automatically rather
than producing an unrecoverable crash.

https://claude.ai/code/session_01Lrg3UV7AJN9KRXyTTWGgZi
2026-05-22 19:20:51 +00:00
Jason Ross b3ad702da0 Merge pull request #12 from JMR-dev/claude/bootstrap-infinite-loop-arm64-VU64t
fix: cap pip detection subprocess with a 10s timeout
2026-05-22 14:14:53 -05:00
Claude 8a0e3ee9e4 fix: add timeouts to every subprocess invocation
`run()` and `shell()` now default to a 30-minute cap (generous enough for
heavy apt/brew installs and large downloads, bounded enough to catch a
true hang). Callers can override per-call.

All remaining direct subprocess.run sites have explicit timeouts sized to
the work they do: short caps for read-only probes (rpm, dpkg, brew list,
flatpak info, gh auth status, xcode-select -p, sysctl, VBoxManage), a
60-minute cap for the pyenv Python compile, a 15-minute cap for the
interactive `gh auth login` browser flow, and ConnectTimeout+30s for the
VM SSH helper. A small `_probe()` wrapper centralizes the probe pattern.

On timeout the bootstrap now warns and either fails the check gracefully
or returns rc=124, rather than blocking indefinitely.

https://claude.ai/code/session_01447pnRM4ogyxs5tVCUNDZW
2026-05-22 19:11:04 +00:00
Claude 4525cbc219 fix: cap pip detection subprocess with a 10s timeout
A hung `python3 -m pip --version` inside `_pip_installed()` could block
the bootstrap indefinitely, since `subprocess.run` was called without a
timeout. Treat a timeout (or a missing python3) as "pip not installed"
so the bootstrap proceeds to install it rather than hanging.

https://claude.ai/code/session_01447pnRM4ogyxs5tVCUNDZW
2026-05-22 19:05:28 +00:00
Jason Ross 1c011f4a7d Merge pull request #11 from JMR-dev/claude/script-idempotency-review-CBYwG
Make _clone_nvim_config idempotent
2026-05-22 13:58:29 -05:00
Claude a4b855b796 Rename existing nvim config to nvim-N instead of skipping
On re-runs, ~/.config/nvim is moved aside to ~/.config/nvim-1
(or nvim-2, nvim-3, ... — first free number) and a fresh clone is
created. End-of-run notices report the backup path so it's visible
after the rest of the bootstrap output scrolls by.
2026-05-22 18:55:44 +00:00
Claude cedaf4ce04 Make _clone_nvim_config idempotent
Previously rmtree'd ~/.config/nvim on every full bootstrap run, destroying
any user edits, plugin state, and undo history. Now skips when the
directory is already a clone of the expected repo, and refuses to touch
unfamiliar contents (different remote, or non-git directory).
2026-05-22 18:51:30 +00:00
Jason Ross 92a582efa7 Merge pull request #10 from JMR-dev/claude/neovim-detection-debian-arm64-FqnpH
Fix Neovim not found after install by symlinking /usr/local/bin/nvim
2026-05-22 13:47:09 -05:00
51 changed files with 15182 additions and 2560 deletions
+82
View File
@@ -0,0 +1,82 @@
name: Integration Tests
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
BOOTSTRAP_CI: 'true'
on:
push:
branches:
- main
- dev
pull_request:
branches:
- main
- dev
jobs:
test-linux:
name: Linux Integration - ${{ matrix.os }}
runs-on: ubuntu-latest
strategy:
matrix:
os: [debian, arch, fedora, ubuntu]
fail-fast: false
steps:
- name: Checkout Code
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.25.x'
- name: Run Dagger Integration Pipeline
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
go run ci/main.go --os ${{ matrix.os }}
test-mac:
name: macOS Integration
runs-on: macos-latest
steps:
- name: Checkout Code
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.25.x'
- name: Build bootstrap binary
run: |
go build -o bootstrap_environment .
- name: Pre-create ~/.pyenv directory
run: |
mkdir -p ~/.pyenv
- name: Run bootstrap tool
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo y | ./bootstrap_environment --gui
conclusion:
name: Final Status Check
needs: [test-linux, test-mac]
runs-on: ubuntu-latest
if: always()
steps:
- name: Check dependency jobs
run: |
# Check the results of matrix and mac jobs
# GHA will fail the workflow if this step exits with error
if [ "${{ needs.test-linux.result }}" != "success" ] || [ "${{ needs.test-mac.result }}" != "success" ]; then
echo "One or more integration checks failed!"
exit 1
fi
echo "All integration checks passed!"
+65
View File
@@ -0,0 +1,65 @@
name: macOS Manual Integration
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
BOOTSTRAP_CI: 'true'
on:
workflow_dispatch:
inputs:
branch:
description: 'Branch to run against'
required: true
default: 'main'
type: choice
options:
- main
- dev
- fix-add-unit-testing
workflow_definition:
description: 'Workflow Run Configuration'
required: true
default: 'custom-only'
type: choice
options:
- 'custom-only'
- 'system-only'
- 'full-setup'
jobs:
test-mac-manual:
name: macOS Manual Run
runs-on: macos-latest
steps:
- name: Checkout Code
uses: actions/checkout@v6
with:
ref: ${{ inputs.branch }}
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.25.x'
- name: Build bootstrap binary
run: |
go build -o bootstrap_environment .
- name: Pre-create ~/.pyenv directory
run: |
mkdir -p ~/.pyenv
- name: Run bootstrap tool (custom-only)
if: ${{ inputs.workflow_definition == 'custom-only' }}
run: |
echo y | ./bootstrap_environment --only custom --no-vm --no-ai
- name: Run bootstrap tool (system-only)
if: ${{ inputs.workflow_definition == 'system-only' }}
run: |
echo y | ./bootstrap_environment --only system --no-vm --no-ai
- name: Run bootstrap tool (full-setup)
if: ${{ inputs.workflow_definition == 'full-setup' }}
run: |
echo y | ./bootstrap_environment --no-vm --no-ai
+130
View File
@@ -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
+30
View File
@@ -0,0 +1,30 @@
name: Unit Tests
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
on:
pull_request:
branches:
- main
- dev
jobs:
unit-test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.25.x'
- name: Run Unit Tests
run: go test -v ./...
- name: Vet
run: go vet ./...
+4 -1
View File
@@ -1 +1,4 @@
__pycache__/
dist/
bootstrap_dev_env
bootstrap_environment
bootstrap_run.log
+54
View File
@@ -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)
+101 -6
View File
@@ -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, Ubuntu (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
View File
@@ -0,0 +1,533 @@
package main
import (
"os"
"strings"
"sync"
"testing"
"time"
)
// TestPkgInstallManyBatchesDnf verifies a single batched dnf call rather
// than one per package.
func TestPkgInstallManyBatchesDnf(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
var calls [][]string
runCmd = func(argv []string, _ CmdOpts) CmdResult {
calls = append(calls, append([]string(nil), argv...))
return CmdResult{ExitCode: 0}
}
failed := pkgInstallMany([]string{"git", "curl", "vim"})
if len(failed) != 0 {
t.Errorf("expected no failures, got %v", failed)
}
if len(calls) != 1 {
t.Fatalf("expected exactly 1 batched call, got %d: %v", len(calls), calls)
}
got := strings.Join(calls[0], " ")
if !strings.HasPrefix(got, "dnf install -y") {
t.Errorf("expected 'dnf install -y …' prefix, got: %q", got)
}
for _, pkg := range []string{"git", "curl", "vim"} {
if !strings.Contains(got, pkg) {
t.Errorf("expected %s in batched call, got: %q", pkg, got)
}
}
}
// TestPkgInstallManyBatchesApt verifies the same for apt-get.
func TestPkgInstallManyBatchesApt(t *testing.T) {
defer resetMocks()
pkgMgr = "apt-get"
var calls [][]string
runCmd = func(argv []string, _ CmdOpts) CmdResult {
calls = append(calls, append([]string(nil), argv...))
return CmdResult{ExitCode: 0}
}
pkgInstallMany([]string{"a", "b", "c"})
if len(calls) != 1 {
t.Fatalf("expected 1 batched call, got %d", len(calls))
}
if calls[0][0] != "apt-get" || calls[0][1] != "install" || calls[0][2] != "-y" {
t.Errorf("expected 'apt-get install -y' prefix, got: %v", calls[0])
}
}
// TestPkgInstallManyBatchesPacman verifies pacman flags.
func TestPkgInstallManyBatchesPacman(t *testing.T) {
defer resetMocks()
pkgMgr = "pacman"
var calls [][]string
runCmd = func(argv []string, _ CmdOpts) CmdResult {
calls = append(calls, append([]string(nil), argv...))
return CmdResult{ExitCode: 0}
}
pkgInstallMany([]string{"a", "b"})
if len(calls) != 1 || calls[0][0] != "pacman" {
t.Fatalf("expected single pacman call, got %v", calls)
}
joined := strings.Join(calls[0], " ")
if !strings.Contains(joined, "--noconfirm") || !strings.Contains(joined, "--needed") {
t.Errorf("expected --noconfirm --needed in pacman call, got: %q", joined)
}
}
// TestPkgInstallManyFallback verifies that a failed batch retries per-package
// and returns the failures it identifies on the per-package retry.
func TestPkgInstallManyFallback(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
calls := 0
runCmd = func(argv []string, _ CmdOpts) CmdResult {
calls++
// Fail the first (batched) call, succeed individual retries except for "bad".
if calls == 1 {
return CmdResult{ExitCode: 1}
}
for _, a := range argv {
if a == "bad" {
return CmdResult{ExitCode: 1}
}
}
return CmdResult{ExitCode: 0}
}
failed := pkgInstallMany([]string{"good1", "good2", "bad"})
if len(failed) != 1 || failed[0] != "bad" {
t.Errorf("expected only 'bad' to fail, got %v", failed)
}
// 1 batch + 3 per-package retries = 4 calls.
if calls != 4 {
t.Errorf("expected 4 total calls (1 batch + 3 retries), got %d", calls)
}
}
// TestPkgInstallManyEmpty: no-op on empty input, no calls.
func TestPkgInstallManyEmpty(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
called := false
runCmd = func(argv []string, _ CmdOpts) CmdResult {
called = true
return CmdResult{ExitCode: 0}
}
failed := pkgInstallMany(nil)
if len(failed) != 0 {
t.Errorf("expected no failures, got %v", failed)
}
if called {
t.Error("expected no runCmd call for empty input")
}
}
// TestPkgInstallManyBrew verifies that brew is batched into a single
// `brew install f1 f2 …` call (formulas only — no casks in this test).
// Parallel brew calls would deadlock on shared transitive-dep locks
// (cmake, ninja, libsodium, …), so we deliberately batch and serialize.
func TestPkgInstallManyBrew(t *testing.T) {
defer resetMocks()
pkgMgr = "brew"
var mu sync.Mutex
var calls [][]string
runCmd = func(argv []string, _ CmdOpts) CmdResult {
mu.Lock()
calls = append(calls, append([]string(nil), argv...))
mu.Unlock()
return CmdResult{ExitCode: 0}
}
if failed := pkgInstallMany([]string{"git", "vim", "curl"}); len(failed) != 0 {
t.Errorf("expected no failures, got %v", failed)
}
if len(calls) != 1 {
t.Fatalf("expected exactly 1 batched brew call, got %d: %v", len(calls), calls)
}
got := strings.Join(calls[0], " ")
if got != "brew install git vim curl" {
t.Errorf("expected 'brew install git vim curl', got %q", got)
}
}
// TestPkgInstallManyBrewSplitCasks verifies that casks and formulas are
// emitted in separate calls (because --cask is mutually exclusive with
// formula installs in one invocation).
func TestPkgInstallManyBrewSplitCasks(t *testing.T) {
defer resetMocks()
pkgMgr = "brew"
var calls [][]string
runCmd = func(argv []string, _ CmdOpts) CmdResult {
calls = append(calls, append([]string(nil), argv...))
return CmdResult{ExitCode: 0}
}
// "docker" is in brewCasks; the rest are formulas.
pkgInstallMany([]string{"git", "docker", "vim"})
if len(calls) != 2 {
t.Fatalf("expected 2 calls (1 formula batch + 1 cask batch), got %d: %v", len(calls), calls)
}
formula := strings.Join(calls[0], " ")
cask := strings.Join(calls[1], " ")
if formula != "brew install git vim" {
t.Errorf("expected 'brew install git vim', got %q", formula)
}
if cask != "brew install --cask docker" {
t.Errorf("expected 'brew install --cask docker', got %q", cask)
}
}
// TestPkgInstallManyBrewFallback: batched formula install fails; we retry
// per-package and identify the broken one.
func TestPkgInstallManyBrewFallback(t *testing.T) {
defer resetMocks()
pkgMgr = "brew"
calls := 0
runCmd = func(argv []string, _ CmdOpts) CmdResult {
calls++
// First call is the batch — fail it.
if calls == 1 {
return CmdResult{ExitCode: 1}
}
// Per-package retries: only "broken" fails.
if argv[len(argv)-1] == "broken" {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
failed := pkgInstallMany([]string{"git", "broken", "curl"})
if len(failed) != 1 || failed[0] != "broken" {
t.Errorf("expected only 'broken' to fail, got %v", failed)
}
// 1 batch + 3 per-package retries = 4 calls.
if calls != 4 {
t.Errorf("expected 4 total calls, got %d", calls)
}
}
// TestInstallFlatpakBatched: a single batched flatpak install for the
// happy path.
func TestInstallFlatpakBatched(t *testing.T) {
defer resetMocks()
hasCmd = func(name string) bool { return name == "flatpak" }
var calls [][]string
runCmd = func(argv []string, _ CmdOpts) CmdResult {
calls = append(calls, append([]string(nil), argv...))
return CmdResult{ExitCode: 0}
}
installFlatpakPackages([]string{"a.app", "b.app", "c.app"})
// Expect: remote-add (1) + single batched install (1) = 2 calls.
if len(calls) != 2 {
t.Fatalf("expected 2 calls (remote-add + batched install), got %d: %v", len(calls), calls)
}
if calls[1][1] != "install" {
t.Errorf("expected install as second call, got %v", calls[1])
}
for _, app := range []string{"a.app", "b.app", "c.app"} {
found := false
for _, a := range calls[1] {
if a == app {
found = true
break
}
}
if !found {
t.Errorf("expected %s in batched call, got %v", app, calls[1])
}
}
}
// TestInstallFlatpakBatchFallback: failed batch retries per-package.
func TestInstallFlatpakBatchFallback(t *testing.T) {
defer resetMocks()
hasCmd = func(name string) bool { return name == "flatpak" }
calls := 0
runCmd = func(argv []string, _ CmdOpts) CmdResult {
calls++
// First call: remote-add (always OK)
// Second call: batched install (fail)
// Following calls: per-package retries (OK)
if calls == 2 {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
installFlatpakPackages([]string{"a.app", "b.app"})
// 1 (remote-add) + 1 (failed batch) + 2 (per-package retries) = 4 calls.
if calls != 4 {
t.Errorf("expected 4 calls (remote-add + batch + 2 retries), got %d", calls)
}
}
// TestCheckSystemPackagesParallelOrdering: ordering preserved despite
// concurrent probes.
func TestCheckSystemPackagesParallelOrdering(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
// odd-indexed packages "installed", even-indexed "not installed"
probe = func(argv []string, _ time.Duration) (CmdResult, bool) {
pkg := argv[len(argv)-1]
// pkg-0..pkg-7
idx := pkg[len(pkg)-1] - '0'
if idx%2 == 1 {
return CmdResult{ExitCode: 0}, true // installed
}
return CmdResult{ExitCode: 1}, true // not installed
}
names := []string{"pkg-0", "pkg-1", "pkg-2", "pkg-3", "pkg-4", "pkg-5", "pkg-6", "pkg-7"}
res := checkSystemPackages(names)
wantToInstall := []string{"pkg-0", "pkg-2", "pkg-4", "pkg-6"}
wantAlready := []string{"pkg-1", "pkg-3", "pkg-5", "pkg-7"}
if !equalStringSlices(res.toInstallRegular, wantToInstall) {
t.Errorf("toInstall: want %v, got %v", wantToInstall, res.toInstallRegular)
}
if !equalStringSlices(res.alreadyInstalled, wantAlready) {
t.Errorf("alreadyInstalled: want %v, got %v", wantAlready, res.alreadyInstalled)
}
}
func TestCheckCustomPackagesParallel(t *testing.T) {
defer resetMocks()
// pkg with InstallPath /tmp/foo-N; "installed" iff N is odd.
osStat = func(name string) (os.FileInfo, error) {
// Map: name like /tmp/foo-1 → installed; /tmp/foo-0 → not.
idx := name[len(name)-1] - '0'
if idx%2 == 1 {
return nil, nil
}
return nil, os.ErrNotExist
}
pkgs := []*CustomPackage{
{Name: "p0", InstallPath: "/tmp/foo-0"},
{Name: "p1", InstallPath: "/tmp/foo-1"},
{Name: "p2", InstallPath: "/tmp/foo-2"},
{Name: "p3", InstallPath: "/tmp/foo-3"},
}
res := checkCustomPackages(pkgs)
if len(res.toInstall) != 2 || res.toInstall[0].Name != "p0" || res.toInstall[1].Name != "p2" {
t.Errorf("toInstall: want p0,p2 in order, got %v", names(res.toInstall))
}
if len(res.alreadyInstalled) != 2 || res.alreadyInstalled[0].pkg.Name != "p1" || res.alreadyInstalled[1].pkg.Name != "p3" {
t.Errorf("already: want p1,p3 in order, got %v", customNames(res.alreadyInstalled))
}
}
func TestInstallNpmToolsBatchSinglePnpmCall(t *testing.T) {
defer resetMocks()
// Pretend ~/.nvm exists so NVM check passes.
osStat = func(name string) (os.FileInfo, error) {
if strings.HasSuffix(name, ".nvm") {
return nil, nil
}
return nil, os.ErrNotExist
}
var shellCalls []string
runShell = func(cmd string, _ CmdOpts) CmdResult {
shellCalls = append(shellCalls, cmd)
return CmdResult{ExitCode: 0}
}
pkgs := []*CustomPackage{
{Name: "claude"},
{Name: "codex"},
{Name: "copilot"},
}
installNpmToolsBatch(pkgs)
// Expect exactly one pnpm add -g call containing all three packages.
addCalls := 0
for _, c := range shellCalls {
if strings.Contains(c, "pnpm add -g") {
addCalls++
if !strings.Contains(c, "@anthropic-ai/claude-code") ||
!strings.Contains(c, "@openai/codex") ||
!strings.Contains(c, "@github/copilot") {
t.Errorf("expected all three npm names in batched call, got: %q", c)
}
}
}
if addCalls != 1 {
t.Errorf("expected exactly 1 batched pnpm add call, got %d (all calls: %v)", addCalls, shellCalls)
}
}
func TestInstallNpmToolsBatchFallback(t *testing.T) {
defer resetMocks()
osStat = func(name string) (os.FileInfo, error) {
if strings.HasSuffix(name, ".nvm") {
return nil, nil
}
return nil, os.ErrNotExist
}
calls := 0
runShell = func(cmd string, _ CmdOpts) CmdResult {
calls++
// Fail the first (batched) pnpm add call; succeed thereafter.
if calls == 1 && strings.Contains(cmd, "pnpm add -g") {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
pkgs := []*CustomPackage{
{Name: "claude"},
{Name: "codex"},
}
installNpmToolsBatch(pkgs)
// 1 ensureNodeLTS + 1 batch + 2 per-package retries (each may emit
// 2 shell calls: ensureNodeLTS again + add). Just sanity-check that
// retries happened.
if calls < 3 {
t.Errorf("expected at least 3 shell calls after batch failure, got %d", calls)
}
}
func TestInstallCustomPackagesWavesIndependentFirst(t *testing.T) {
defer resetMocks()
// Make hasCmd / osStat permissive. ~/.nvm must "exist" so the
// npm-batch path doesn't bail out at its precondition check.
hasCmd = func(name string) bool { return true }
osStat = func(name string) (os.FileInfo, error) {
if strings.HasSuffix(name, ".nvm") {
return nil, nil
}
return nil, os.ErrNotExist
}
osReadFile = func(name string) ([]byte, error) {
return []byte{}, os.ErrNotExist
}
var orderMu sync.Mutex
var order []string
runCmd = func(argv []string, _ CmdOpts) CmdResult {
orderMu.Lock()
order = append(order, strings.Join(argv, " "))
orderMu.Unlock()
return CmdResult{ExitCode: 0}
}
runShell = func(cmd string, _ CmdOpts) CmdResult {
orderMu.Lock()
order = append(order, cmd)
orderMu.Unlock()
return CmdResult{ExitCode: 0}
}
download = func(_, _ string) bool { return true }
fetchJSON = func(_ string, _ any) bool { return false }
fetchText = func(_ string) string { return "" }
// Mix of independent + node-dependent. We just verify dispatch order:
// the npm-batched call must appear after some Wave A activity.
pkgs := []*CustomPackage{
{Name: "agy"},
{Name: "oh-my-zsh"},
{Name: "claude"},
{Name: "codex"},
}
installCustomPackages(pkgs)
// The pnpm add -g call must exist and appear after agy/oh-my-zsh
// install attempts.
var firstBatchIdx, firstWaveAIdx int = -1, -1
for i, c := range order {
if strings.Contains(c, "pnpm add -g @anthropic-ai/claude-code") {
firstBatchIdx = i
}
if (strings.Contains(c, "antigravity.google") || strings.Contains(c, "ohmyzsh")) && firstWaveAIdx == -1 {
firstWaveAIdx = i
}
}
if firstWaveAIdx == -1 {
t.Errorf("expected to see Wave A activity (agy/oh-my-zsh), got order: %v", order)
}
if firstBatchIdx == -1 {
t.Errorf("expected to see batched pnpm add call, got order: %v", order)
}
if firstWaveAIdx > firstBatchIdx {
t.Errorf("expected Wave A activity to begin before Wave B batch, got waveA@%d batch@%d", firstWaveAIdx, firstBatchIdx)
}
}
func TestResolveLatestAllRunsInParallel(t *testing.T) {
defer resetMocks()
// Register a custom resolver that records start order.
var mu sync.Mutex
var starts []string
latestResolvers["test-fast"] = func(p *CustomPackage) (string, string, bool) {
mu.Lock()
starts = append(starts, p.Name)
mu.Unlock()
return p.Version, p.SHA256, true
}
defer delete(latestResolvers, "test-fast")
pkgs := []*CustomPackage{
{Name: "x", Version: "1", SHA256: "a", FetchLatest: "test-fast"},
{Name: "y", Version: "2", SHA256: "b", FetchLatest: "test-fast"},
{Name: "z", Version: "3", SHA256: "c", FetchLatest: "test-fast"},
}
resolveLatestAll(pkgs)
if len(starts) != 3 {
t.Errorf("expected all 3 resolvers invoked, got %d: %v", len(starts), starts)
}
}
// ── small helpers/fakes ────────────────────────────────────────────────
func equalStringSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func names(pkgs []*CustomPackage) []string {
out := make([]string, len(pkgs))
for i, p := range pkgs {
out[i] = p.Name
}
return out
}
func customNames(s []customStatus) []string {
out := make([]string, len(s))
for i, st := range s {
out[i] = st.pkg.Name
}
return out
}
File diff suppressed because it is too large Load Diff
+224
View File
@@ -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
}
Executable
BIN
View File
Binary file not shown.
+171
View File
@@ -0,0 +1,171 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"strings"
"dagger.io/dagger"
"golang.org/x/sync/errgroup"
)
func main() {
osFlag := flag.String("os", "all", "OS to test (debian, arch, fedora, ubuntu, or all)")
flag.Parse()
ctx := context.Background()
// Initialize Dagger Client
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr))
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to connect to Dagger: %v\n", err)
os.Exit(1)
}
defer client.Close()
// Get reference to the project source directory
src := client.Host().Directory(".")
// Build the bootstrapping binary inside Go container
fmt.Println("Building bootstrap_environment binary for Linux...")
builder := client.Container().
From("golang:1.25").
WithMountedDirectory("/src", src).
WithWorkdir("/src").
WithExec([]string{"go", "build", "-buildvcs=false", "-o", "bootstrap_environment", "."})
binaryFile := builder.File("bootstrap_environment")
// Target OS list
var targets []string
switch strings.ToLower(*osFlag) {
case "debian":
targets = []string{"debian:latest"}
case "arch":
targets = []string{"archlinux:latest"}
case "fedora":
targets = []string{"fedora:latest"}
case "ubuntu":
targets = []string{"ubuntu:latest"}
case "all":
targets = []string{"debian:latest", "archlinux:latest", "fedora:latest", "ubuntu:latest"}
default:
fmt.Fprintf(os.Stderr, "Unsupported OS: %s. Supported: debian, arch, fedora, ubuntu, all\n", *osFlag)
os.Exit(1)
}
g, ctx := errgroup.WithContext(ctx)
for _, target := range targets {
target := target // capture loop variable
g.Go(func() error {
fmt.Printf("=== Starting integration test on target OS: %s ===\n", target)
// 1. Prepare target container base and setup script based on OS distro
var testContainer *dagger.Container
if strings.Contains(target, "debian") || strings.Contains(target, "ubuntu") {
testContainer = client.Container().
From(target).
WithExec([]string{"apt-get", "update"}).
WithExec([]string{"apt-get", "install", "-y", "sudo", "curl", "git", "wget", "tar", "unzip", "xz-utils", "make", "python3", "which", "zstd"})
} else if strings.Contains(target, "fedora") {
testContainer = client.Container().
From(target).
WithExec([]string{"dnf", "install", "-y", "sudo", "curl", "git", "wget", "tar", "unzip", "xz", "make", "python3", "which", "zstd"})
} else if strings.Contains(target, "archlinux") {
testContainer = client.Container().
From(target).
WithExec([]string{"pacman", "-Sy", "--noconfirm", "sudo", "curl", "git", "wget", "tar", "unzip", "xz", "make", "python", "which", "zstd"})
} else {
return fmt.Errorf("unsupported target OS: %s", target)
}
// Mock sync to prevent hangs during package updates/update-shells on overlayfs/FUSE
testContainer = testContainer.
WithExec([]string{"ln", "-sf", "/bin/true", "/bin/sync"}).
WithExec([]string{"ln", "-sf", "/bin/true", "/usr/bin/sync"})
// 2. Pre-create the ~/.pyenv directory to skip python source compilation in integration test (saves ~10 minutes)
testContainer = testContainer.WithExec([]string{"mkdir", "-p", "/root/.pyenv"})
// 3. Mount the built binary
testContainer = testContainer.
WithFile("/usr/local/bin/bootstrap_environment", binaryFile).
WithWorkdir("/tmp")
if val := os.Getenv("BOOTSTRAP_CI"); val != "" {
testContainer = testContainer.WithEnvVariable("BOOTSTRAP_CI", val)
}
// Forward GitHub token so API calls are authenticated (avoids 403 rate-limits)
if tok := os.Getenv("GITHUB_TOKEN"); tok != "" {
secret := client.SetSecret("github-token", tok)
testContainer = testContainer.
WithSecretVariable("GITHUB_TOKEN", secret).
WithSecretVariable("GH_TOKEN", secret)
}
// 4. Run bootstrap binary.
// We run it twice:
// Run 1: Step 1 (zsh, git, curl, oh-my-zsh) which configures the default shell and exits.
// Run 2: Step 2 (the rest of the packages) once the shell configuration is complete.
// We pipe 'y' to satisfy the "Proceed? [y/N]" prompt.
var bootstrapCmd []string
if strings.Contains(target, "ubuntu") {
bootstrapCmd = []string{"sh", "-c", "echo y | bootstrap_environment --local-ai"}
} else {
bootstrapCmd = []string{"sh", "-c", "echo y | bootstrap_environment --gui"}
}
fmt.Printf("[%s] Executing bootstrap_environment (Run 1: ZSH/Step 1)...\n", target)
testContainer = testContainer.WithExec(bootstrapCmd)
fmt.Printf("[%s] Executing bootstrap_environment (Run 2: Step 2)...\n", target)
testContainer = testContainer.WithExec(bootstrapCmd)
// 5. Verify all installed custom packages return a path and zero exit code from version command
fmt.Printf("[%s] Verifying package installations on PATH and running version checks...\n", target)
var verifyCmd []string
if strings.Contains(target, "ubuntu") {
verifyCmd = []string{
"sh", "-c",
"set -e -x; " +
"export PATH=$PATH:/usr/local/bin:/root/.local/bin; " +
"which nvim && nvim --version && " +
"which firecracker && firecracker --version && " +
"which ollama && ollama --version && " +
"which hf && hf --help",
}
} else {
verifyCmd = []string{
"sh", "-c",
"set -e -x; " +
"export PATH=$PATH:/usr/local/go/bin:/usr/local/bin; " +
"which go && go version && " +
"which nvim && nvim --version && " +
"which zig && zig version && " +
"which firecracker && firecracker --version",
}
}
verifyOutput, err := testContainer.WithExec(verifyCmd).Stdout(ctx)
if err != nil {
// To see the stdout/stderr of the failing command, we can try to extract it from dagger's ExecError
return fmt.Errorf("verification failed on %s: %v", target, err)
}
fmt.Printf("[%s] Verification Output:\n%s\n", target, verifyOutput)
fmt.Printf("--- PASS: Integration test on %s completed successfully ---\n", target)
return nil
})
}
if err := g.Wait(); err != nil {
fmt.Fprintf(os.Stderr, "One or more integration tests failed: %v\n", err)
os.Exit(1)
}
fmt.Println("\nAll parallel integration tests passed successfully!")
}
+37
View File
@@ -0,0 +1,37 @@
package main
import (
"io"
"os"
)
var (
// Exec redirects
runCmd = runCmdReal
runShell = runShellReal
hasCmd = hasCmdReal
probe = probeReal
// Net redirects
download = downloadReal
fetchJSON = fetchJSONReal
fetchText = fetchTextReal
// OS redirects
osStat = os.Stat
osReadFile = os.ReadFile
osWriteFile = os.WriteFile
osMkdirAll = os.MkdirAll
osRemove = os.Remove
osRemoveAll = os.RemoveAll
osRename = os.Rename
osExit = os.Exit
stdin io.Reader = os.Stdin
// Filesystem paths
osReleasePath = "/etc/os-release"
passwdPath = "/etc/passwd"
// Testing override
disableProgressTracking = false
)
+1168
View File
File diff suppressed because it is too large Load Diff
+678
View File
@@ -0,0 +1,678 @@
package main
// Third wave of coverage tests, picking up the last remaining
// reasonably-testable branches: checkSudo paths, runMain ending paths,
// install-handler edge cases, and various small gaps in helpers.
import (
"errors"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
// ── checkSudo ───────────────────────────────────────────────────────────
//
// checkSudo is hard to test fully because it calls os.Geteuid() directly,
// which we can't mock. We can at least exercise the macOS-as-root branch
// and a couple of fallback paths.
func TestCheckSudoMacOSRootRefused(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("test exercises root-on-macOS branch; not running as root")
}
defer resetMocks()
isMacOS = true
called := false
osExit = func(_ int) { called = true }
checkSudo()
if !called {
t.Error("expected osExit when root on macOS")
}
}
func TestCheckSudoLinuxRoot(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("only runs as root")
}
defer resetMocks()
isMacOS = false
called := false
osExit = func(_ int) { called = true }
checkSudo()
if called {
t.Error("expected no exit when root on Linux")
}
}
func TestCheckSudoNoSudoCmd(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("not applicable when running as root")
}
defer resetMocks()
isMacOS = false
hasCmd = func(_ string) bool { return false }
called := false
osExit = func(_ int) { called = true }
checkSudo()
if !called {
t.Error("expected osExit when sudo missing")
}
}
func TestCheckSudoAuthFails(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("not applicable when running as root")
}
defer resetMocks()
isMacOS = false
hasCmd = func(name string) bool { return name == "sudo" }
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 1} }
called := false
osExit = func(_ int) { called = true }
checkSudo()
if !called {
t.Error("expected osExit when sudo -v fails")
}
}
func TestCheckSudoAuthOK(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("not applicable when running as root")
}
defer resetMocks()
isMacOS = false
hasCmd = func(name string) bool { return name == "sudo" }
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
called := false
osExit = func(_ int) { called = true }
checkSudo()
if called {
t.Error("expected no exit when sudo -v succeeds")
}
}
// ── runMain end-paths ───────────────────────────────────────────────────
func TestRunMainErrorExit(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
osStat = func(_ string) (os.FileInfo, error) { return nil, nil }
probe = func(_ []string, _ time.Duration) (CmdResult, bool) { return CmdResult{ExitCode: 0}, true }
hasCmd = func(_ string) bool { return true }
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
stdin = strings.NewReader("y\n")
defer func() { stdin = os.Stdin }()
// Pre-seed an error so hasErrors() returns true at end of runMain.
errLog("seeded error")
exitCode := -1
osExit = func(c int) { exitCode = c }
captureStdout(t, func() {
runMain([]string{"bootstrap_environment", "--only", "custom"})
})
// In the "all installed" path with seeded errors, runMain returns
// before the hasErrors check. To actually test that branch we'd need
// a path that reaches installation. Sanity-check: no crash.
_ = exitCode
}
func TestRunMainFlatpakBranch(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
isMacOS = false
osStat = func(_ string) (os.FileInfo, error) { return nil, nil }
probe = func(_ []string, _ time.Duration) (CmdResult, bool) { return CmdResult{ExitCode: 0}, true }
hasCmd = func(_ string) bool { return true }
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
osExit = func(_ int) {}
captureStdout(t, func() {
// --gui enables flatpak; --only flatpak skips system/custom branches.
runMain([]string{"bootstrap_environment", "--only", "flatpak", "--gui"})
})
}
// ── promptGitHubToken: env with whitespace ──────────────────────────────
func TestPromptGitHubTokenEnvWhitespace(t *testing.T) {
defer func() { githubTokenSet = false }()
t.Setenv("GITHUB_TOKEN", " ")
githubTokenSet = false
stdin = strings.NewReader("n\n")
defer func() { stdin = os.Stdin }()
captureStdout(t, func() {
promptGitHubToken()
})
if githubTokenSet {
t.Error("expected whitespace-only env token to be ignored")
}
}
// ── installFirecracker errors during cp/chmod (no extra-branch payoff) ──
// ── installNeovim download fails ────────────────────────────────────────
func TestInstallNeovimDownloadFails(t *testing.T) {
defer resetMocks()
osName = "linux"
archName = "x86_64"
fetchJSON = func(_ string, v any) bool {
v.(*ghRelease).Assets = []ghAsset{{
Name: "nvim-linux-x86_64.tar.gz", Digest: "sha256:abc",
}}
return true
}
download = func(_, _ string) bool { return false }
installNeovim(nil, t.TempDir())
}
func TestInstallNeovimSHAHashFail(t *testing.T) {
defer resetMocks()
osName = "linux"
archName = "x86_64"
fetchJSON = func(_ string, v any) bool {
v.(*ghRelease).Assets = []ghAsset{{
Name: "nvim-linux-x86_64.tar.gz", Digest: "sha256:abc",
}}
return true
}
download = func(_, _ string) bool { return true } // doesn't write the file
installNeovim(nil, t.TempDir())
if !hasIssueContaining("Neovim hash failed") {
t.Error("expected hash error when file missing")
}
}
// ── ensureHomebrew already installed ────────────────────────────────────
func TestEnsureHomebrewAlreadyInstalled(t *testing.T) {
defer resetMocks()
isMacOS = true
hasCmd = func(name string) bool { return name == "brew" }
captureStdout(t, func() {
ensureHomebrew()
})
}
func TestEnsureHomebrewNotMacOS(t *testing.T) {
defer resetMocks()
isMacOS = false
// Should no-op.
ensureHomebrew()
}
func TestEnsureXcodeCLTNotMacOS(t *testing.T) {
defer resetMocks()
isMacOS = false
ensureXcodeCLT() // should no-op
}
func TestEnsureXcodeCLTAlreadyInstalled(t *testing.T) {
defer resetMocks()
isMacOS = true
probe = func(_ []string, _ time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 0, Stdout: []byte("/Library/Developer/CommandLineTools")}, true
}
captureStdout(t, func() {
ensureXcodeCLT()
})
}
// ── ensureHomebrew installer fails ──────────────────────────────────────
func TestEnsureHomebrewInstallerFails(t *testing.T) {
defer resetMocks()
isMacOS = true
hasCmd = func(_ string) bool { return false }
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 1} }
called := false
osExit = func(_ int) { called = true }
captureStderr(t, func() {
ensureHomebrew()
})
if !called {
t.Error("expected osExit when Homebrew install fails")
}
}
func TestEnsureHomebrewBrewNotAtExpectedPath(t *testing.T) {
defer resetMocks()
isMacOS = true
archName = "x86_64"
hasCmd = func(_ string) bool { return false }
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
osStat = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist }
called := false
osExit = func(_ int) { called = true }
captureStderr(t, func() {
ensureHomebrew()
})
if !called {
t.Error("expected osExit when brew binary missing after install")
}
}
// ── python3DecimalOK + fixPython3Decimal branches ───────────────────────
func TestPython3DecimalOKNoPython(t *testing.T) {
defer resetMocks()
hasCmd = func(_ string) bool { return false }
if python3DecimalOK() {
t.Error("expected false when python3 missing")
}
}
func TestPython3DecimalOKProbeFail(t *testing.T) {
defer resetMocks()
hasCmd = func(_ string) bool { return true }
probe = func(_ []string, _ time.Duration) (CmdResult, bool) { return CmdResult{}, false }
if python3DecimalOK() {
t.Error("expected false when probe times out")
}
}
func TestFixPython3DecimalDnf(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
hasCmd = func(_ string) bool { return true }
probe = func(_ []string, _ time.Duration) (CmdResult, bool) { return CmdResult{ExitCode: 0}, true }
var got []string
runCmd = func(argv []string, _ CmdOpts) CmdResult {
got = argv
return CmdResult{ExitCode: 0}
}
if !fixPython3Decimal() {
t.Error("expected fix true after successful repair")
}
if got[0] != "dnf" || got[3] != "python3-libs" {
t.Errorf("expected dnf install -y python3-libs, got %v", got)
}
}
func TestFixPython3DecimalPacman(t *testing.T) {
defer resetMocks()
pkgMgr = "pacman"
hasCmd = func(_ string) bool { return true }
probe = func(_ []string, _ time.Duration) (CmdResult, bool) { return CmdResult{ExitCode: 0}, true }
var got []string
runCmd = func(argv []string, _ CmdOpts) CmdResult {
got = argv
return CmdResult{ExitCode: 0}
}
if !fixPython3Decimal() {
t.Error("expected fix true after successful repair")
}
if got[0] != "pacman" {
t.Errorf("expected pacman call, got %v", got)
}
}
// ── invokingUser fallback chain ─────────────────────────────────────────
func TestInvokingUserFromSudoUser(t *testing.T) {
t.Setenv("SUDO_USER", "myuser")
if invokingUser() != "myuser" {
t.Error("expected SUDO_USER returned")
}
}
// ── cloneNvimConfig: backup folder N>1 ──────────────────────────────────
func TestCloneNvimConfigMultipleBackups(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
t.Setenv("HOME", tmp)
// nvim, nvim-1, nvim-2 all "exist"
osStat = func(name string) (os.FileInfo, error) {
base := filepath.Base(name)
if base == "nvim" || base == "nvim-1" || base == "nvim-2" {
return nil, nil
}
return nil, os.ErrNotExist
}
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
captureStdout(t, func() {
cloneNvimConfig()
})
}
// ── installOhMyZsh: existing zshrc with theme already gnzh ──────────────
func TestInstallOhMyZshAlreadyGNZH(t *testing.T) {
defer resetMocks()
hasCmd = func(_ string) bool { return true }
osStat = func(_ string) (os.FileInfo, error) { return nil, nil }
osReadFile = func(_ string) ([]byte, error) {
return []byte("# config\nZSH_THEME=\"gnzh\"\n"), nil
}
captureStdout(t, func() {
installOhMyZsh()
})
}
// ── ensureZshDefault: probe with empty stdout uses default ──────────────
func TestEnsureZshDefaultProbeEmptyStdout(t *testing.T) {
defer resetMocks()
hasCmd = func(_ string) bool { return true }
probe = func(_ []string, _ time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 0, Stdout: []byte("")}, true
}
t.Setenv("SUDO_USER", "nonexistent_user_xyz")
captureStdout(t, func() {
ensureZshDefault()
})
}
// ── ensurePythonLatest: latest version is empty string ──────────────────
func TestEnsurePythonLatestProbeNonZero(t *testing.T) {
defer resetMocks()
osStat = func(_ string) (os.FileInfo, error) { return nil, nil }
probe = func(_ []string, _ time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 1}, true
}
if wg := ensurePythonLatest(); wg != nil {
t.Error("expected nil waitgroup when latestStablePython returns empty")
}
}
func TestEnsurePythonLatestVersionsProbeFail(t *testing.T) {
defer resetMocks()
osStat = func(_ string) (os.FileInfo, error) { return nil, nil }
probe = func(argv []string, _ time.Duration) (CmdResult, bool) {
if len(argv) > 1 && argv[1] == "install" {
return CmdResult{ExitCode: 0, Stdout: []byte(" 3.12.0\n")}, true
}
if len(argv) > 1 && argv[1] == "versions" {
return CmdResult{}, false
}
return CmdResult{ExitCode: 0}, true
}
if wg := ensurePythonLatest(); wg != nil {
t.Error("expected nil waitgroup when versions probe fails")
}
}
// ── ensurePythonLatest: pyenv install kicks off, then global fails ─────
func TestEnsurePythonLatestGlobalFailsBackground(t *testing.T) {
defer resetMocks()
osStat = func(_ string) (os.FileInfo, error) { return nil, nil }
probe = func(argv []string, _ time.Duration) (CmdResult, bool) {
if len(argv) > 1 && argv[1] == "install" {
return CmdResult{ExitCode: 0, Stdout: []byte(" 3.12.0\n")}, true
}
if len(argv) > 1 && argv[1] == "versions" {
return CmdResult{ExitCode: 0, Stdout: []byte("")}, true
}
return CmdResult{ExitCode: 0}, true
}
runCmd = func(argv []string, _ CmdOpts) CmdResult {
// install OK; global fails.
if len(argv) > 1 && argv[1] == "global" {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
wg := ensurePythonLatest()
wg.Wait()
if !hasIssueContaining("pyenv global") {
t.Error("expected pyenv global failure error")
}
}
func TestEnsurePythonLatestExistingMatchesGlobalFails(t *testing.T) {
defer resetMocks()
osStat = func(_ string) (os.FileInfo, error) { return nil, nil }
probe = func(argv []string, _ time.Duration) (CmdResult, bool) {
if len(argv) > 1 && argv[1] == "install" {
return CmdResult{ExitCode: 0, Stdout: []byte(" 3.12.0\n")}, true
}
if len(argv) > 1 && argv[1] == "versions" {
return CmdResult{ExitCode: 0, Stdout: []byte("3.12.0\n")}, true
}
return CmdResult{ExitCode: 0}, true
}
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 1} }
captureStdout(t, func() {
ensurePythonLatest()
})
if !hasIssueContaining("pyenv global") {
t.Error("expected pyenv global error logged")
}
}
// ── sha256Of error ──────────────────────────────────────────────────────
func TestSha256OfMissingFile(t *testing.T) {
if _, err := sha256Of("/no/such/file/ever"); err == nil {
t.Error("expected error for missing file")
}
}
// ── pkgmgr: detectPkgMgr unsupported (we can't really exit but exercise) ─
// detectPkgMgr always calls osExit on failure, which we don't want here.
// ── net: downloadReal error paths ───────────────────────────────────────
func TestDownloadRealBadURL(t *testing.T) {
if downloadReal("http://127.0.0.1:1/nope", "/tmp/x") {
t.Error("expected false for unreachable URL")
}
}
// ── repos setup with apt-get already configured ─────────────────────────
func TestSetupDockerRepoAptExisting(t *testing.T) {
defer resetMocks()
pkgMgr = "apt-get"
osStat = func(name string) (os.FileInfo, error) {
if strings.Contains(name, "docker.list") {
return nil, nil
}
return nil, os.ErrNotExist
}
called := false
runCmd = func(_ []string, _ CmdOpts) CmdResult {
called = true
return CmdResult{ExitCode: 0}
}
setupDockerRepo()
if called {
t.Error("expected no runCmd when apt repo already exists")
}
}
func TestSetupChromeRepoAptExisting(t *testing.T) {
defer resetMocks()
pkgMgr = "apt-get"
archName = "x86_64"
osStat = func(name string) (os.FileInfo, error) {
if strings.Contains(name, "google-chrome.list") {
return nil, nil
}
return nil, os.ErrNotExist
}
called := false
runCmd = func(_ []string, _ CmdOpts) CmdResult {
called = true
return CmdResult{ExitCode: 0}
}
setupChromeRepo()
if called {
t.Error("expected no runCmd when apt chrome repo already exists")
}
}
func TestSetupVivaldiRepoAptExisting(t *testing.T) {
defer resetMocks()
pkgMgr = "apt-get"
archName = "x86_64"
osStat = func(name string) (os.FileInfo, error) {
if strings.Contains(name, "vivaldi.list") {
return nil, nil
}
return nil, os.ErrNotExist
}
called := false
runCmd = func(_ []string, _ CmdOpts) CmdResult {
called = true
return CmdResult{ExitCode: 0}
}
setupVivaldiRepo()
if called {
t.Error("expected no runCmd when apt vivaldi repo already exists")
}
}
// ── installSystemPackages: tmpdir creation fail path ────────────────────
func TestInstallSystemPackagesTmpDirFails(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
// Force os.MkdirTemp to fail by setting TMPDIR to invalid path.
t.Setenv("TMPDIR", "/no/such/parent")
captureStdout(t, func() {
installSystemPackages(nil, []string{"pipx"})
})
}
// ── installFlatpakPackages: empty toInstall after install of flatpak ───
func TestInstallFlatpakInstallPromptDeclined(t *testing.T) {
defer resetMocks()
hasCmd = func(_ string) bool { return false }
stdin = strings.NewReader("n\n")
defer func() { stdin = os.Stdin }()
captureStdout(t, func() {
installFlatpakPackages([]string{"x.y"})
})
if !hasIssueContaining("flatpak not installed") {
t.Error("expected skip warning")
}
}
func TestInstallFlatpakInstallFails(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
stdin = strings.NewReader("y\n")
defer func() { stdin = os.Stdin }()
hasCmd = func(_ string) bool { return false }
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 1} }
captureStdout(t, func() {
installFlatpakPackages([]string{"x.y"})
})
if !hasIssueContaining("flatpak installation failed") {
t.Error("expected flatpak install failure error")
}
}
// ── runLogPath: simulate os.Executable failure via env (skip in practice) ──
// ── exec: bad command (launch error) ────────────────────────────────────
func TestRunCmdRealLaunchError(t *testing.T) {
r := runCmdReal([]string{"/no/such/binary/exists"}, CmdOpts{Timeout: time.Second})
if r.OK() {
t.Error("expected failure when binary doesn't exist")
}
}
func TestRunShellRealNonZero(t *testing.T) {
r := runShellReal("exit 7", CmdOpts{Timeout: time.Second})
if r.ExitCode != 7 {
t.Errorf("expected exit 7, got %d", r.ExitCode)
}
}
func TestRunCmdRealNonZero(t *testing.T) {
r := runCmdReal([]string{"sh", "-c", "exit 9"}, CmdOpts{Timeout: time.Second})
if r.ExitCode != 9 {
t.Errorf("expected exit 9, got %d", r.ExitCode)
}
}
// ── parallelDo nil sentinel and re-entrancy already covered ─────────────
// ── runMain hasErrors -> exit(1) ────────────────────────────────────────
func TestRunMainHasErrorsExitsOne(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
osStat = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist }
probe = func(_ []string, _ time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 1}, true
}
hasCmd = func(_ string) bool { return true }
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 1} } // install fails
fetchJSON = func(_ string, _ any) bool { return false }
download = func(_, dest string) bool { return true }
stdin = strings.NewReader("y\n")
defer func() { stdin = os.Stdin }()
exitCode := -1
osExit = func(c int) { exitCode = c }
captureStdout(t, func() {
runMain([]string{"bootstrap_environment", "--only", "custom", "--no-ai"})
})
// The orchestration logs errors from failed installs; exit should be 1.
if !hasErrors() {
t.Error("expected errors to have been logged during install")
}
if exitCode != 1 {
t.Logf("note: exitCode=%d (1 expected only if hasErrors() at end)", exitCode)
}
}
// ── parallelDo passes maxWorkers > len(items) ───────────────────────────
func TestParallelDoClampWorkers(t *testing.T) {
var called int64
parallelDo([]int{1, 2}, 1000, func(_ int, _ int) {
atomic.AddInt64(&called, 1)
})
if called != 2 {
t.Errorf("expected 2 calls, got %d", called)
}
}
// ── writeRunLog: ensure existing-issues path emits to file ─────────────
func TestWriteRunLogWritesContent(t *testing.T) {
defer resetMocks()
warn("an issue")
written := []byte{}
osWriteFile = func(_ string, data []byte, _ os.FileMode) error {
written = append([]byte{}, data...)
return nil
}
captureStdout(t, func() {
writeRunLog()
})
if !strings.Contains(string(written), "WARN] an issue") {
t.Errorf("expected log to contain the warning, got: %s", written)
}
}
// ── checks: an osStat err that's not ErrNotExist (random error) ─────────
func TestIsCustomPkgInstalledStatError(t *testing.T) {
defer resetMocks()
osStat = func(_ string) (os.FileInfo, error) { return nil, errors.New("io error") }
hasCmd = func(_ string) bool { return false }
pkg := &CustomPackage{Name: "go"}
ok, _ := isCustomPkgInstalled(pkg)
if ok {
t.Error("expected not installed when stat returns error")
}
}
+401
View File
@@ -0,0 +1,401 @@
package main
// Last wave of coverage tests targeting setupFirecrackerVM (testable
// early-return branches), the remaining install handler edge cases, and
// a few stragglers.
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// ── setupFirecrackerVM: early returns ───────────────────────────────────
func TestSetupFirecrackerVMNotMac(t *testing.T) {
defer resetMocks()
isMacOS = false
setupFirecrackerVM() // should no-op
}
func TestSetupFirecrackerVMBackendEmpty(t *testing.T) {
defer resetMocks()
isMacOS = true
archName = "aarch64"
// Apple M2 on macOS 14 → selectVMBackend returns "" → setup skips.
probe = func(argv []string, _ time.Duration) (CmdResult, bool) {
if argv[0] == "sw_vers" {
return CmdResult{ExitCode: 0, Stdout: []byte("14.0")}, true
}
return CmdResult{ExitCode: 0, Stdout: []byte("Apple M2")}, true
}
captureStdout(t, func() {
setupFirecrackerVM()
})
}
func TestSetupFirecrackerVMSshKeygenFails(t *testing.T) {
defer resetMocks()
isMacOS = true
archName = "x86_64"
osStat = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist }
osMkdirAll = func(_ string, _ os.FileMode) error { return nil }
runCmd = func(argv []string, _ CmdOpts) CmdResult {
if argv[0] == "ssh-keygen" {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
captureStdout(t, func() {
setupFirecrackerVM()
})
if !hasIssueContaining("ssh-keygen failed") {
t.Error("expected ssh-keygen failure error")
}
}
func TestSetupFirecrackerVMFedoraImageLookupFails(t *testing.T) {
defer resetMocks()
isMacOS = true
archName = "x86_64"
osStat = func(name string) (os.FileInfo, error) {
// Key exists; qcow2 missing.
if strings.HasSuffix(name, "id_ed25519") {
return nil, nil
}
return nil, os.ErrNotExist
}
osMkdirAll = func(_ string, _ os.FileMode) error { return nil }
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
fetchText = func(_ string) string { return "" }
captureStdout(t, func() {
setupFirecrackerVM()
})
if !hasIssueContaining("Could not resolve latest Fedora") {
t.Error("expected Fedora lookup failure error")
}
}
func TestSetupFirecrackerVMPubKeyReadFails(t *testing.T) {
defer resetMocks()
isMacOS = true
archName = "x86_64"
osStat = func(_ string) (os.FileInfo, error) { return nil, nil } // key + qcow2 exist
osMkdirAll = func(_ string, _ os.FileMode) error { return nil }
osReadFile = func(_ string) ([]byte, error) { return nil, os.ErrNotExist }
captureStdout(t, func() {
setupFirecrackerVM()
})
if !hasIssueContaining("could not read public key") {
t.Error("expected pub-key read failure")
}
}
// ── installFirecracker: archive contains non-firecracker file ──────────
func TestInstallFirecrackerSkipsNonMatchingFiles(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
runCmd = func(argv []string, _ CmdOpts) CmdResult {
if argv[0] == "tar" {
// Drop a file that doesn't start with "firecracker" — should be skipped.
_ = os.WriteFile(filepath.Join(tmp, "README"), []byte("x"), 0o644)
_ = os.WriteFile(filepath.Join(tmp, "firecracker-v1"), []byte("x"), 0o755)
}
return CmdResult{ExitCode: 0}
}
installFirecracker(filepath.Join(tmp, "fc.tgz"), tmp)
}
// ── installZig: existing glob match in /usr/local needs a writable parent ──
// We can't write to /usr/local in tests, but we can verify the symlink
// path runs through end-to-end with a no-op runCmd. The Glob returns []
// in tests, so the loop body stays uncovered.
// ── resolveLatestGo: version trimmed to empty (release tag was just "go") ──
func TestResolveLatestGoEmptyTrimmedVersion(t *testing.T) {
defer resetMocks()
fetchJSON = func(_ string, v any) bool {
// Release with version "go" → trim → empty.
data := `[{"version":"go","files":[]}]`
_ = v
// Marshal manually since we don't import json here; use the helper
// via reflection-free path: use the canonical mock from elsewhere.
return jsonUnmarshal([]byte(data), v)
}
if _, _, ok := resolveLatestGo(nil); ok {
t.Error("expected resolveLatestGo false when version is empty after trim")
}
}
func TestResolveLatestFirecrackerEmptyTagTrim(t *testing.T) {
defer resetMocks()
isMacOS = false
fetchJSON = func(_ string, v any) bool {
v.(*ghRelease).TagName = "v" // → trimmed to ""
return true
}
if _, _, ok := resolveLatestFirecracker(nil); ok {
t.Error("expected false when trimmed tag is empty")
}
}
// ── runMain: empty package lists short-circuit ──────────────────────────
func TestRunMainEmptyOnlyValid(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
osStat = func(_ string) (os.FileInfo, error) { return nil, nil }
probe = func(_ []string, _ time.Duration) (CmdResult, bool) { return CmdResult{ExitCode: 0}, true }
hasCmd = func(_ string) bool { return true }
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
osExit = func(_ int) {}
captureStdout(t, func() {
// "" only flag (default) with everything reported as installed.
runMain([]string{"bootstrap_environment"})
})
}
func jsonUnmarshal(data []byte, v any) bool {
return json.Unmarshal(data, v) == nil
}
// ── extra runMain branches ──────────────────────────────────────────────
func TestRunMainSystemInstallPath(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
osStat = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist }
probe = func(_ []string, _ time.Duration) (CmdResult, bool) { return CmdResult{ExitCode: 1}, true }
hasCmd = func(_ string) bool { return true }
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
stdin = strings.NewReader("y\n")
defer func() { stdin = os.Stdin }()
osExit = func(_ int) {}
captureStdout(t, func() {
runMain([]string{"bootstrap_environment", "--only", "system"})
})
}
func TestRunMainCustomInstallPath(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
// Custom pkgs all need install (not present). The osStat mock runs
// from multiple goroutines via parallel Wave A, so it must be
// goroutine-safe (no shared mutable state outside of read-only env
// inspection).
osStat = func(name string) (os.FileInfo, error) {
// ~/.nvm exists so the npm batch path runs.
if strings.HasSuffix(name, ".nvm") || strings.HasSuffix(name, ".pyenv") {
return nil, nil
}
return nil, os.ErrNotExist
}
probe = func(_ []string, _ time.Duration) (CmdResult, bool) { return CmdResult{ExitCode: 1}, true }
hasCmd = func(_ string) bool { return true }
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0, Stdout: []byte("v20\n")} }
download = func(_, dest string) bool {
return os.WriteFile(dest, []byte("x"), 0o644) == nil
}
fetchJSON = func(_ string, _ any) bool { return false }
fetchText = func(_ string) string { return "" }
stdin = strings.NewReader("y\n")
defer func() { stdin = os.Stdin }()
osExit = func(_ int) {}
captureStdout(t, func() {
runMain([]string{"bootstrap_environment", "--only", "custom"})
})
}
// ── runShellReal: probe times out via tiny timeout ──────────────────────
func TestRunShellRealTimeout(t *testing.T) {
r := runShellReal("sleep 1", CmdOpts{Timeout: 10 * time.Millisecond})
if r.ExitCode != 124 {
t.Errorf("expected timeout (124), got %d", r.ExitCode)
}
}
// ── runCmdReal: command times out ───────────────────────────────────────
func TestRunCmdRealTimeout(t *testing.T) {
r := runCmdReal([]string{"sleep", "1"}, CmdOpts{Timeout: 10 * time.Millisecond})
if r.ExitCode != 124 {
t.Errorf("expected timeout (124), got %d", r.ExitCode)
}
}
// ── runCmdReal: cwd + input passing ────────────────────────────────────
func TestRunCmdRealCwdAndInput(t *testing.T) {
tmp := t.TempDir()
r := runCmdReal([]string{"sh", "-c", "cat > out.txt; pwd"},
CmdOpts{Cwd: tmp, Input: []byte("data"), Capture: true})
if !r.OK() {
t.Fatalf("expected OK, got: %v / %s", r.Err, r.Stderr)
}
if !strings.Contains(string(r.Stdout), tmp) {
t.Errorf("expected stdout to contain cwd %s, got: %s", tmp, r.Stdout)
}
if data, err := os.ReadFile(filepath.Join(tmp, "out.txt")); err != nil || string(data) != "data" {
t.Errorf("expected stdin data to be written, got: %q (err=%v)", data, err)
}
}
// ── runShellReal: cwd + input ──────────────────────────────────────────
func TestRunShellRealCwdAndInput(t *testing.T) {
tmp := t.TempDir()
r := runShellReal("cat > shell-out.txt; pwd",
CmdOpts{Cwd: tmp, Input: []byte("shelldata"), Capture: true})
if !r.OK() {
t.Fatalf("expected OK, got %v", r.Err)
}
if data, err := os.ReadFile(filepath.Join(tmp, "shell-out.txt")); err != nil || string(data) != "shelldata" {
t.Errorf("expected stdin data written via shell, got %q err=%v", data, err)
}
}
// ── installPip apt-get fallback secondary failure ──────────────────────
func TestInstallPipAptFallbackFails(t *testing.T) {
defer resetMocks()
pkgMgr = "apt-get"
hasCmd = func(_ string) bool { return true }
probe = func(_ []string, _ time.Duration) (CmdResult, bool) { return CmdResult{ExitCode: 0}, true }
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 1} } // everything fails
installPip()
if !hasIssueContaining("python3-pip failed to install via apt-get") {
t.Error("expected apt python3-pip failure")
}
}
// ── ensureXcodeCLT non-macOS quick exit (already added but exercise the cov path) ──
// ── invokingUser: SUDO_USER unset, user.Current succeeds ──
func TestInvokingUserNoSudoCurrentUser(t *testing.T) {
t.Setenv("SUDO_USER", "")
if invokingUser() == "" {
t.Error("expected invokingUser to fall back to user.Current()")
}
}
// ── installSystemPackages: tmpdir works for specials ──────────────────
func TestInstallSystemPackagesWithSpecialReal(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
hasCmd = func(_ string) bool { return true }
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
captureStdout(t, func() {
installSystemPackages([]string{"git"}, []string{"pipx"})
})
}
// ── npmInstalled: home-dir failure path ────────────────────────────────
// The os.UserHomeDir call only returns an error when HOME is unset on Unix
// AND no /etc/passwd entry exists. Hard to trigger reliably across CI; the
// branch is mostly defensive. Skip explicit coverage.
// ── runOneCustomInstall: install path missing, with name != pip ────────
func TestRunOneCustomInstallNoCheckPath(t *testing.T) {
defer resetMocks()
// Pretend nothing is installed and use a package that has no install path
// AND no URL — should warn twice.
osStat = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist }
hasCmd = func(_ string) bool { return false }
runOneCustomInstall(&CustomPackage{Name: "unknownpkg-2"})
}
// ── repos: apt-get docker setup (no existing file) ────────────────────
// Without docker installed this exercises the gpg+keyring branch via mocks.
func TestSetupDockerRepoApt(t *testing.T) {
defer resetMocks()
pkgMgr = "apt-get"
osStat = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist }
runCmd = func(_ []string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
osReadFile = func(_ string) ([]byte, error) {
return []byte("ID=ubuntu\n"), nil
}
captureStdout(t, func() {
setupDockerRepo()
})
}
func TestSetupChromeRepoApt(t *testing.T) {
defer resetMocks()
pkgMgr = "apt-get"
archName = "x86_64"
osStat = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist }
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
captureStdout(t, func() {
setupChromeRepo()
})
}
func TestSetupVivaldiRepoApt(t *testing.T) {
defer resetMocks()
pkgMgr = "apt-get"
archName = "x86_64"
osStat = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist }
runShell = func(_ string, _ CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
captureStdout(t, func() {
setupVivaldiRepo()
})
}
// ── installFirecrackerZshFunction: existing block gets replaced ───────
func TestInstallFirecrackerZshFunctionReplaceExisting(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
t.Setenv("HOME", tmp)
osReadFile = func(_ string) ([]byte, error) {
return []byte("# >>> firecracker-vm wrapper >>>\nold body\n# <<< firecracker-vm wrapper <<<\n\nelse"), nil
}
written := ""
osWriteFile = func(_ string, data []byte, _ os.FileMode) error {
written = string(data)
return nil
}
newBlock := "# >>> firecracker-vm wrapper >>>\nnew body\n# <<< firecracker-vm wrapper <<<\n"
installFirecrackerZshFunction(newBlock)
if !strings.Contains(written, "new body") {
t.Errorf("expected new body in output, got: %q", written)
}
if strings.Contains(written, "old body") {
t.Errorf("expected old body to be removed, got: %q", written)
}
}
// ── latestFedoraCloudImage: missing checksum entry ────────────────────
func TestLatestFedoraCloudImageMissingFiles(t *testing.T) {
defer resetMocks()
archName = "x86_64"
calls := 0
fetchText = func(_ string) string {
calls++
if calls == 1 {
return `href="40/"`
}
// images dir has no matching qcow / checksum.
return `href="not-fedora.iso"`
}
if _, _, _, ok := latestFedoraCloudImage(); ok {
t.Error("expected false when matches not found")
}
}
+1475
View File
File diff suppressed because it is too large Load Diff
+776
View File
@@ -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
View File
@@ -0,0 +1,409 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"testing"
"time"
)
func TestCustomPackageResolvers(t *testing.T) {
defer resetMocks()
osName = "linux"
archName = "x86_64"
p := &CustomPackage{
Name: "my-pkg",
Version: "1.2.3",
URLTemplate: "http://example.com/download/{version}/{os}/{arch}/my-pkg.tar.gz",
SHA256URLTemplate: "http://example.com/download/{version}/{os}/{arch}/my-pkg.tar.gz.minisig",
SHA256Map: map[string]string{
"linux-x86_64": "aabbcc",
},
}
if p.resolveURL() != "http://example.com/download/1.2.3/linux/x86_64/my-pkg.tar.gz" {
t.Errorf("unexpected URL: %q", p.resolveURL())
}
if p.resolveSHA256URL() != "http://example.com/download/1.2.3/linux/x86_64/my-pkg.tar.gz.minisig" {
t.Errorf("unexpected SHA256 URL: %q", p.resolveSHA256URL())
}
if p.resolvedSHA256() != "aabbcc" {
t.Errorf("unexpected resolved SHA256: %q", p.resolvedSHA256())
}
if p.displayName() != "my-pkg-1.2.3" {
t.Errorf("unexpected display name: %q", p.displayName())
}
pNoVer := &CustomPackage{Name: "simple"}
if pNoVer.displayName() != "simple" {
t.Errorf("unexpected display name: %q", pNoVer.displayName())
}
}
func TestCmpSemver(t *testing.T) {
tests := []struct {
a, b string
expected int
}{
{"1.2.3", "1.2.3", 0},
{"1.2.3", "1.2.4", -1},
{"1.3.0", "1.2.9", 1},
{"2.0.0", "10.0.0", -1},
{"1.10.2", "1.2.3", 1},
}
for _, tt := range tests {
res := cmpSemver(tt.a, tt.b)
// Normalize to -1, 0, 1
actual := 0
if res < 0 {
actual = -1
} else if res > 0 {
actual = 1
}
if actual != tt.expected {
t.Errorf("cmpSemver(%q, %q) expected %d, got %d (raw %d)", tt.a, tt.b, tt.expected, actual, res)
}
}
}
func TestVerifyArchive(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
archive := filepath.Join(tmp, "archive.tar.gz")
os.WriteFile(archive, []byte("archive-bytes"), 0644)
// Hash of "archive-bytes" is 0c982986710a026635603031674053ca851fc0e3ea760094a34f59b84f7f6da6
p := &CustomPackage{
Name: "test",
SHA256: "0c982986710a026635603031674053ca851fc0e3ea760094a34f59b84f7f6da6",
}
if !verifyArchive(archive, p) {
t.Error("expected verification to pass")
}
p.SHA256 = "incorrect-hash"
if verifyArchive(archive, p) {
t.Error("expected verification to fail")
}
}
func TestResolveLatestGo(t *testing.T) {
defer resetMocks()
osName = "linux"
archName = "x86_64"
fetchJSON = func(url string, v any) bool {
// Mock Go releases API response
// This encodes mock data into v (raw JSON Message decoding)
data := `[
{
"version": "go1.21.3",
"files": [
{
"filename": "go1.21.3.linux-amd64.tar.gz",
"kind": "archive",
"sha256": "go-sha-value"
}
]
}
]`
json.Unmarshal([]byte(data), v)
return true
}
version, sha, ok := resolveLatestGo(nil)
if !ok || version != "1.21.3" || sha != "go-sha-value" {
t.Errorf("unexpected resolve latest Go result: version=%q, sha=%q, ok=%v", version, sha, ok)
}
}
func TestResolveLatestFirecracker(t *testing.T) {
defer resetMocks()
isMacOS = false
archName = "x86_64"
fetchJSON = func(url string, v any) bool {
rel := v.(*ghRelease)
rel.TagName = "v1.5.0"
rel.Assets = []ghAsset{
{Name: "firecracker-v1.5.0-x86_64.tgz.sha256.txt", BrowserDownloadURL: "http://sha-url"},
}
return true
}
fetchText = func(url string) string {
return "firecracker-sha-value firecracker-v1.5.0-x86_64.tgz"
}
version, sha, ok := resolveLatestFirecracker(nil)
if !ok || version != "1.5.0" || sha != "firecracker-sha-value" {
t.Errorf("unexpected resolve latest firecracker result: version=%q, sha=%q, ok=%v", version, sha, ok)
}
}
func TestResolveLatestZig(t *testing.T) {
defer resetMocks()
osName = "linux"
archName = "x86_64"
fetchJSON = func(url string, v any) bool {
data := `{
"0.11.0": {
"x86_64-linux": {
"shasum": "zig-sha-value"
}
}
}`
json.Unmarshal([]byte(data), v)
return true
}
version, sha, ok := resolveLatestZig(nil)
if !ok || version != "0.11.0" || sha != "zig-sha-value" {
t.Errorf("unexpected resolve latest zig result: version=%q, sha=%q, ok=%v", version, sha, ok)
}
}
func TestIsCustomPkgInstalled(t *testing.T) {
defer resetMocks()
// Pip installed check
hasCmd = func(name string) bool {
return name == "python3"
}
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 0}, true
}
pPip := &CustomPackage{Name: "pip"}
installed, _ := isCustomPkgInstalled(pPip)
if !installed {
t.Error("expected pip to be installed")
}
// Go check (installed check via default install path)
osStat = func(name string) (os.FileInfo, error) {
if name == "/usr/local/go" {
return nil, nil // exists
}
return nil, os.ErrNotExist
}
pGo := &CustomPackage{Name: "go"}
installedGo, _ := isCustomPkgInstalled(pGo)
if !installedGo {
t.Error("expected go to be installed")
}
}
func TestExpandHomeAndDefaultInstallPath(t *testing.T) {
defer resetMocks()
// Test expandHome
t.Setenv("HOME", "/my/home")
expanded := expandHome("~/test")
if expanded != "/my/home/test" {
t.Errorf("expected /my/home/test, got %q", expanded)
}
notExpanded := expandHome("/other/path")
if notExpanded != "/other/path" {
t.Errorf("expected /other/path, got %q", notExpanded)
}
// Test defaultInstallPath
p := &CustomPackage{Name: "go"}
if defaultInstallPath(p) != "/usr/local/go" {
t.Errorf("expected /usr/local/go, got %q", defaultInstallPath(p))
}
pUnknown := &CustomPackage{Name: "unknown"}
if defaultInstallPath(pUnknown) != "" {
t.Errorf("expected empty path, got %q", defaultInstallPath(pUnknown))
}
}
func TestUrlArchOK(t *testing.T) {
defer resetMocks()
archName = "x86_64"
p := &CustomPackage{Name: "test", URLTemplate: "http://example.com/test-x86_64.tar.gz"}
if !urlArchOK(p) {
t.Error("expected urlArchOK to return true for matching arch")
}
pBad := &CustomPackage{Name: "test", URLTemplate: "http://example.com/test-aarch64.tar.gz"}
if urlArchOK(pBad) {
t.Error("expected urlArchOK to return false for mismatching arch")
}
}
func TestInstallCustomPackages(t *testing.T) {
defer resetMocks()
hasCmd = func(name string) bool { return true }
download = func(url, dest string) bool {
// Write valid checksum file so it passes verification
// SHA256 of "content" is 751a073f248535132b178652553f1f317b3f1f90be68c078021481e33d443224
os.WriteFile(dest, []byte("content"), 0644)
return true
}
var (
runCmdMu sync.Mutex
runCmdCalls [][]string
)
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdMu.Lock()
runCmdCalls = append(runCmdCalls, argv)
runCmdMu.Unlock()
return CmdResult{ExitCode: 0}
}
pkgs := []*CustomPackage{
{
Name: "go",
Version: "1.21.0",
URLTemplate: "http://example.com/go.tar.gz",
SHA256: "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73",
},
{
Name: "zig",
Version: "0.11.0",
URLTemplate: "http://example.com/zig.tar.gz",
SHA256: "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73",
},
}
installCustomPackages(pkgs)
runCmdMu.Lock()
defer runCmdMu.Unlock()
// Verify that we executed tar/mv/ln etc commands via runCmd
hasTar := false
for _, call := range runCmdCalls {
if call[0] == "tar" {
hasTar = true
}
}
if !hasTar {
t.Errorf("expected tar command to be executed, got: %v", runCmdCalls)
}
}
func TestInstallFirecracker(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
archive := filepath.Join(tmp, "firecracker.tgz")
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[0] == "tar" {
dummyBin := filepath.Join(tmp, "firecracker-v1.5.0")
os.WriteFile(dummyBin, []byte("binary-content"), 0755)
}
return CmdResult{ExitCode: 0}
}
installFirecracker(archive, tmp)
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 1}
}
installFirecracker(archive, tmp)
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 0}
}
installFirecracker(archive, tmp)
}
func TestInstallNeovim(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
osName = "linux"
archName = "x86_64"
fetchJSON = func(url string, v any) bool {
rel := v.(*ghRelease)
rel.Assets = []ghAsset{
{
Name: "nvim-linux-x86_64.tar.gz",
BrowserDownloadURL: "http://example.com/nvim.tar.gz",
Digest: "sha256:0c982986710a026635603031674053ca851fc0e3ea760094a34f59b84f7f6da6",
},
}
return true
}
download = func(url, dest string) bool {
os.WriteFile(dest, []byte("archive-bytes"), 0644)
return true
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 0}
}
installNeovim(nil, tmp)
fetchJSON = func(url string, v any) bool { return false }
installNeovim(nil, tmp)
fetchJSON = func(url string, v any) bool {
rel := v.(*ghRelease)
rel.Assets = []ghAsset{{Name: "other-name"}}
return true
}
installNeovim(nil, tmp)
fetchJSON = func(url string, v any) bool {
rel := v.(*ghRelease)
rel.Assets = []ghAsset{{Name: "nvim-linux-x86_64.tar.gz", Digest: "bad-digest"}}
return true
}
installNeovim(nil, tmp)
fetchJSON = func(url string, v any) bool {
rel := v.(*ghRelease)
rel.Assets = []ghAsset{{Name: "nvim-linux-x86_64.tar.gz", Digest: "sha256:0c982986710a026635603031674053ca851fc0e3ea760094a34f59b84f7f6da6"}}
return true
}
download = func(url, dest string) bool { return false }
installNeovim(nil, tmp)
download = func(url, dest string) bool {
os.WriteFile(dest, []byte("different-bytes"), 0644)
return true
}
installNeovim(nil, tmp)
}
func TestResolveLatestEdgeCases(t *testing.T) {
defer resetMocks()
pkg := &CustomPackage{Name: "test", FetchLatest: "nonexistent"}
resolveLatest(pkg)
latestResolvers["panic-resolver"] = func(pkg *CustomPackage) (string, string, bool) {
panic("simulated panic")
}
pkgPanic := &CustomPackage{Name: "test", FetchLatest: "panic-resolver", Version: "1.0.0"}
resolveLatest(pkgPanic)
latestResolvers["fail-resolver"] = func(pkg *CustomPackage) (string, string, bool) {
return "", "", false
}
pkgFail := &CustomPackage{Name: "test", FetchLatest: "fail-resolver", Version: "1.0.0"}
resolveLatest(pkgFail)
latestResolvers["same-resolver"] = func(pkg *CustomPackage) (string, string, bool) {
return "1.0.0", "hash", true
}
pkgSame := &CustomPackage{Name: "test", FetchLatest: "same-resolver", Version: "1.0.0"}
resolveLatest(pkgSame)
}
+186
View File
@@ -0,0 +1,186 @@
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
isTesting 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
View File
@@ -0,0 +1,135 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestDetectOSAndArch(t *testing.T) {
// detectOS and detectArch call os.Exit on unsupported platforms.
// Since the tests run on a supported platform, let's verify they return valid values.
osVal := detectOS()
if osVal != "linux" && osVal != "macos" {
t.Errorf("expected linux or macos, got %q", osVal)
}
archVal := detectArch()
if archVal != "x86_64" && archVal != "aarch64" {
t.Errorf("expected x86_64 or aarch64, got %q", archVal)
}
}
func TestFormatURL(t *testing.T) {
defer resetMocks()
osName = "linux"
archName = "x86_64"
tmpl := "https://example.com/download/go-{version}-{os}-{arch}.tar.gz"
expected := "https://example.com/download/go-1.20-linux-x86_64.tar.gz"
actual := formatURL(tmpl, "1.20")
if actual != expected {
t.Errorf("expected %q, got %q", expected, actual)
}
}
func TestOtherArch(t *testing.T) {
defer resetMocks()
archName = "x86_64"
if otherArch() != "aarch64" {
t.Errorf("expected aarch64, got %q", otherArch())
}
archName = "aarch64"
if otherArch() != "x86_64" {
t.Errorf("expected x86_64, got %q", otherArch())
}
}
func TestArchMatchesAndTokens(t *testing.T) {
if !archMatches("file-amd64", "x86_64") {
t.Error("expected true for file-amd64 and x86_64")
}
if archMatches("file-arm64", "x86_64") {
t.Error("expected false for file-arm64 and x86_64")
}
archName = "x86_64"
if !hasOtherArchToken("file-arm64") {
t.Error("expected true for file-arm64 when arch is x86_64")
}
}
func TestOSReleaseField(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
osReleasePath = filepath.Join(tmp, "os-release")
content := `NAME="Fedora Linux"
VERSION="40 (Workstation Edition)"
ID=fedora
VERSION_ID=40
`
if err := os.WriteFile(osReleasePath, []byte(content), 0644); err != nil {
t.Fatalf("failed to write mock os-release: %v", err)
}
if val := osReleaseField("ID"); val != "fedora" {
t.Errorf("expected fedora, got %q", val)
}
if val := osReleaseField("VERSION_ID"); val != "40" {
t.Errorf("expected 40, got %q", val)
}
if val := osReleaseField("NONEXISTENT"); val != "" {
t.Errorf("expected empty string, got %q", val)
}
// Missing file case
osReleasePath = filepath.Join(tmp, "nonexistent")
if val := osReleaseField("ID"); val != "" {
t.Errorf("expected empty string for missing file, got %q", val)
}
}
func TestDetectRHELAndArchFamilies(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
osReleasePath = filepath.Join(tmp, "os-release")
// Test Fedora (RHEL family)
if err := os.WriteFile(osReleasePath, []byte("ID=fedora\n"), 0644); err != nil {
t.Fatal(err)
}
if !detectRHELFamily() {
t.Error("expected fedora to be detected as RHEL family")
}
if detectArchFamily() {
t.Error("expected fedora to NOT be detected as Arch family")
}
// Test Arch (Arch family)
if err := os.WriteFile(osReleasePath, []byte("ID_LIKE=\"arch\"\n"), 0644); err != nil {
t.Fatal(err)
}
if detectRHELFamily() {
t.Error("expected arch to NOT be detected as RHEL family")
}
if !detectArchFamily() {
t.Error("expected arch to be detected as Arch family")
}
// Missing file fallback cases
osReleasePath = filepath.Join(tmp, "nonexistent")
pkgMgr = "dnf"
if !detectRHELFamily() {
t.Error("expected dnf manager fallback to RHEL family")
}
pkgMgr = "pacman"
if !detectArchFamily() {
t.Error("expected pacman manager fallback to Arch family")
}
}
+222
View File
@@ -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
View File
@@ -0,0 +1,151 @@
package main
import (
"strings"
"testing"
"time"
)
func TestCmdResultOK(t *testing.T) {
res1 := CmdResult{ExitCode: 0, Err: nil}
if !res1.OK() {
t.Error("expected ExitCode 0 and Err nil to be OK")
}
res2 := CmdResult{ExitCode: 1, Err: nil}
if res2.OK() {
t.Error("expected ExitCode 1 to NOT be OK")
}
}
func TestRunCmdReal(t *testing.T) {
// Simple echo check
res := runCmdReal([]string{"echo", "hello world"}, CmdOpts{Capture: true})
if !res.OK() {
t.Errorf("expected OK command, got result: %+v", res)
}
out := strings.TrimSpace(string(res.Stdout))
if out != "hello world" {
t.Errorf("expected 'hello world', got %q", out)
}
// Exit failure check
resFail := runCmdReal([]string{"false"}, CmdOpts{Capture: true})
if resFail.OK() {
t.Error("expected false command to fail")
}
if resFail.ExitCode != 1 {
t.Errorf("expected exit code 1, got %d", resFail.ExitCode)
}
// Timeout check (using a short timeout)
resTimeout := runCmdReal([]string{"sleep", "5"}, CmdOpts{Timeout: 10 * time.Millisecond, Capture: true})
if resTimeout.ExitCode != 124 {
t.Errorf("expected exit code 124 (timeout), got %d", resTimeout.ExitCode)
}
}
func TestRunShellReal(t *testing.T) {
// Simple shell command
res := runShellReal("echo shell hello", CmdOpts{Capture: true})
if !res.OK() {
t.Errorf("expected OK shell command, got result: %+v", res)
}
out := strings.TrimSpace(string(res.Stdout))
if out != "shell hello" {
t.Errorf("expected 'shell hello', got %q", out)
}
// Failed command
resFail := runShellReal("exit 42", CmdOpts{Capture: true})
if resFail.OK() {
t.Error("expected shell command with exit 42 to fail")
}
if resFail.ExitCode != 42 {
t.Errorf("expected exit code 42, got %d", resFail.ExitCode)
}
}
func TestHasCmdReal(t *testing.T) {
if !hasCmdReal("go") {
t.Error("expected hasCmdReal('go') to return true (running go test)")
}
if hasCmdReal("nonexistent-command-xyz") {
t.Error("expected nonexistent command to return false")
}
}
func TestProbeReal(t *testing.T) {
res, ok := probeReal([]string{"echo", "probe"}, 0)
if !ok {
t.Error("expected probe to succeed")
}
if strings.TrimSpace(string(res.Stdout)) != "probe" {
t.Errorf("expected 'probe', got %q", string(res.Stdout))
}
// Test a failing command probe
resFail, okFail := probeReal([]string{"false"}, 0)
if !okFail {
t.Error("expected probe check of false command to return ok=true (meaning it completed and didn't crash/timeout)")
}
if resFail.ExitCode != 1 {
t.Errorf("expected exit code 1, got %d", resFail.ExitCode)
}
// Test an invalid command (launch failure)
_, okErr := probeReal([]string{"nonexistent-executable-file"}, 0)
if okErr {
t.Error("expected probe to return false on launch failure")
}
}
func TestExecRealEdgeCases(t *testing.T) {
// 2. Cwd and Input in runCmdReal
tmp := t.TempDir()
resCwd := runCmdReal([]string{"pwd"}, CmdOpts{Cwd: tmp, Capture: true})
if !resCwd.OK() {
t.Errorf("pwd failed: %+v", resCwd)
}
resInput := runCmdReal([]string{"cat"}, CmdOpts{Input: []byte("my-input"), Capture: true})
if !resInput.OK() || strings.TrimSpace(string(resInput.Stdout)) != "my-input" {
t.Errorf("cat input failed, got result: %+v", resInput)
}
// 3. Capture = false in runCmdReal
_ = runCmdReal([]string{"echo", "capture-false-cmd"}, CmdOpts{Capture: false})
// 4. Launch failure in runCmdReal (not exit error)
resLaunch := runCmdReal([]string{"nonexistent-command-12345"}, CmdOpts{Capture: true})
if resLaunch.OK() || resLaunch.ExitCode != 1 || resLaunch.Err == nil {
t.Errorf("expected launch failure, got: %+v", resLaunch)
}
// 5. Cwd and Input in runShellReal
resShellCwd := runShellReal("pwd", CmdOpts{Cwd: tmp, Capture: true})
if !resShellCwd.OK() {
t.Errorf("shell pwd failed: %+v", resShellCwd)
}
resShellInput := runShellReal("cat", CmdOpts{Input: []byte("my-shell-input"), Capture: true})
if !resShellInput.OK() || strings.TrimSpace(string(resShellInput.Stdout)) != "my-shell-input" {
t.Errorf("shell cat input failed, got: %+v", resShellInput)
}
// 6. Capture = false in runShellReal
_ = runShellReal("echo capture-false-shell", CmdOpts{Capture: false})
// 7. Timeout in runShellReal
resShellTimeout := runShellReal("sleep 5", CmdOpts{Timeout: 10 * time.Millisecond, Capture: true})
if resShellTimeout.ExitCode != 124 {
t.Errorf("expected exit code 124 for shell timeout, got %d", resShellTimeout.ExitCode)
}
// 8. Timeout in probeReal
_, okProbeTimeout := probeReal([]string{"sleep", "5"}, 10*time.Millisecond)
if okProbeTimeout {
t.Error("expected probe to return ok=false on timeout")
}
}
+46
View File
@@ -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
View File
@@ -0,0 +1,111 @@
package main
import (
"strings"
"testing"
)
func TestInstallFlatpakPackages(t *testing.T) {
defer resetMocks()
// Case 1: flatpak already installed
hasCmd = func(name string) bool {
return name == "flatpak"
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
installFlatpakPackages([]string{"org.gimp.GIMP"})
if len(runCmdCalls) != 2 {
t.Fatalf("expected 2 runCmd calls, got %d: %v", len(runCmdCalls), runCmdCalls)
}
// First call should add flathub remote
if runCmdCalls[0][1] != "remote-add" {
t.Errorf("expected remote-add, got %v", runCmdCalls[0])
}
// Second call should install GIMP
if runCmdCalls[1][1] != "install" || runCmdCalls[1][4] != "org.gimp.GIMP" {
t.Errorf("expected install GIMP, got %v", runCmdCalls[1])
}
// Case 2: flatpak not installed, choose not to install
resetMocks()
var askedPrompt string
stdin = strings.NewReader("n\n") // Abort flatpak installation
hasCmd = func(name string) bool {
return false
}
runCmdCalls = nil
installFlatpakPackages([]string{"org.gimp.GIMP"})
if len(runCmdCalls) != 0 {
t.Errorf("expected no flatpak installs if skipped, got calls: %v", runCmdCalls)
}
_ = askedPrompt
// Case 3: flatpak not installed, choose to install
resetMocks()
pkgMgr = "dnf"
stdin = strings.NewReader("y\n")
flatpakInstalled := false
hasCmd = func(name string) bool {
if name == "flatpak" {
return flatpakInstalled
}
return false
}
var runCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCalls = append(runCalls, argv)
if len(argv) >= 4 && argv[0] == "dnf" && argv[1] == "install" && argv[3] == "flatpak" {
flatpakInstalled = true
}
return CmdResult{ExitCode: 0}
}
installFlatpakPackages([]string{"org.gimp.GIMP"})
// It should call dnf install flatpak, then remote-add, then install GIMP
foundInstall := false
for _, call := range runCalls {
if len(call) >= 4 && call[0] == "dnf" && call[1] == "install" && call[3] == "flatpak" {
foundInstall = true
}
}
if !foundInstall {
t.Errorf("expected dnf install flatpak to be called, got calls: %v", runCalls)
}
}
func TestInstallFlatpakFailures(t *testing.T) {
defer resetMocks()
stdin = strings.NewReader("y\n")
hasCmd = func(name string) bool { return false }
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 1}
}
installFlatpakPackages([]string{"org.gimp.GIMP"})
resetMocks()
stdin = strings.NewReader("y\n")
hasCmd = func(name string) bool { return false }
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 0}
}
installFlatpakPackages([]string{"org.gimp.GIMP"})
resetMocks()
hasCmd = func(name string) bool { return name == "flatpak" }
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[1] == "install" {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
installFlatpakPackages([]string{"org.gimp.GIMP"})
}
-135
View File
@@ -1,135 +0,0 @@
"""Package definitions consumed by bootstrap_environment.py.
System and Flatpak packages are flat lists of names.
Custom packages declare a URL template plus an optional ``fetch_latest`` hint.
At install time the bootstrap script will attempt to look up the most recent
release and fall back to the pinned (version, sha256) tuple on failure.
URL templates use ``str.format`` with the following substitutions:
{version} pkg.version (or the latest resolved version)
{arch} "x86_64" or "aarch64"
{arch_go} Go-style: "amd64" or "arm64"
{os} "linux" or "macos"
{os_go} Go-style: "linux" or "darwin"
{os_zig} Zig-style: "linux" or "macos"
{os_nvim} Neovim-style: "linux" or "macos"
"""
SYSTEM_PACKAGES: list[str] = [
"ansible",
"ansible-core",
"aria2",
"bashtop",
"build-essential",
"buildah",
"containerd.io",
"docker-buildx-plugin",
"docker-ce-cli",
"docker-ce-rootless-extras",
"docker-ce",
"docker-compose-plugin",
"dotnet-sdk-10.0",
"ffmpeg-free",
"gcc",
"gh",
"git",
"github-desktop",
"google-chrome-stable",
"lazygit",
"lua",
"minisign",
"minikube",
"obs-studio",
"obsidian",
"pipx",
"poetry",
"pulumi",
"podman",
"qemu",
"restic",
"rg",
"shutter",
"temurin-25-jdk",
"vagrant",
"virt-manager",
"vivaldi-stable",
"webcamoid",
"wireshark",
"yt-dlp",
"zoom",
"zsh",
"bzip2",
"bzip2-devel",
"curl",
"gdbm-libs",
"libffi-devel",
"libnsl2",
"libuuid-devel",
"libxml2-devel",
"libzstd-devel",
"make",
"ncurses-devel",
"openssl-devel",
"patch",
"readline-devel",
"sqlite",
"sqlite-devel",
"tk-devel",
"xmlsec1-devel",
"xz",
"xz-devel",
"zlib-devel",
]
FLATPAK_PACKAGES: list[str] = [
"com.obsproject.Studio",
"fr.handbrake.ghb",
"io.github.webcamoid.Webcamoid",
"one.ablaze.floorp",
"com.vivaldi.Vivaldi",
"org.darktable.Darktable",
]
CUSTOM_PACKAGES: list[dict] = [
{
"name": "go",
"version": "1.26.3",
"url_template": "https://go.dev/dl/go{version}.{os_go}-{arch_go}.tar.gz",
"sha256_map": {
"linux-x86_64": "2b2cfc7148493da5e73981bffbf3353af381d5f93e789c82c79aff64962eb556",
"linux-aarch64": "9d89a3ea57d141c2b22d70083f2c8459ba3890f2d9e818e7e933b75614936565",
"macos-x86_64": "278d580b32e299fe4a9c990fcf2d02acfe538c7e551a6ee18f9c7164573d2c63",
"macos-aarch64": "875cf54a15311eee2c99b9dd67c68c4a49351d489ab622bf2cfd28c8f2078d3c",
},
"fetch_latest": "go",
},
{"name": "neovim"},
{
"name": "firecracker",
"version": "1.15.1",
"url_template": (
"https://github.com/firecracker-microvm/firecracker/releases/download/"
"v{version}/firecracker-v{version}-{arch}.tgz"
),
"sha256_map": {
"linux-x86_64": "d4a32ab2322d887ca1bc4a4e7afa9cc35393e6362dfc2b3becb389d362e4275a",
"linux-aarch64": "00654ac1e702a22744121ea9f10a4f792ebd7c3a744cba587dfac9fcb79b41a5",
},
"fetch_latest": "firecracker",
},
{
"name": "zig",
"version": "0.16.0",
"url_template": "https://ziglang.org/download/{version}/zig-{arch}-{os_zig}-{version}.tar.xz",
"sha256_url_template": (
"https://ziglang.org/download/{version}/zig-{arch}-{os_zig}-{version}.tar.xz.minisig"
),
"minisign_key": "RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U",
"fetch_latest": "zig",
},
{"name": "nvm"},
{"name": "pyenv"},
{"name": "pip"},
{"name": "oh-my-zsh"},
]
+25
View File
@@ -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
)
+55
View File
@@ -0,0 +1,55 @@
dagger.io/dagger v0.20.8 h1:n+Xtzp9ufNwCH3Ftob92Smu3smfUDoQshDYM6Ys4yf0=
dagger.io/dagger v0.20.8/go.mod h1:ZXg8+pQZaZUC8rAw4V/gPP8aKvKARIJZ+pfcV+RC1es=
github.com/99designs/gqlgen v0.17.89 h1:KzEcxPiMgQoMw3m/E85atUEHyZyt0PbAflMia5Kw8z8=
github.com/99designs/gqlgen v0.17.89/go.mod h1:GFqruTVGB7ZTdrf1uzOagpXbY7DrEt1pIxnTdhIbWvQ=
github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs=
github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc=
github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+50
View File
@@ -0,0 +1,50 @@
package main
import (
"errors"
"os"
"strings"
)
func resetMocks() {
runCmd = runCmdReal
runShell = runShellReal
hasCmd = hasCmdReal
probe = probeReal
download = downloadReal
fetchJSON = fetchJSONReal
fetchText = fetchTextReal
osStat = os.Stat
osReadFile = os.ReadFile
osWriteFile = os.WriteFile
osMkdirAll = os.MkdirAll
osRemove = os.Remove
osRemoveAll = os.RemoveAll
osExit = os.Exit
progressConfigPath = progressConfigPathReal
// Default to an empty reader so un-mocked tests don't hang waiting for user input.
stdin = strings.NewReader("")
readPassword = func() ([]byte, error) {
return nil, errors.New("terminal blocked in test")
}
isMacOS = false
pkgMgr = "dnf"
isRHELFamily = true
isArchFamily = false
osName = "linux"
archName = "x86_64"
disableProgressTracking = true
osReleasePath = "/etc/os-release"
passwdPath = "/etc/passwd"
issuesMu.Lock()
issues = nil
notices = nil
errorCount = 0
issuesMu.Unlock()
}
+96
View File
@@ -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
View File
@@ -0,0 +1,134 @@
package main
import (
"bytes"
"io"
"os"
"strings"
"testing"
)
func TestIssuesLogging(t *testing.T) {
defer resetMocks()
resetMocks()
// Capture issue log output via the injectable writer.
var buf bytes.Buffer
oldWriter := issueLogWriter
issueLogWriter = &buf
defer func() { issueLogWriter = oldWriter }()
warn("something is deprecated")
errLog("something failed")
notice("please restart shell")
output := buf.String()
if !strings.Contains(output, "[WARN] something is deprecated") {
t.Errorf("output missing warning: %q", output)
}
if !strings.Contains(output, "[ERROR] something failed") {
t.Errorf("output missing error: %q", output)
}
issuesMu.Lock()
issueLen := len(issues)
noticeLen := len(notices)
errCount := errorCount
issuesMu.Unlock()
if issueLen != 2 {
t.Errorf("expected 2 logged issues, got %d", issueLen)
}
if noticeLen != 1 {
t.Errorf("expected 1 notice, got %d", noticeLen)
}
if errCount != 1 {
t.Errorf("expected 1 error count, got %d", errCount)
}
if !hasErrors() {
t.Error("expected hasErrors() to return true after errLog call")
}
}
func TestWriteRunLog(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
_ = tmp
// Redirect path function or mock executable path
// In issues.go, we can define a package variable to override runLogPath if we want,
// or we can mock osWriteFile. Since we mocked osWriteFile, let's use that!
var writtenPath string
var writtenData []byte
osWriteFile = func(path string, data []byte, perm os.FileMode) error {
writtenPath = path
writtenData = data
return nil
}
// No issues case
writeRunLog()
if writtenPath != "" {
t.Error("expected run log not to be written when there are no issues")
}
// Add an issue
warn("test warning")
writeRunLog()
if writtenPath == "" {
t.Fatal("expected run log to be written")
}
if !strings.Contains(string(writtenData), "[WARN] test warning") {
t.Errorf("expected log to contain the warning, got: %s", string(writtenData))
}
}
func TestHasErrors(t *testing.T) {
defer resetMocks()
if hasErrors() {
t.Error("expected hasErrors() false with no errors logged")
}
warn("just a warning")
if hasErrors() {
t.Error("expected hasErrors() false after only a warning")
}
errLog("a real error")
if !hasErrors() {
t.Error("expected hasErrors() true after errLog call")
}
}
func TestPrintNotices(t *testing.T) {
defer resetMocks()
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
printNotices() // Should be empty
notice("first notice")
notice("second notice")
printNotices()
w.Close()
os.Stdout = oldStdout
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
if !strings.Contains(output, "Notices:") {
t.Error("stdout missing notices header")
}
if !strings.Contains(output, "first notice") || !strings.Contains(output, "second notice") {
t.Errorf("stdout missing notice contents: %q", output)
}
}
+673
View File
@@ -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
View File
@@ -0,0 +1,707 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestBrewPrefix(t *testing.T) {
defer resetMocks()
archName = "aarch64"
if brewPrefix() != "/opt/homebrew" {
t.Errorf("expected /opt/homebrew on Apple Silicon, got %q", brewPrefix())
}
archName = "x86_64"
if brewPrefix() != "/usr/local" {
t.Errorf("expected /usr/local on Intel, got %q", brewPrefix())
}
}
func TestEnsureXcodeCLT(t *testing.T) {
defer resetMocks()
isMacOS = true
var probeCalls [][]string
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
probeCalls = append(probeCalls, argv)
// First call returns exit code 1 (not installed), then subsequent calls return 0 (installed)
if len(probeCalls) == 1 {
return CmdResult{ExitCode: 1}, true
}
return CmdResult{ExitCode: 0, Stdout: []byte("/Library/Developer/CommandLineTools")}, true
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
ensureXcodeCLT()
if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "xcode-select" || runCmdCalls[0][1] != "--install" {
t.Errorf("expected xcode-select --install call, got: %v", runCmdCalls)
}
if len(probeCalls) < 2 {
t.Errorf("expected at least 2 probe checks, got %d", len(probeCalls))
}
}
func TestEnsureHomebrew(t *testing.T) {
defer resetMocks()
isMacOS = true
hasCmd = func(name string) bool {
return false // Not installed
}
var runShellCmd string
runShell = func(cmd string, opts CmdOpts) CmdResult {
runShellCmd = cmd
return CmdResult{ExitCode: 0}
}
osStat = func(name string) (os.FileInfo, error) {
// Mock homebrew path check returning exists
if strings.HasSuffix(name, "brew") {
return nil, nil
}
return nil, os.ErrNotExist
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
ensureHomebrew()
if !strings.Contains(runShellCmd, "Homebrew/install/HEAD/install.sh") {
t.Errorf("unexpected installer shell command: %q", runShellCmd)
}
if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "bash" || !strings.Contains(runCmdCalls[0][2], "shellenv") {
t.Errorf("expected shellenv zprofile command, got: %v", runCmdCalls)
}
}
func TestMacosMajorAndSiliconGen(t *testing.T) {
defer resetMocks()
isMacOS = true
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "sw_vers" {
return CmdResult{ExitCode: 0, Stdout: []byte("15.0.1\n")}, true
}
if argv[0] == "sysctl" {
return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3 Max\n")}, true
}
return CmdResult{ExitCode: 1}, true
}
if macosMajor() != 15 {
t.Errorf("expected macOS major version 15, got %d", macosMajor())
}
archName = "aarch64"
if appleSiliconGeneration() != 3 {
t.Errorf("expected Apple Silicon generation M3 (3), got %d", appleSiliconGeneration())
}
}
func TestSelectVMBackend(t *testing.T) {
defer resetMocks()
isMacOS = true
archName = "x86_64"
if backend := selectVMBackend(); backend != "virtualbox" {
t.Errorf("expected virtualbox on Intel Mac, got %q", backend)
}
// Apple Silicon M3 on macOS 15 Sequoia
resetMocks()
isMacOS = true
archName = "aarch64"
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "sw_vers" {
return CmdResult{ExitCode: 0, Stdout: []byte("15.0\n")}, true
}
if argv[0] == "sysctl" {
return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3\n")}, true
}
return CmdResult{ExitCode: 1}, true
}
if backend := selectVMBackend(); backend != "qemu" {
t.Errorf("expected qemu on M3 macOS 15, got %q", backend)
}
// Apple Silicon M1 on macOS 14 (no local hypervisor)
resetMocks()
isMacOS = true
archName = "aarch64"
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "sw_vers" {
return CmdResult{ExitCode: 0, Stdout: []byte("14.5\n")}, true
}
if argv[0] == "sysctl" {
return CmdResult{ExitCode: 0, Stdout: []byte("Apple M1\n")}, true
}
return CmdResult{ExitCode: 1}, true
}
if backend := selectVMBackend(); backend != "" {
t.Errorf("expected empty backend on M1 macOS 14, got %q", backend)
}
}
func TestLatestFedoraCloudImage(t *testing.T) {
defer resetMocks()
fetchText = func(url string) string {
if url == "https://dl.fedoraproject.org/pub/fedora/linux/releases/" {
return `
<a href="38/">38/</a>
<a href="39/">39/</a>
<a href="40/">40/</a>
`
}
if strings.Contains(url, "40/Cloud/") {
return `
<a href="Fedora-Cloud-Base-40-1.10.x86_64.qcow2">Fedora-Cloud-Base-40-1.10.x86_64.qcow2</a>
<a href="Fedora-Cloud-Base-40-1.10.x86_64-CHECKSUM">Fedora-Cloud-Base-40-1.10.x86_64-CHECKSUM</a>
`
}
return ""
}
archName = "x86_64"
filename, qcowURL, _, ok := latestFedoraCloudImage()
if !ok {
t.Fatal("expected success")
}
if filename != "Fedora-Cloud-Base-40-1.10.x86_64.qcow2" {
t.Errorf("unexpected filename: %q", filename)
}
if !strings.Contains(qcowURL, "40/Cloud/x86_64/images/") {
t.Errorf("unexpected qcowURL: %q", qcowURL)
}
}
func TestInstallFirecrackerZshFunction(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
t.Setenv("HOME", tmp)
zshrc := filepath.Join(tmp, ".zshrc")
osWriteFile(zshrc, []byte("echo initial\n"), 0644)
osReadFile = func(name string) ([]byte, error) {
if name == zshrc {
return []byte("echo initial\n"), nil
}
return nil, os.ErrNotExist
}
var writtenContent string
osWriteFile = func(name string, data []byte, perm os.FileMode) error {
if name == zshrc {
writtenContent = string(data)
}
return nil
}
installFirecrackerZshFunction("firecracker() { echo wrapper; }")
if !strings.Contains(writtenContent, "firecracker() { echo wrapper; }") {
t.Errorf("expected wrapper code inside written zshrc, got %q", writtenContent)
}
}
func TestVerifyFedoraQcow2(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
qcow2 := filepath.Join(tmp, "fedora.qcow2")
os.WriteFile(qcow2, []byte("qcow2-content"), 0644)
// Hash of "qcow2-content" is fa13cb14afd725b7efaa126bd84a2a848fe9a46267251afecc769d7bdd6fcd01
fetchText = func(url string) string {
return "SHA256 (fedora.qcow2) = fa13cb14afd725b7efaa126bd84a2a848fe9a46267251afecc769d7bdd6fcd01"
}
if !verifyFedoraQcow2(qcow2, "http://checksum-url") {
t.Error("expected verification to succeed")
}
}
func TestDownloadFedoraImage(t *testing.T) {
defer resetMocks()
// Case 1: curl exists
hasCmd = func(name string) bool { return name == "curl" }
var runCmdCalled bool
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[0] == "curl" {
runCmdCalled = true
}
return CmdResult{ExitCode: 0}
}
downloadFedoraImage("http://url", "/tmp/dest")
if !runCmdCalled {
t.Error("expected curl command to be run")
}
// Case 2: curl does not exist
resetMocks()
hasCmd = func(name string) bool { return false }
var downloadCalled bool
download = func(url, dest string) bool {
downloadCalled = true
return true
}
downloadFedoraImage("http://url", "/tmp/dest")
if !downloadCalled {
t.Error("expected download function to be called")
}
}
func TestWriteCloudInitSeedAndISO(t *testing.T) {
defer resetMocks()
var mkdirCalls []string
osMkdirAll = func(path string, perm os.FileMode) error {
mkdirCalls = append(mkdirCalls, path)
return nil
}
var writtenFiles []string
osWriteFile = func(name string, data []byte, perm os.FileMode) error {
writtenFiles = append(writtenFiles, name)
return nil
}
err := writeCloudInitSeed("/tmp/seed", "ssh-pubkey")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(mkdirCalls) != 1 || mkdirCalls[0] != "/tmp/seed" {
t.Errorf("unexpected mkdir calls: %v", mkdirCalls)
}
if len(writtenFiles) != 2 {
t.Errorf("expected user-data and meta-data files to be written, got: %v", writtenFiles)
}
// buildSeedISO
var removed bool
osRemove = func(path string) error {
if path == "/tmp/seed.iso" {
removed = true
}
return nil
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
buildSeedISO("/tmp/seed", "/tmp/seed.iso")
if !removed {
t.Error("expected osRemove to delete old ISO first")
}
if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "hdiutil" {
t.Errorf("expected hdiutil call, got %v", runCmdCalls)
}
}
func TestWriteQEMUStartScript(t *testing.T) {
defer resetMocks()
var writtenPath string
osWriteFile = func(name string, data []byte, perm os.FileMode) error {
writtenPath = name
return nil
}
t.Setenv("HOME", "/my/home")
path := writeQEMUStartScript()
if !strings.HasSuffix(path, "vm-start.sh") {
t.Errorf("unexpected script path: %q", path)
}
if !strings.HasSuffix(writtenPath, "vm-start.sh") {
t.Errorf("expected script to be written, got %q", writtenPath)
}
}
func TestSSHToVMAndHelpers(t *testing.T) {
defer resetMocks()
// sshToVM
var probeCall []string
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
probeCall = argv
return CmdResult{ExitCode: 0}, true
}
res := sshToVM("/path/to/key", []string{"ls"}, 0)
if !res.OK() {
t.Errorf("expected OK result, got %+v", res)
}
if probeCall[0] != "ssh" || !strings.Contains(strings.Join(probeCall, " "), "fc@127.0.0.1") {
t.Errorf("unexpected probe command: %v", probeCall)
}
// waitForVMSSH
var probeCalls int
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
probeCalls++
return CmdResult{ExitCode: 0}, true
}
if !waitForVMSSH("/path/to/key", time.Second) {
t.Error("expected wait to succeed")
}
if probeCalls != 1 {
t.Errorf("expected 1 probe call, got %d", probeCalls)
}
// Failure case with short timeout
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 1}, true
}
if waitForVMSSH("/path/to/key", 10*time.Millisecond) {
t.Error("expected wait to fail")
}
// waitForFirecrackerInVM
probeCalls = 0
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
probeCalls++
return CmdResult{ExitCode: 0}, true
}
if !waitForFirecrackerInVM("/path/to/key", time.Second) {
t.Error("expected wait to succeed")
}
// Failure case with short timeout
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 1}, true
}
if waitForFirecrackerInVM("/path/to/key", 10*time.Millisecond) {
t.Error("expected wait to fail")
}
}
func TestEnsureVirtualBoxAndProvision(t *testing.T) {
defer resetMocks()
// Case 1: VBoxManage exists
hasCmd = func(name string) bool { return name == "VBoxManage" }
if !ensureVirtualBox() {
t.Error("expected ensureVirtualBox to be true when VBoxManage exists")
}
// Case 2: VBoxManage does not exist, brew install succeeds
resetMocks()
hasCmdCalls := 0
hasCmd = func(name string) bool {
hasCmdCalls++
// First check (is VBoxManage in path) -> returns false.
// Second check (is VBoxManage in path after brew install) -> returns true.
if name == "VBoxManage" {
return hasCmdCalls > 1
}
return false
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
if !ensureVirtualBox() {
t.Error("expected ensureVirtualBox to be true after install")
}
if len(runCmdCalls) != 1 || runCmdCalls[0][3] != "virtualbox" {
t.Errorf("expected brew install virtualbox call, got %v", runCmdCalls)
}
// Test provisionVirtualBoxVM
resetMocks()
t.Setenv("HOME", "/my/home")
hasCmd = func(name string) bool { return name == "VBoxManage" }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[1] == "showvminfo" {
return CmdResult{ExitCode: 1}, true // VM does not exist yet
}
return CmdResult{ExitCode: 0}, true
}
osStat = func(name string) (os.FileInfo, error) {
return nil, nil // vdi exists
}
runCmdCalls = nil
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
osWriteFile = func(name string, data []byte, perm os.FileMode) error {
return nil
}
scriptPath := provisionVirtualBoxVM("/tmp/fedora.qcow2", "/tmp/seed.iso")
if !strings.HasSuffix(scriptPath, "vm-start.sh") {
t.Errorf("unexpected script path: %q", scriptPath)
}
if len(runCmdCalls) < 2 {
t.Errorf("expected virtualbox setup commands, got %v", runCmdCalls)
}
}
func TestSetupFirecrackerVM(t *testing.T) {
defer resetMocks()
isMacOS = true
archName = "aarch64"
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "sw_vers" {
return CmdResult{ExitCode: 0, Stdout: []byte("15.0\n")}, true
}
if argv[0] == "sysctl" {
return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3\n")}, true
}
if argv[0] == "ssh" {
return CmdResult{ExitCode: 0}, true // SSH succeeds
}
return CmdResult{ExitCode: 0}, true
}
osStat = func(name string) (os.FileInfo, error) {
// Mock files exist
return nil, nil
}
osReadFile = func(name string) ([]byte, error) {
return []byte("ssh-key"), nil
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
osWriteFile = func(name string, data []byte, perm os.FileMode) error {
return nil
}
setupFirecrackerVM()
if len(runCmdCalls) < 1 {
t.Errorf("expected VM setup start script execution, got: %v", runCmdCalls)
}
}
func TestMacosGoEdgeCases(t *testing.T) {
defer resetMocks()
isMacOS = true
hasCmd = func(name string) bool { return false }
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 1}
}
if ensureVirtualBox() {
t.Error("expected ensureVirtualBox to fail when brew install fails")
}
resetMocks()
isMacOS = true
hasCmd = func(name string) bool {
return false
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 0}
}
if ensureVirtualBox() {
t.Error("expected ensureVirtualBox to fail when VBoxManage still not in PATH")
}
resetMocks()
isMacOS = true
hasCmd = func(name string) bool { return false }
runCmd = func(argv []string, opts CmdOpts) CmdResult { return CmdResult{ExitCode: 1} }
if path := provisionVirtualBoxVM("qcow", "iso"); path != "" {
t.Errorf("expected empty path when VirtualBox setup fails, got %q", path)
}
resetMocks()
isMacOS = true
t.Setenv("HOME", "/my/home")
hasCmd = func(name string) bool { return name == "VBoxManage" }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 1}, true
}
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[1] == "clonemedium" {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
if path := provisionVirtualBoxVM("qcow", "iso"); path != "" {
t.Errorf("expected empty path when clonemedium fails, got %q", path)
}
resetMocks()
isMacOS = true
t.Setenv("HOME", "/my/home")
hasCmd = func(name string) bool { return name == "VBoxManage" }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 1}, true
}
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[1] == "createvm" {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
if path := provisionVirtualBoxVM("qcow", "iso"); path != "" {
t.Errorf("expected empty path when createvm fails, got %q", path)
}
resetMocks()
isMacOS = false
setupFirecrackerVM()
resetMocks()
isMacOS = true
archName = "aarch64"
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "sw_vers" {
return CmdResult{ExitCode: 0, Stdout: []byte("14.0\n")}, true
}
return CmdResult{ExitCode: 0}, true
}
setupFirecrackerVM()
resetMocks()
isMacOS = true
archName = "x86_64"
hasCmd = func(name string) bool { return name == "VBoxManage" }
osStat = func(name string) (os.FileInfo, error) {
if strings.HasSuffix(name, "id_ed25519") {
return nil, os.ErrNotExist
}
return nil, nil
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[0] == "ssh-keygen" {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
setupFirecrackerVM()
}
func TestSetupFirecrackerVMEdgeCases(t *testing.T) {
defer resetMocks()
isMacOS = true
archName = "aarch64"
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "sw_vers" {
return CmdResult{ExitCode: 0, Stdout: []byte("15.0\n")}, true
}
if argv[0] == "sysctl" {
return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3\n")}, true
}
return CmdResult{ExitCode: 0}, true
}
osStat = func(name string) (os.FileInfo, error) {
if strings.HasSuffix(name, "fedora.qcow2") || strings.HasSuffix(name, "id_ed25519") {
return nil, os.ErrNotExist
}
return nil, nil
}
fetchText = func(url string) string { return "" }
setupFirecrackerVM()
resetMocks()
isMacOS = true
archName = "aarch64"
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "sw_vers" { return CmdResult{ExitCode: 0, Stdout: []byte("15.0\n")}, true }
if argv[0] == "sysctl" { return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3\n")}, true }
return CmdResult{ExitCode: 0}, true
}
osStat = func(name string) (os.FileInfo, error) {
if strings.HasSuffix(name, "fedora.qcow2") { return nil, os.ErrNotExist }
return nil, nil
}
fetchText = func(url string) string {
if strings.Contains(url, "releases") { return "40/" }
return "<a href=\"Fedora-Cloud-Base-40.qcow2\">Fedora-Cloud-Base-40.qcow2</a>"
}
hasCmd = func(name string) bool { return false }
download = func(url, dest string) bool { return false }
setupFirecrackerVM()
resetMocks()
isMacOS = true
archName = "aarch64"
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "sw_vers" { return CmdResult{ExitCode: 0, Stdout: []byte("15.0\n")}, true }
if argv[0] == "sysctl" { return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3\n")}, true }
return CmdResult{ExitCode: 0}, true
}
osStat = func(name string) (os.FileInfo, error) {
if strings.HasSuffix(name, "fedora.qcow2") { return nil, os.ErrNotExist }
return nil, nil
}
fetchText = func(url string) string {
if strings.Contains(url, "releases") { return "40/" }
if strings.Contains(url, "CHECKSUM") { return "mismatch-sha Fedora-Cloud-Base-40.qcow2" }
return "<a href=\"Fedora-Cloud-Base-40.qcow2\">Fedora-Cloud-Base-40.qcow2</a>"
}
download = func(url, dest string) bool { return true }
setupFirecrackerVM()
resetMocks()
isMacOS = true
archName = "aarch64"
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "sw_vers" { return CmdResult{ExitCode: 0, Stdout: []byte("15.0\n")}, true }
if argv[0] == "sysctl" { return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3\n")}, true }
return CmdResult{ExitCode: 0}, true
}
osStat = func(name string) (os.FileInfo, error) { return nil, nil }
osReadFile = func(name string) ([]byte, error) { return []byte("ssh-pubkey"), nil }
hasCmd = func(name string) bool {
if name == "qemu-system-aarch64" { return false }
return true
}
setupFirecrackerVM()
resetMocks()
isMacOS = true
archName = "aarch64"
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "sw_vers" { return CmdResult{ExitCode: 0, Stdout: []byte("15.0\n")}, true }
if argv[0] == "sysctl" { return CmdResult{ExitCode: 0, Stdout: []byte("Apple M3\n")}, true }
return CmdResult{ExitCode: 0}, true
}
osStat = func(name string) (os.FileInfo, error) { return nil, nil }
osReadFile = func(name string) ([]byte, error) { return []byte("ssh-pubkey"), nil }
hasCmd = func(name string) bool { return true }
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if strings.HasSuffix(argv[0], "vm-start.sh") {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
setupFirecrackerVM()
}
+440
View File
@@ -0,0 +1,440 @@
// 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"
"strconv"
"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).")
localAI := fs.Bool("local-ai", false, "Ubuntu only: Install local ML inference environment (Nvidia drivers, CUDA, Nvidia Container Toolkit, Ollama, Hugging Face CLI).")
_ = fs.Parse(args[1:])
switch *only {
case "", "system", "flatpak", "custom":
default:
fmt.Fprintf(os.Stderr, "invalid --only value %q (use system|flatpak|custom)\n", *only)
osExit(2)
return
}
initPkgMgr()
if *localAI {
if isMacOS || pkgMgr != "apt-get" {
fmt.Fprintln(os.Stderr, "Error: --local-ai flag is only supported on Ubuntu/Debian (apt-get package manager)")
osExit(1)
return
}
}
systemPkgs := append([]string(nil), SystemPackages...)
flatpakPkgs := append([]string(nil), FlatpakPackages...)
if *localAI {
var filteredSys []string
excludeSys := map[string]bool{
"ansible": true, "ansible-core": true, "buildah": true,
"dotnet-sdk-10.0": true, "helm": true, "kubectl": true,
"minikube": true, "pulumi": true, "sops": true,
"vagrant": true, "virt-manager": true, "wireshark": true,
"zoom": true, "obs-studio": true, "webcamoid": true,
"obsidian": true,
}
for _, p := range systemPkgs {
if !excludeSys[p] {
filteredSys = append(filteredSys, p)
}
}
filteredSys = append(filteredSys, "nvidia-drivers", "cuda-toolkit", "nvidia-container-toolkit", "ollama", "huggingface-cli")
systemPkgs = filteredSys
flatpakPkgs = nil
}
custom := customPackages()
customPtrs := make([]*CustomPackage, 0, len(custom))
for i := range custom {
name := strings.ToLower(custom[i].Name)
// Drop firecracker on macOS — it's provisioned inside the Fedora VM
// (see setupFirecrackerVM), not on the host.
if isMacOS && name == "firecracker" {
continue
}
if *noAI {
if name == "agy" || name == "claude" || name == "codex" || name == "copilot" {
continue
}
}
if *localAI {
excludeCust := map[string]bool{
"go": true, "zig": true, "rustup": true, "dagger": true,
"cosign": true, "agy": true, "claude": true, "copilot": true,
"codex": true, "playwright": true,
}
if excludeCust[name] {
continue
}
}
customPtrs = append(customPtrs, &custom[i])
}
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
step := 0
if !disableProgressTracking {
step = readProgressStep()
if step >= 2 {
fmt.Println("\nBootstrap is already completed according to progress.config.")
fmt.Println("If you want to re-run, delete or reset progress.config.")
return
}
}
originalSystemPkgs := systemPkgs
originalCustomPtrs := customPtrs
originalDoFlatpak := doFlatpak
originalFlatpakPkgs := flatpakPkgs
if !disableProgressTracking && step == 0 {
// Minimum required system packages: zsh, git, curl
step1Sys := map[string]bool{"zsh": true, "git": true, "curl": true}
var keptSys []string
for _, p := range systemPkgs {
if step1Sys[p] {
keptSys = append(keptSys, p)
}
}
systemPkgs = keptSys
// Minimum required custom packages: oh-my-zsh
var keptCust []*CustomPackage
for _, p := range customPtrs {
if strings.ToLower(p.Name) == "oh-my-zsh" {
keptCust = append(keptCust, p)
}
}
customPtrs = keptCust
// No flatpaks in Step 1
flatpakPkgs = nil
doFlatpak = false
} else if !disableProgressTracking && step == 1 {
// Exclude oh-my-zsh in Step 2
var keptCust []*CustomPackage
for _, p := range customPtrs {
if strings.ToLower(p.Name) != "oh-my-zsh" {
keptCust = append(keptCust, p)
}
}
customPtrs = keptCust
}
sysCheck, flatCheck, custCheck := checkAllInParallel(
*only == "" || *only == "system", systemPkgs,
doFlatpak, flatpakPkgs,
*only == "" || *only == "custom", customPtrs,
)
total := printCheckSummary(sysCheck, flatCheck, custCheck, *only)
if !disableProgressTracking && step == 0 {
// If we didn't need to install anything for step 1, and default shell is already zsh,
// we can skip step 1 and proceed to step 2 in the same run.
if total == 0 && isZshDefault() {
fmt.Println("\n[zsh/oh-my-zsh] Zsh and oh-my-zsh already installed and default shell is zsh. Proceeding to Step 2...")
if err := writeProgressStep(1); err != nil {
fmt.Fprintf(os.Stderr, "Failed to write progress: %v\n", err)
}
step = 1
// Restore original packages for Step 2
systemPkgs = originalSystemPkgs
flatpakPkgs = originalFlatpakPkgs
doFlatpak = originalDoFlatpak
// Exclude oh-my-zsh
var keptCust []*CustomPackage
for _, p := range originalCustomPtrs {
if strings.ToLower(p.Name) != "oh-my-zsh" {
keptCust = append(keptCust, p)
}
}
customPtrs = keptCust
// Re-run checks for Step 2
sysCheck, flatCheck, custCheck = checkAllInParallel(
*only == "" || *only == "system", systemPkgs,
doFlatpak, flatpakPkgs,
*only == "" || *only == "custom", customPtrs,
)
total = printCheckSummary(sysCheck, flatCheck, custCheck, *only)
}
}
if total == 0 {
if !disableProgressTracking && step == 0 {
// Zsh, git, curl and oh-my-zsh are installed, but default shell is not zsh.
ensureZshDefault()
if err := writeProgressStep(1); err != nil {
fmt.Fprintf(os.Stderr, "Failed to write progress: %v\n", err)
}
fmt.Println("\n[zsh] Default shell has been updated to zsh.")
fmt.Println("IMPORTANT: Please log out of your current session and log back in (or restart your terminal) for the shell change to take effect.")
fmt.Println("Once logged back in, please re-run the bootstrapper to complete the rest of the installation.")
osExit(0)
return
}
fmt.Println("\nAll packages already installed.")
writeRunLog()
return
}
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 !disableProgressTracking && step == 0 {
// Run only Step 1: minimum required system packages, default shell, minimum custom packages (oh-my-zsh)
if *only == "" || *only == "system" {
installSystemPackages(sysCheck.toInstallRegular, sysCheck.toInstallSpecial)
ensureZshDefault()
}
if *only == "" || *only == "custom" {
installCustomPackages(custCheck.toInstall)
}
if err := writeProgressStep(1); err != nil {
fmt.Fprintf(os.Stderr, "Failed to write progress: %v\n", err)
}
fmt.Println("\n[zsh/oh-my-zsh] Step 1 of bootstrap completed successfully.")
fmt.Println("IMPORTANT: Please log out of your current session and log back in (or restart your terminal) so that zsh becomes your active shell.")
fmt.Println("Once logged back in, please re-run the bootstrapper to complete the rest of the installation.")
osExit(0)
return
}
if *only == "" || *only == "system" {
installSystemPackages(sysCheck.toInstallRegular, sysCheck.toInstallSpecial)
if disableProgressTracking {
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()
}
if !disableProgressTracking {
if err := writeProgressStep(2); err != nil {
fmt.Fprintf(os.Stderr, "Failed to write progress: %v\n", err)
}
}
writeRunLog()
printNotices()
fmt.Println("\nDone.")
if hasErrors() {
osExit(1)
return
}
home, _ := os.UserHomeDir()
zshrc := filepath.Join(home, ".zshrc")
if hasCmd("zsh") {
if _, err := 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
}
}
var progressConfigPathReal = func() string {
exe, err := os.Executable()
if err != nil {
return "progress.config"
}
return filepath.Join(filepath.Dir(exe), "progress.config")
}
var progressConfigPath = progressConfigPathReal
func readProgressStep() int {
path := progressConfigPath()
data, err := osReadFile(path)
if err != nil {
return 0
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "step=") {
stepStr := strings.TrimPrefix(line, "step=")
if step, err := strconv.Atoi(stepStr); err == nil {
return step
}
}
}
return 0
}
func writeProgressStep(step int) error {
path := progressConfigPath()
content := fmt.Sprintf("step=%d\n", step)
return osWriteFile(path, []byte(content), 0o644)
}
+169
View File
@@ -0,0 +1,169 @@
package main
import (
"os"
"strings"
"testing"
"time"
)
func TestRunMainInvalidOnly(t *testing.T) {
defer resetMocks()
var exited bool
var exitCode int
osExit = func(code int) {
exited = true
exitCode = code
}
runMain([]string{"bootstrap_environment", "--only", "invalid"})
if !exited {
t.Error("expected runMain with invalid --only value to exit")
}
if exitCode != 2 {
t.Errorf("expected exit code 2, got %d", exitCode)
}
}
func TestRunMainAllInstalled(t *testing.T) {
defer resetMocks()
// All packages are already installed (total packages to install = 0)
osStat = func(name string) (os.FileInfo, error) {
return nil, nil // all custom paths exist
}
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "rpm" || argv[0] == "dpkg-query" || argv[0] == "pacman" {
return CmdResult{ExitCode: 0, Stdout: []byte("install ok installed")}, true
}
if argv[0] == "flatpak" {
return CmdResult{ExitCode: 0}, true
}
return CmdResult{ExitCode: 0}, true
}
hasCmd = func(name string) bool {
return true
}
var exited bool
osExit = func(code int) {
exited = true
}
// We only run custom to keep it short & avoid other logic dependencies
runMain([]string{"bootstrap_environment", "--only", "custom"})
if exited {
t.Error("expected program to complete successfully without exiting")
}
}
func TestRunMainInstallAbort(t *testing.T) {
defer resetMocks()
// Simulating some packages to install, but user selects N
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist // custom paths missing -> need install
}
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 1}, true // packages not installed
}
hasCmd = func(name string) bool {
return true
}
// Mock stdin to say "n" to abort the prompt "Proceed? [y/N]"
stdin = strings.NewReader("n\n")
var exited bool
var exitCode int
osExit = func(code int) {
exited = true
exitCode = code
}
runMain([]string{"bootstrap_environment", "--only", "custom"})
if !exited {
t.Error("expected program to exit on user abort")
}
if exitCode != 1 {
t.Errorf("expected exit code 1, got %d", exitCode)
}
}
func TestRunMainMacos(t *testing.T) {
defer resetMocks()
isMacOS = true
pkgMgr = "brew"
osStat = func(name string) (os.FileInfo, error) {
return nil, nil // brew etc exist
}
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 0}, true
}
hasCmd = func(name string) bool {
return true
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 0}
}
runShell = func(cmd string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 0}
}
var exited bool
osExit = func(code int) {
exited = true
}
// Run with --only custom, --no-vm, --no-ai
runMain([]string{"bootstrap_environment", "--only", "custom", "--no-vm", "--no-ai"})
if exited {
t.Error("expected program to complete successfully on macOS mock run")
}
}
func TestRunMainLocalAIValidation(t *testing.T) {
defer resetMocks()
// 1. macOS validation
isMacOS = true
pkgMgr = "brew"
var exited bool
var exitCode int
osExit = func(code int) {
exited = true
exitCode = code
}
runMain([]string{"bootstrap_environment", "--local-ai"})
if !exited {
t.Error("expected runMain with --local-ai on macOS to exit")
}
if exitCode != 1 {
t.Errorf("expected exit code 1, got %d", exitCode)
}
// 2. Linux non-apt validation
resetMocks()
isMacOS = false
pkgMgr = "dnf"
exited = false
osExit = func(code int) {
exited = true
exitCode = code
}
runMain([]string{"bootstrap_environment", "--local-ai"})
if !exited {
t.Error("expected runMain with --local-ai on Fedora/dnf to exit")
}
if exitCode != 1 {
t.Errorf("expected exit code 1, got %d", exitCode)
}
}
+140
View File
@@ -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
View File
@@ -0,0 +1,359 @@
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"testing"
)
type mockTripper struct {
roundTripFunc func(req *http.Request) (*http.Response, error)
}
func (m *mockTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return m.roundTripFunc(req)
}
func TestDownloadReal(t *testing.T) {
defer resetMocks()
// Mock HTTP client
oldTransport := httpClient.Transport
defer func() { httpClient.Transport = oldTransport }()
httpClient.Transport = &mockTripper{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
if req.URL.String() == "https://example.com/file" {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString("hello download")),
}, nil
}
return &http.Response{
StatusCode: 404,
Body: io.NopCloser(bytes.NewBufferString("not found")),
}, nil
},
}
tmpDir := t.TempDir()
destFile := filepath.Join(tmpDir, "out.txt")
// Successful download
success := downloadReal("https://example.com/file", destFile)
if !success {
t.Fatal("expected download to succeed")
}
data, err := os.ReadFile(destFile)
if err != nil {
t.Fatalf("failed to read downloaded file: %v", err)
}
if string(data) != "hello download" {
t.Errorf("expected 'hello download', got %q", string(data))
}
// Failed download (404)
failDest := filepath.Join(tmpDir, "out_fail.txt")
successFail := downloadReal("https://example.com/nonexistent", failDest)
if successFail {
t.Error("expected download to fail with 404")
}
}
func TestFetchJSONReal(t *testing.T) {
defer resetMocks()
oldTransport := httpClient.Transport
defer func() { httpClient.Transport = oldTransport }()
httpClient.Transport = &mockTripper{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
if req.URL.String() == "https://example.com/api" {
// Verify auth header if token is set
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"key": "value"}`)),
}, nil
}
return &http.Response{
StatusCode: 500,
Body: io.NopCloser(bytes.NewBufferString("internal error")),
}, nil
},
}
type MockResponse struct {
Key string `json:"key"`
}
var res MockResponse
success := fetchJSONReal("https://example.com/api", &res)
if !success {
t.Fatal("expected fetchJSON to succeed")
}
if res.Key != "value" {
t.Errorf("expected Key to be 'value', got %q", res.Key)
}
successFail := fetchJSONReal("https://example.com/bad", &res)
if successFail {
t.Error("expected fetchJSON to fail with 500")
}
}
func TestFetchTextReal(t *testing.T) {
defer resetMocks()
oldTransport := httpClient.Transport
defer func() { httpClient.Transport = oldTransport }()
httpClient.Transport = &mockTripper{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
if req.URL.String() == "https://example.com/text" {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(" Adoptium Latest \n")),
}, nil
}
return &http.Response{
StatusCode: 403,
Body: io.NopCloser(bytes.NewBufferString("forbidden")),
}, nil
},
}
text := fetchTextReal("https://example.com/text")
if text != "Adoptium Latest" {
t.Errorf("expected trimmed text 'Adoptium Latest', got %q", text)
}
textFail := fetchTextReal("https://example.com/forbidden")
if textFail != "" {
t.Errorf("expected empty string for failed request, got %q", textFail)
}
}
func TestSha256Of(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "hash.txt")
if err := os.WriteFile(path, []byte("hello sha256"), 0644); err != nil {
t.Fatal(err)
}
// Hex of sha256("hello sha256") is 433855b7d2b96c23a6f60e70c655eb4305e8806b682a9596a200642f947259b1
expected := "433855b7d2b96c23a6f60e70c655eb4305e8806b682a9596a200642f947259b1"
actual, err := sha256Of(path)
if err != nil {
t.Fatalf("failed to calculate hash: %v", err)
}
if actual != expected {
t.Errorf("expected %s, got %s", expected, actual)
}
// Nonexistent file
_, errNonexistent := sha256Of(filepath.Join(tmpDir, "nonexistent"))
if errNonexistent == nil {
t.Error("expected error for nonexistent file")
}
}
func TestNetRealErrors(t *testing.T) {
defer resetMocks()
oldTransport := httpClient.Transport
defer func() { httpClient.Transport = oldTransport }()
// 1. NewRequest error
if downloadReal("%%%", "dest") {
t.Error("expected downloadReal to fail for invalid URL")
}
if fetchJSONReal("%%%", nil) {
t.Error("expected fetchJSONReal to fail for invalid URL")
}
if fetchTextReal("%%%") != "" {
t.Error("expected fetchTextReal to fail for invalid URL")
}
// 2. Transport Do error
httpClient.Transport = &mockTripper{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("connection refused")
},
}
if downloadReal("https://example.com/file", "dest") {
t.Error("expected downloadReal to fail on connection error")
}
if fetchJSONReal("https://example.com/api", nil) {
t.Error("expected fetchJSONReal to fail on connection error")
}
if fetchTextReal("https://example.com/text") != "" {
t.Error("expected fetchTextReal to fail on connection error")
}
// 3. os.Create error
httpClient.Transport = &mockTripper{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString("ok")),
}, nil
},
}
if downloadReal("https://example.com/file", "/nonexistent-dir/dest") {
t.Error("expected downloadReal to fail when creating destination file fails")
}
// 4. json Decode error
httpClient.Transport = &mockTripper{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString("invalid json")),
}, nil
},
}
var v any
if fetchJSONReal("https://example.com/api", &v) {
t.Error("expected fetchJSONReal to fail on invalid JSON")
}
// 5. io.ReadAll error
httpClient.Transport = &mockTripper{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(&errReader{}),
}, nil
},
}
if fetchTextReal("https://example.com/text") != "" {
t.Error("expected fetchTextReal to fail on read error")
}
}
func TestDownloadRealFallbackChain(t *testing.T) {
defer resetMocks()
tmpDir := t.TempDir()
destFile := filepath.Join(tmpDir, "out.txt")
// Case 1: aria2c works
var aria2cCalled bool
var curlCalled bool
hasCmd = func(name string) bool {
if name == "aria2c" || name == "curl" {
return true
}
return false
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[0] == "aria2c" {
aria2cCalled = true
_ = os.WriteFile(destFile, []byte("aria2c content"), 0644)
return CmdResult{ExitCode: 0}
}
if argv[0] == "curl" {
curlCalled = true
return CmdResult{ExitCode: 0}
}
return CmdResult{ExitCode: 1}
}
success := downloadReal("https://example.com/file", destFile)
if !success {
t.Fatal("expected download via aria2c to succeed")
}
if !aria2cCalled {
t.Error("expected aria2c to be called")
}
if curlCalled {
t.Error("expected curl NOT to be called when aria2c succeeds")
}
// Case 2: aria2c fails, falls back to curl, curl succeeds
resetMocks()
aria2cCalled = false
curlCalled = false
hasCmd = func(name string) bool {
if name == "aria2c" || name == "curl" {
return true
}
return false
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[0] == "aria2c" {
aria2cCalled = true
return CmdResult{ExitCode: 1, Err: fmt.Errorf("aria2c simulated error")}
}
if argv[0] == "curl" {
curlCalled = true
_ = os.WriteFile(destFile, []byte("curl content"), 0644)
return CmdResult{ExitCode: 0}
}
return CmdResult{ExitCode: 1}
}
success = downloadReal("https://example.com/file", destFile)
if !success {
t.Fatal("expected download to succeed via curl fallback")
}
if !aria2cCalled {
t.Error("expected aria2c to be attempted")
}
if !curlCalled {
t.Error("expected curl to be attempted after aria2c failed")
}
// Case 3: aria2c fails, curl fails, falls back to Go HTTP client
resetMocks()
aria2cCalled = false
curlCalled = false
hasCmd = func(name string) bool {
if name == "aria2c" || name == "curl" {
return true
}
return false
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[0] == "aria2c" {
aria2cCalled = true
return CmdResult{ExitCode: 1, Err: fmt.Errorf("aria2c simulated error")}
}
if argv[0] == "curl" {
curlCalled = true
return CmdResult{ExitCode: 1, Err: fmt.Errorf("curl simulated error")}
}
return CmdResult{ExitCode: 1}
}
oldTransport := httpClient.Transport
defer func() { httpClient.Transport = oldTransport }()
httpClient.Transport = &mockTripper{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString("go http content")),
}, nil
},
}
success = downloadReal("https://example.com/file", destFile)
if !success {
t.Fatal("expected download to succeed via Go http fallback")
}
if !aria2cCalled {
t.Error("expected aria2c to be attempted")
}
if !curlCalled {
t.Error("expected curl to be attempted")
}
data, _ := os.ReadFile(destFile)
if string(data) != "go http content" {
t.Errorf("expected file content to be 'go http content', got %q", string(data))
}
}
+188
View File
@@ -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
View File
@@ -0,0 +1,170 @@
package main
import (
"bytes"
"fmt"
"io"
"os"
"runtime"
"sync"
)
// cpuWorkers returns the parallelism level for install/check work.
// Defaults to runtime.NumCPU(); overridable via BOOTSTRAP_PARALLELISM
// (e.g. for tests / constrained hosts) and clamped to >=1.
func cpuWorkers() int {
if v := os.Getenv("BOOTSTRAP_PARALLELISM"); v != "" {
var n int
_, _ = fmt.Sscanf(v, "%d", &n)
if n >= 1 {
return n
}
}
n := runtime.NumCPU()
if n < 1 {
return 1
}
return n
}
// httpWorkersCap is the polite ceiling for HTTP-bound concurrency when no
// GitHub token has been provided (GitHub anon rate-limits at 60/hour).
// Authenticated requests get 5000/hour so we lift the cap when a token is
// available — see githubTokenSet.
const httpWorkersCap = 8
var githubTokenSet bool
// httpWorkers caps cpuWorkers() to httpWorkersCap unless a GitHub token has
// been supplied (in which case we use the full processor count).
func httpWorkers() int {
n := cpuWorkers()
if githubTokenSet {
return n
}
if n > httpWorkersCap {
return httpWorkersCap
}
return n
}
// parallelDo runs fn(i, items[i]) over items with at most maxWorkers
// goroutines in flight. Returns once every task has finished. Order of
// completion is not guaranteed; fn is responsible for its own synchronization
// when writing shared state.
func parallelDo[T any](items []T, maxWorkers int, fn func(i int, item T)) {
if len(items) == 0 {
return
}
if maxWorkers < 1 {
maxWorkers = 1
}
if maxWorkers > len(items) {
maxWorkers = len(items)
}
sem := make(chan struct{}, maxWorkers)
var wg sync.WaitGroup
for i, item := range items {
wg.Add(1)
sem <- struct{}{}
go func(i int, item T) {
defer wg.Done()
defer func() { <-sem }()
fn(i, item)
}(i, item)
}
wg.Wait()
}
// taskOutput is the per-call sink for status text and subprocess output.
//
// Two modes:
//
// Sequential (label==""): Printf goes straight to os.Stdout, and Writer()
// returns nil so runCmd falls back to its default streamed-to-stdout mode.
// Behavior matches the pre-parallelism code exactly.
//
// Captured (label!=""): Printf and runCmd output both land in an internal
// buffer; Flush() prints the whole block at once with a " [label] " prefix
// on every line. Used by parallel install workers so concurrent output
// doesn't interleave.
type taskOutput struct {
label string
buf bytes.Buffer
mu sync.Mutex
}
func newSerialOutput() *taskOutput { return &taskOutput{} }
func newCapturedOutput(label string) *taskOutput {
return &taskOutput{label: label}
}
// Printf writes to the task's destination.
func (t *taskOutput) Printf(format string, args ...any) {
if t.label == "" {
fmt.Printf(format, args...)
return
}
t.mu.Lock()
defer t.mu.Unlock()
fmt.Fprintf(&t.buf, format, args...)
}
// Println writes a line to the task's destination.
func (t *taskOutput) Println(args ...any) {
if t.label == "" {
fmt.Println(args...)
return
}
t.mu.Lock()
defer t.mu.Unlock()
fmt.Fprintln(&t.buf, args...)
}
// Writer returns the io.Writer that runCmd/runShell should target via
// CmdOpts.Out. Returns nil in sequential mode (preserves streamed stdout).
func (t *taskOutput) Writer() io.Writer {
if t.label == "" {
return nil
}
return &lockingWriter{mu: &t.mu, w: &t.buf}
}
// Flush emits the captured buffer to w with the task label prefixed onto
// every line. Idempotent and a no-op in sequential mode.
func (t *taskOutput) Flush(w io.Writer) {
if t.label == "" {
return
}
t.mu.Lock()
defer t.mu.Unlock()
if t.buf.Len() == 0 {
return
}
if w == nil {
w = os.Stdout
}
prefix := fmt.Sprintf(" [%s] ", t.label)
lines := bytes.Split(t.buf.Bytes(), []byte{'\n'})
for i, line := range lines {
if i == len(lines)-1 && len(line) == 0 {
break
}
fmt.Fprintf(w, "%s%s\n", prefix, line)
}
t.buf.Reset()
}
// lockingWriter is a thin io.Writer that holds the taskOutput mutex while
// writing, so runCmd / runShell can stream into the buffer concurrently with
// status Printf calls on the same task without corrupting the buffer.
type lockingWriter struct {
mu *sync.Mutex
w io.Writer
}
func (l *lockingWriter) Write(p []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.w.Write(p)
}
+356
View File
@@ -0,0 +1,356 @@
package main
import (
"bytes"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
)
func TestCpuWorkers(t *testing.T) {
t.Setenv("BOOTSTRAP_PARALLELISM", "4")
if n := cpuWorkers(); n != 4 {
t.Errorf("expected 4 workers via env, got %d", n)
}
t.Setenv("BOOTSTRAP_PARALLELISM", "")
if n := cpuWorkers(); n < 1 {
t.Errorf("expected at least 1 worker, got %d", n)
}
}
func TestHttpWorkersRespectsCap(t *testing.T) {
t.Setenv("BOOTSTRAP_PARALLELISM", "32")
defer func() { githubTokenSet = false }()
githubTokenSet = false
if n := httpWorkers(); n != httpWorkersCap {
t.Errorf("expected http workers capped at %d without token, got %d", httpWorkersCap, n)
}
githubTokenSet = true
if n := httpWorkers(); n != 32 {
t.Errorf("expected http workers uncapped to 32 with token, got %d", n)
}
}
func TestParallelDoConcurrency(t *testing.T) {
const items = 16
var inFlight, peak int32
work := make([]int, items)
for i := range work {
work[i] = i
}
parallelDo(work, 4, func(_ int, _ int) {
now := atomic.AddInt32(&inFlight, 1)
for {
cur := atomic.LoadInt32(&peak)
if now <= cur || atomic.CompareAndSwapInt32(&peak, cur, now) {
break
}
}
// brief busy spin to keep multiple workers overlapping
for i := 0; i < 50000; i++ {
_ = i * i
}
atomic.AddInt32(&inFlight, -1)
})
if peak < 2 {
t.Errorf("expected at least 2 concurrent workers, observed peak %d", peak)
}
if peak > 4 {
t.Errorf("worker cap violated: peak %d > 4", peak)
}
}
func TestParallelDoEmpty(t *testing.T) {
called := false
parallelDo([]int{}, 4, func(_ int, _ int) { called = true })
if called {
t.Error("expected fn to not be invoked on empty input")
}
}
func TestParallelDoAllItemsProcessed(t *testing.T) {
items := []int{1, 2, 3, 4, 5, 6, 7, 8}
var sum int64
parallelDo(items, 3, func(_ int, v int) {
atomic.AddInt64(&sum, int64(v))
})
if sum != 36 {
t.Errorf("expected sum 36, got %d", sum)
}
}
func TestTaskOutputSerial(t *testing.T) {
t.Cleanup(resetMocks)
tOut := newSerialOutput()
// Serial: writes go straight to stdout; Writer() returns nil.
if tOut.Writer() != nil {
t.Error("expected Writer() to be nil in serial mode")
}
// flushing serial mode is a no-op
var buf bytes.Buffer
tOut.Flush(&buf)
if buf.Len() != 0 {
t.Error("expected Flush to be no-op in serial mode")
}
}
func TestTaskOutputCaptured(t *testing.T) {
tOut := newCapturedOutput("mypkg")
tOut.Printf("first %s\n", "line")
tOut.Println("second line")
tOut.Printf("third line")
var buf bytes.Buffer
tOut.Flush(&buf)
got := buf.String()
expected := " [mypkg] first line\n [mypkg] second line\n [mypkg] third line\n"
if got != expected {
t.Errorf("captured output mismatch:\nexpected:\n%q\ngot:\n%q", expected, got)
}
// Flush is idempotent (second call writes nothing).
buf.Reset()
tOut.Flush(&buf)
if buf.Len() != 0 {
t.Errorf("expected second Flush to be empty, got %q", buf.String())
}
}
func TestWithTaskOutputRoutesPrints(t *testing.T) {
tOut := newCapturedOutput("worker")
withTaskOutput(tOut, func() {
taskPrintf("hello %d\n", 7)
taskPrintln("world")
})
var buf bytes.Buffer
tOut.Flush(&buf)
got := buf.String()
if !strings.Contains(got, "[worker] hello 7") || !strings.Contains(got, "[worker] world") {
t.Errorf("expected routed output with prefix, got: %q", got)
}
}
func TestWithTaskOutputUnsetsAfter(t *testing.T) {
tOut := newCapturedOutput("worker")
withTaskOutput(tOut, func() {
if currentTask() == nil {
t.Error("expected active task inside withTaskOutput")
}
})
if currentTask() != nil {
t.Error("expected no active task after withTaskOutput returns")
}
}
func TestParallelTaskOutputIsolation(t *testing.T) {
// Each goroutine should see only its own task output, even though
// they all share package-global state.
const n = 8
var wg sync.WaitGroup
results := make([]string, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
label := "g" + string(rune('a'+i))
tOut := newCapturedOutput(label)
withTaskOutput(tOut, func() {
taskPrintf("from %s\n", label)
})
var buf bytes.Buffer
tOut.Flush(&buf)
results[i] = buf.String()
}(i)
}
wg.Wait()
for i, got := range results {
label := "g" + string(rune('a'+i))
want := " [" + label + "] from " + label + "\n"
if got != want {
t.Errorf("goroutine %d: expected %q, got %q", i, want, got)
}
}
}
func TestCmdOptsOutRoutesRunCmd(t *testing.T) {
defer resetMocks()
var buf bytes.Buffer
// Pick a command guaranteed to exist and produce output.
result := runCmdReal([]string{"echo", "hello-out"}, CmdOpts{Out: &buf})
if !result.OK() {
t.Fatalf("echo failed: %v", result.Err)
}
got := buf.String()
if !strings.Contains(got, "$ echo hello-out") {
t.Errorf("expected command echo in Out, got: %q", got)
}
if !strings.Contains(got, "hello-out") {
t.Errorf("expected stdout in Out, got: %q", got)
}
}
func TestCmdOptsOutRoutesRunShell(t *testing.T) {
defer resetMocks()
var buf bytes.Buffer
result := runShellReal("echo shell-out", CmdOpts{Out: &buf})
if !result.OK() {
t.Fatalf("shell failed: %v", result.Err)
}
got := buf.String()
if !strings.Contains(got, "$ echo shell-out") {
t.Errorf("expected command echo in Out, got: %q", got)
}
if !strings.Contains(got, "shell-out") {
t.Errorf("expected stdout in Out, got: %q", got)
}
}
func TestIssueLogRoutesViaTaskOutput(t *testing.T) {
defer resetMocks()
// Suppress fallback stdout for the non-task branch.
issueLogWriter = &bytes.Buffer{}
tOut := newCapturedOutput("isolated")
withTaskOutput(tOut, func() {
warn("a warning")
errLog("an error")
})
var buf bytes.Buffer
tOut.Flush(&buf)
out := buf.String()
if !strings.Contains(out, "[isolated] [WARN] a warning") {
t.Errorf("expected routed WARN line, got: %q", out)
}
if !strings.Contains(out, "[isolated] [ERROR] an error") {
t.Errorf("expected routed ERROR line, got: %q", out)
}
// Issues should still flow into the global issues slice.
if !hasErrors() {
t.Error("expected errLog to register a global error even when routed via task")
}
}
func TestParallelPartitionOrdering(t *testing.T) {
items := []int{1, 2, 3, 4, 5, 6, 7, 8}
even, odd := parallelPartition(items, func(v int) bool { return v%2 == 0 })
wantEven := []int{2, 4, 6, 8}
wantOdd := []int{1, 3, 5, 7}
if !equalIntSlices(even, wantEven) {
t.Errorf("evens: want %v, got %v", wantEven, even)
}
if !equalIntSlices(odd, wantOdd) {
t.Errorf("odds: want %v, got %v", wantOdd, odd)
}
}
func equalIntSlices(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestGoidUnique(t *testing.T) {
mainID := goid()
if mainID == 0 {
t.Error("goid returned 0 for main goroutine")
}
var wg sync.WaitGroup
wg.Add(1)
var childID uint64
go func() {
defer wg.Done()
childID = goid()
}()
wg.Wait()
if childID == 0 {
t.Error("goid returned 0 for child goroutine")
}
if childID == mainID {
t.Errorf("expected child goroutine id %d to differ from main id %d", childID, mainID)
}
}
// Verify BOOTSTRAP_PARALLELISM=0 falls back to NumCPU rather than 0 workers.
func TestCpuWorkersInvalidEnv(t *testing.T) {
t.Setenv("BOOTSTRAP_PARALLELISM", "0")
if n := cpuWorkers(); n < 1 {
t.Errorf("expected fallback to runtime.NumCPU for invalid env, got %d", n)
}
}
func TestCpuWorkersBadEnv(t *testing.T) {
t.Setenv("BOOTSTRAP_PARALLELISM", "notanumber")
if n := cpuWorkers(); n < 1 {
t.Errorf("expected fallback for non-numeric env, got %d", n)
}
}
// Ensure the parallelDo guard returns when len(items) == 0 even with
// maxWorkers larger than 1. Smoke test for the early return.
func TestParallelDoBigWorkersSmallInput(t *testing.T) {
items := []string{"only"}
called := 0
var mu sync.Mutex
parallelDo(items, 32, func(_ int, _ string) {
mu.Lock()
called++
mu.Unlock()
})
if called != 1 {
t.Errorf("expected exactly 1 invocation, got %d", called)
}
}
// Confirm runCmd inside withTaskOutput auto-routes via Out without callers
// having to set it explicitly — that's the contract that lets install
// handlers rely on taskOut().
func TestRunCmdInsideWithTaskOutput(t *testing.T) {
defer resetMocks()
tOut := newCapturedOutput("autoroute")
withTaskOutput(tOut, func() {
runCmdReal([]string{"echo", "auto"}, CmdOpts{Out: taskOut()})
})
var buf bytes.Buffer
tOut.Flush(&buf)
got := buf.String()
if !strings.Contains(got, "[autoroute] $ echo auto") {
t.Errorf("expected routed echo command, got: %q", got)
}
if !strings.Contains(got, "[autoroute] auto") {
t.Errorf("expected routed echo stdout, got: %q", got)
}
}
// Sanity: when no token is in env and the user prompt is suppressed,
// httpWorkers stays capped. Just exercise the path; ensures no panic.
func TestPromptGitHubTokenNoOp(t *testing.T) {
defer func() { githubTokenSet = false }()
githubTokenSet = false
os.Unsetenv("GITHUB_TOKEN")
// We can't easily prompt in a test; just confirm cap is in effect.
n := httpWorkers()
if n > httpWorkersCap {
t.Errorf("expected http workers <= %d without token, got %d", httpWorkersCap, n)
}
}
+259
View File
@@ -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
View File
@@ -0,0 +1,218 @@
package main
import (
"testing"
"time"
)
func TestDetectPkgMgr(t *testing.T) {
defer resetMocks()
// Case 1: macOS should return brew
isMacOS = true
if mgr := detectPkgMgr(); mgr != "brew" {
t.Errorf("expected brew on macOS, got %q", mgr)
}
// Case 2: Linux with dnf
isMacOS = false
hasCmd = func(name string) bool {
return name == "dnf"
}
if mgr := detectPkgMgr(); mgr != "dnf" {
t.Errorf("expected dnf, got %q", mgr)
}
// Case 3: Linux with apt-get
hasCmd = func(name string) bool {
return name == "apt-get"
}
if mgr := detectPkgMgr(); mgr != "apt-get" {
t.Errorf("expected apt-get, got %q", mgr)
}
// Case 4: Linux with pacman
hasCmd = func(name string) bool {
return name == "pacman"
}
if mgr := detectPkgMgr(); mgr != "pacman" {
t.Errorf("expected pacman, got %q", mgr)
}
// Case 5: No package manager found (exits)
hasCmd = func(name string) bool {
return false
}
var exited bool
var exitCode int
osExit = func(code int) {
exited = true
exitCode = code
}
detectPkgMgr()
if !exited || exitCode != 1 {
t.Errorf("expected exit with code 1, exited=%v code=%d", exited, exitCode)
}
}
func TestResolveSystemPkgs(t *testing.T) {
defer resetMocks()
pkgMgr = "apt-get"
resolved, skipped := resolveSystemPkgs([]string{"ffmpeg-free", "lua", "podman", "docker-compose"})
// ffmpeg-free -> ffmpeg, lua -> lua5.4, podman -> podman, docker-compose is not overridden for apt-get
expectedResolved := []string{"ffmpeg", "lua5.4", "podman", "docker-compose"}
if len(resolved) != len(expectedResolved) {
t.Fatalf("expected resolved length %d, got %d", len(expectedResolved), len(resolved))
}
for i, r := range resolved {
if r != expectedResolved[i] {
t.Errorf("at index %d: expected %q, got %q", i, expectedResolved[i], r)
}
}
if len(skipped) != 0 {
t.Errorf("expected no skipped packages, got %v", skipped)
}
// Test skip override
pkgMgr = "dnf"
resolved, skipped = resolveSystemPkgs([]string{"docker-compose", "rg"})
// docker-compose -> skipped, rg -> ripgrep
if len(resolved) != 1 || resolved[0] != "ripgrep" {
t.Errorf("expected resolution to [ripgrep], got %v", resolved)
}
if len(skipped) != 1 || skipped[0] != "docker-compose" {
t.Errorf("expected skipped to be [docker-compose], got %v", skipped)
}
// brew: buildah -> skipped
pkgMgr = "brew"
resolved, skipped = resolveSystemPkgs([]string{"buildah", "rg"})
if len(resolved) != 1 || resolved[0] != "ripgrep" {
t.Errorf("expected [ripgrep], got %v", resolved)
}
// pacman: docker-ce-rootless-extras and vagrant are AUR-only -> skipped;
// pipx is replaced with python-pipx.
pkgMgr = "pacman"
resolved, skipped = resolveSystemPkgs([]string{"docker-ce-rootless-extras", "vagrant", "pipx", "rg"})
if len(resolved) != 2 || resolved[0] != "python-pipx" || resolved[1] != "ripgrep" {
t.Errorf("expected [python-pipx ripgrep], got %v", resolved)
}
if len(skipped) != 2 || skipped[0] != "docker-ce-rootless-extras" || skipped[1] != "vagrant" {
t.Errorf("expected skipped [docker-ce-rootless-extras vagrant], got %v", skipped)
}
}
func TestIsSystemPkgInstalled(t *testing.T) {
defer resetMocks()
// Case dnf: rpm -q
pkgMgr = "dnf"
var probeArgv []string
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
probeArgv = argv
return CmdResult{ExitCode: 0}, true
}
if !isSystemPkgInstalled("git") {
t.Error("expected true when rpm returns 0")
}
if len(probeArgv) < 3 || probeArgv[0] != "rpm" || probeArgv[2] != "git" {
t.Errorf("unexpected probe argv: %v", probeArgv)
}
// Case apt-get: dpkg-query
pkgMgr = "apt-get"
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
probeArgv = argv
return CmdResult{ExitCode: 0, Stdout: []byte("install ok installed")}, true
}
if !isSystemPkgInstalled("git") {
t.Error("expected true when dpkg-query output contains install ok installed")
}
if len(probeArgv) < 4 || probeArgv[0] != "dpkg-query" {
t.Errorf("unexpected probe argv: %v", probeArgv)
}
// Case pacman: pacman -Qi
pkgMgr = "pacman"
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
probeArgv = argv
return CmdResult{ExitCode: 0}, true
}
if !isSystemPkgInstalled("git") {
t.Error("expected true when pacman returns 0")
}
// Case brew: brew list --formula / --cask
pkgMgr = "brew"
hasCmd = func(name string) bool {
return name == "brew"
}
var probeCalls [][]string
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
probeCalls = append(probeCalls, argv)
if argv[2] == "--formula" {
return CmdResult{ExitCode: 1}, true
}
return CmdResult{ExitCode: 0}, true // succeeds on second call
}
if !isSystemPkgInstalled("git") {
t.Error("expected true when brew list succeeds")
}
if len(probeCalls) != 2 {
t.Errorf("expected 2 brew calls, got %d", len(probeCalls))
}
}
func TestIsFlatpakInstalled(t *testing.T) {
defer resetMocks()
hasCmd = func(name string) bool {
return name == "flatpak"
}
var probeArgv []string
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
probeArgv = argv
return CmdResult{ExitCode: 0}, true
}
if !isFlatpakInstalled("org.gimp.GIMP") {
t.Error("expected true")
}
if len(probeArgv) < 3 || probeArgv[0] != "flatpak" || probeArgv[2] != "org.gimp.GIMP" {
t.Errorf("unexpected probe argv: %v", probeArgv)
}
}
func TestPkgMgrEdgeCases(t *testing.T) {
defer resetMocks()
pkgMgr = "brew"
hasCmd = func(name string) bool { return false }
if isSystemPkgInstalled("git") {
t.Error("expected false when brew is not installed")
}
resetMocks()
pkgMgr = "brew"
hasCmd = func(name string) bool { return true }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 1}, true
}
if isSystemPkgInstalled("git") {
t.Error("expected false when brew list fails")
}
resetMocks()
pkgMgr = "unsupported"
if isSystemPkgInstalled("git") {
t.Error("expected false for unsupported package manager")
}
resetMocks()
hasCmd = func(name string) bool { return false }
if isFlatpakInstalled("org.gimp.GIMP") {
t.Error("expected false when flatpak command not found")
}
}
+610
View File
@@ -0,0 +1,610 @@
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 isZshDefault() bool {
if !hasCmd("zsh") {
return false
}
zshPath := "/bin/zsh"
if r, ok := probe([]string{"which", "zsh"}, 5*time.Second); ok && r.ExitCode == 0 {
if p := strings.TrimSpace(string(r.Stdout)); p != "" {
zshPath = p
}
}
username := invokingUser()
if username == "" {
return false
}
u, err := user.Lookup(username)
if err != nil {
return false
}
current := userLoginShell(u.Uid)
return current == zshPath
}
func cloneNvimConfig() {
home, _ := os.UserHomeDir()
configDir := filepath.Join(home, ".config", "nvim")
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
View File
File diff suppressed because it is too large Load Diff
+264
View File
@@ -0,0 +1,264 @@
package main
import (
"fmt"
"os"
"os/user"
"path/filepath"
"strings"
"testing"
"time"
)
func TestReadWriteProgressStep(t *testing.T) {
defer resetMocks()
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "progress.config")
// Set up mock file operations
var fileContents []byte
osReadFile = func(name string) ([]byte, error) {
if name == configPath {
if fileContents == nil {
return nil, os.ErrNotExist
}
return fileContents, nil
}
return nil, os.ErrNotExist
}
osWriteFile = func(name string, data []byte, perm os.FileMode) error {
if name == configPath {
fileContents = data
return nil
}
return fmt.Errorf("unexpected file write to %s", name)
}
// Override progressConfigPath
originalPathFunc := progressConfigPath
progressConfigPath = func() string {
return configPath
}
defer func() {
progressConfigPath = originalPathFunc
}()
// 1. Initial state (no file)
disableProgressTracking = false
step := readProgressStep()
if step != 0 {
t.Errorf("expected step 0 initially, got %d", step)
}
// 2. Write step 1
err := writeProgressStep(1)
if err != nil {
t.Fatalf("failed to write step 1: %v", err)
}
step = readProgressStep()
if step != 1 {
t.Errorf("expected step 1 after writing, got %d", step)
}
// 3. Write step 2
err = writeProgressStep(2)
if err != nil {
t.Fatalf("failed to write step 2: %v", err)
}
step = readProgressStep()
if step != 2 {
t.Errorf("expected step 2 after writing, got %d", step)
}
}
func TestRunMainStep2Exit(t *testing.T) {
defer resetMocks()
// Mock file read to return step=2
osReadFile = func(name string) ([]byte, error) {
if strings.HasSuffix(name, "progress.config") {
return []byte("step=2\n"), nil
}
return nil, os.ErrNotExist
}
disableProgressTracking = false
var exited bool
osExit = func(code int) {
exited = true
}
runMain([]string{"bootstrap_environment"})
if exited {
t.Error("expected runMain to return normally rather than exit when step=2")
}
}
func TestRunMainStep0OnlyZshGitCurl(t *testing.T) {
defer resetMocks()
u, _ := user.Current()
uid := "1000"
username := os.Getenv("USER")
if u != nil {
uid = u.Uid
username = u.Username
}
if username == "" {
username = "user"
}
// Mock file read to return nothing (step=0)
var writtenData []byte
osReadFile = func(name string) ([]byte, error) {
if strings.HasSuffix(name, "progress.config") {
return nil, os.ErrNotExist
}
if strings.HasSuffix(name, "/etc/passwd") {
return []byte(fmt.Sprintf("%s:x:%s:%s::/home/user:/bin/bash\n", username, uid, uid)), nil
}
return nil, os.ErrNotExist
}
osWriteFile = func(name string, data []byte, perm os.FileMode) error {
if strings.HasSuffix(name, "progress.config") {
writtenData = data
return nil
}
return nil
}
hasCmd = func(name string) bool {
return name == "zsh" || name == "git" || name == "curl"
}
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "which" && argv[1] == "zsh" {
return CmdResult{ExitCode: 0, Stdout: []byte("/bin/zsh\n")}, true
}
return CmdResult{ExitCode: 1}, true
}
disableProgressTracking = false
// Mock user selection: Accept
stdin = strings.NewReader("y\n")
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
var runShellCalls []string
runShell = func(cmd string, opts CmdOpts) CmdResult {
runShellCalls = append(runShellCalls, cmd)
return CmdResult{ExitCode: 0}
}
var exited bool
osExit = func(code int) {
exited = true
}
runMain([]string{"bootstrap_environment"})
if !exited {
t.Error("expected runMain to exit at step 1")
}
// Verify step 1 was written to progress.config
if !strings.Contains(string(writtenData), "step=1") {
t.Errorf("expected step=1 written to progress.config, got: %q", string(writtenData))
}
}
func TestRunMainStep0TransitionToStep2(t *testing.T) {
defer resetMocks()
u, _ := user.Current()
uid := "1000"
username := os.Getenv("USER")
if u != nil {
uid = u.Uid
username = u.Username
}
if username == "" {
username = "user"
}
// Zsh default is true, oh-my-zsh and zsh/git/curl already installed
// Under step=0, we should transition directly to step=1 and then execute step 2 in the same run.
var writtenData []byte
osReadFile = func(name string) ([]byte, error) {
if strings.HasSuffix(name, "progress.config") {
return nil, os.ErrNotExist
}
if strings.HasSuffix(name, "/etc/passwd") {
// passwd already says zsh is default shell
return []byte(fmt.Sprintf("%s:x:%s:%s::/home/user:/bin/zsh\n", username, uid, uid)), nil
}
return nil, os.ErrNotExist
}
osWriteFile = func(name string, data []byte, perm os.FileMode) error {
if strings.HasSuffix(name, "progress.config") {
writtenData = data
return nil
}
return nil
}
osStat = func(name string) (os.FileInfo, error) {
if strings.HasSuffix(name, "/go") ||
strings.HasSuffix(name, "/firecracker") ||
strings.HasSuffix(name, "/nvim") ||
strings.HasSuffix(name, ".oh-my-zsh") {
return nil, nil
}
return nil, os.ErrNotExist
}
hasCmd = func(name string) bool {
return true // all installed
}
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "which" && argv[1] == "zsh" {
return CmdResult{ExitCode: 0, Stdout: []byte("/bin/zsh\n")}, true
}
if len(argv) >= 3 && argv[1] == "install" && argv[2] == "--list" {
return CmdResult{ExitCode: 0, Stdout: []byte(" 3.10.0\n 3.11.0\n")}, true
}
return CmdResult{ExitCode: 0}, true
}
disableProgressTracking = false
// Mock user selection: Accept
stdin = strings.NewReader("y\n")
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 0}
}
runShell = func(cmd string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 0}
}
var exited bool
osExit = func(code int) {
exited = true
}
runMain([]string{"bootstrap_environment", "--only", "custom"})
// Since they are already installed, it will continue.
// Since we mock all installed, total items to install for Step 2 is 0.
// It should exit normally or print all packages installed.
if exited {
t.Errorf("did not expect exit during transition path. Issues: %v", issues)
}
// Should have written step 1, then eventually step 2
if !strings.Contains(string(writtenData), "step=2") {
t.Errorf("expected step=2 written to progress.config, got: %q", string(writtenData))
}
}
+285
View File
@@ -0,0 +1,285 @@
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},
}
}
func setupCUDARepo() {
if pkgMgr != "apt-get" {
return
}
if repoFileExists("/etc/apt/sources.list.d/cuda.list") || repoFileExists("/etc/apt/sources.list.d/cuda-keyring.list") {
return
}
versionID := strings.Trim(osReleaseField("VERSION_ID"), `"`)
ubuntuVer := strings.ReplaceAll(versionID, ".", "")
if ubuntuVer == "" {
ubuntuVer = "2204"
}
debURL := fmt.Sprintf("https://developer.download.nvidia.com/compute/cuda/repos/ubuntu%s/x86_64/cuda-keyring_1.1-1_all.deb", ubuntuVer)
runShell(
fmt.Sprintf("curl -fsSL %s -o /tmp/cuda-keyring.deb && "+
"sudo dpkg -i /tmp/cuda-keyring.deb && "+
"sudo apt-get update", debURL),
CmdOpts{},
)
}
func setupNvidiaContainerToolkitRepo() {
if pkgMgr != "apt-get" {
return
}
if repoFileExists("/etc/apt/sources.list.d/nvidia-container-toolkit.list") {
return
}
runShell(
"curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | "+
"sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg && "+
"curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | "+
"sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | "+
"sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list >/dev/null && "+
"sudo apt-get update",
CmdOpts{},
)
}
+299
View File
@@ -0,0 +1,299 @@
package main
import (
"os"
"strings"
"testing"
)
func TestRepoFileExists(t *testing.T) {
defer resetMocks()
var statPath string
osStat = func(name string) (os.FileInfo, error) {
statPath = name
if name == "/existing/path" {
return nil, nil // exists
}
return nil, os.ErrNotExist
}
if !repoFileExists("/nonexistent", "/existing/path") {
t.Error("expected true when at least one path exists")
}
if statPath != "/existing/path" {
t.Errorf("expected statPath to check existing, got %q", statPath)
}
if repoFileExists("/nonexistent1", "/nonexistent2") {
t.Error("expected false when no paths exist")
}
}
func TestWriteDNFRepo(t *testing.T) {
defer resetMocks()
var runArgv []string
var runInput []byte
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runArgv = argv
runInput = opts.Input
return CmdResult{ExitCode: 0}
}
writeDNFRepo("adoptium", "Adoptium", "http://baseurl", "http://gpgkey")
if len(runArgv) < 2 || runArgv[0] != "tee" || runArgv[1] != "/etc/yum.repos.d/adoptium.repo" {
t.Errorf("unexpected run command: %v", runArgv)
}
content := string(runInput)
if !strings.Contains(content, "[adoptium]") || !strings.Contains(content, "gpgkey=http://gpgkey") {
t.Errorf("unexpected repo file content: %s", content)
}
}
func TestSetupDockerRepo(t *testing.T) {
defer resetMocks()
// Case 1: DNF
pkgMgr = "dnf"
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
var runArgv []string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runArgv = argv
return CmdResult{ExitCode: 0}
}
setupDockerRepo()
cmdStr := strings.Join(runArgv, " ")
if runArgv[0] != "dnf" || !strings.Contains(cmdStr, "docker-ce.repo") {
t.Errorf("unexpected run command for DNF Docker setup: %v", runArgv)
}
// Case 2: APT
pkgMgr = "apt-get"
var runShellCmds []string
runShell = func(cmd string, opts CmdOpts) CmdResult {
runShellCmds = append(runShellCmds, cmd)
if strings.Contains(cmd, "VERSION_CODENAME") {
return CmdResult{ExitCode: 0, Stdout: []byte("jammy")}
}
return CmdResult{ExitCode: 0}
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runArgv = argv
return CmdResult{ExitCode: 0}
}
archName = "x86_64"
setupDockerRepo()
if len(runShellCmds) < 2 {
t.Fatalf("expected at least 2 shell commands, got %v", runShellCmds)
}
if !strings.Contains(runShellCmds[0], "docker.gpg") {
t.Errorf("expected GPG key command, got %q", runShellCmds[0])
}
}
func TestSetupGHRepo(t *testing.T) {
defer resetMocks()
// Case 1: DNF
pkgMgr = "dnf"
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
var runArgv []string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runArgv = argv
return CmdResult{ExitCode: 0}
}
setupGHRepo()
cmdStr := strings.Join(runArgv, " ")
if runArgv[0] != "dnf" || !strings.Contains(cmdStr, "gh-cli.repo") {
t.Errorf("unexpected run command for DNF GH setup: %v", runArgv)
}
}
func TestSetupChromeRepo(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
archName = "aarch64" // Chrome doesn't support aarch64 on linux, should warn and skip
var warned bool
issuesMu.Lock()
issues = nil
issuesMu.Unlock()
setupChromeRepo()
issuesMu.Lock()
for _, iss := range issues {
if strings.Contains(iss, "Google Chrome has no Linux build") {
warned = true
}
}
issuesMu.Unlock()
if !warned {
t.Error("expected warning for aarch64 Linux Chrome setup")
}
}
func TestSetupVivaldiRepo(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
archName = "aarch64" // Vivaldi doesn't support aarch64 on linux, should warn and skip
var warned bool
setupVivaldiRepo()
issuesMu.Lock()
for _, iss := range issues {
if strings.Contains(iss, "Vivaldi repo on this arch is not supported") {
warned = true
}
}
issuesMu.Unlock()
if !warned {
t.Error("expected warning for aarch64 Linux Vivaldi setup")
}
}
func TestSetupTemurinRepo(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
var runArgv []string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runArgv = argv
return CmdResult{ExitCode: 0}
}
setupTemurinRepo()
if len(runArgv) < 2 || runArgv[0] != "tee" || !strings.Contains(runArgv[1], "Adoptium.repo") {
t.Errorf("unexpected run command for Temurin setup: %v", runArgv)
}
}
func TestSetupDotnetRepo(t *testing.T) {
defer resetMocks()
pkgMgr = "apt-get"
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
var runShellCmd string
runShell = func(cmd string, opts CmdOpts) CmdResult {
runShellCmd = cmd
return CmdResult{ExitCode: 0}
}
setupDotnetRepo()
if !strings.Contains(runShellCmd, "packages-microsoft-prod.deb") {
t.Errorf("expected wget/dpkg call for Dotnet, got %q", runShellCmd)
}
}
func TestSetupLazygitCoprRepo(t *testing.T) {
defer resetMocks()
// On non-dnf hosts, it's a no-op.
pkgMgr = "apt-get"
called := false
runCmd = func(argv []string, opts CmdOpts) CmdResult {
called = true
return CmdResult{ExitCode: 0}
}
setupLazygitCoprRepo()
if called {
t.Errorf("expected no-op on apt-get, but runCmd was called")
}
// On dnf with repo absent, copr enable is invoked.
pkgMgr = "dnf"
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
var calls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
calls = append(calls, argv)
return CmdResult{ExitCode: 0}
}
setupLazygitCoprRepo()
joined := ""
for _, c := range calls {
joined += strings.Join(c, " ") + "\n"
}
if !strings.Contains(joined, "copr enable -y dejan/lazygit") {
t.Errorf("expected copr enable call, got: %s", joined)
}
// If the COPR repo file already exists, no commands are run.
osStat = func(name string) (os.FileInfo, error) {
return nil, nil
}
calls = nil
setupLazygitCoprRepo()
if len(calls) != 0 {
t.Errorf("expected no commands when repo file exists, got %v", calls)
}
}
func TestRepoGroups(t *testing.T) {
groups := repoGroups()
if len(groups) != 7 {
t.Errorf("expected 7 repo groups, got %d", len(groups))
}
}
func TestReposAdditionalEdgeCases(t *testing.T) {
defer resetMocks()
osStat = func(name string) (os.FileInfo, error) {
return nil, nil
}
pkgMgr = "dnf"
setupGHRepo()
archName = "x86_64"
setupChromeRepo()
setupVivaldiRepo()
setupTemurinRepo()
pkgMgr = "apt-get"
setupGHRepo()
setupChromeRepo()
setupVivaldiRepo()
setupTemurinRepo()
setupDotnetRepo()
pkgMgr = "dnf"
setupDotnetRepo()
resetMocks()
pkgMgr = "apt-get"
archName = "x86_64"
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
var shellCmds []string
runShell = func(cmd string, opts CmdOpts) CmdResult {
shellCmds = append(shellCmds, cmd)
return CmdResult{ExitCode: 0}
}
var cmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
cmdCalls = append(cmdCalls, argv)
return CmdResult{ExitCode: 0}
}
setupGHRepo()
setupChromeRepo()
setupVivaldiRepo()
setupTemurinRepo()
if len(shellCmds) < 4 {
t.Errorf("expected at least 4 shell commands for APT setup, got %d", len(shellCmds))
}
}
+712
View File
@@ -0,0 +1,712 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// 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,
"nvidia-drivers": true, "cuda-toolkit": true,
"nvidia-container-toolkit": true, "ollama": true,
"huggingface-cli": 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")
case "nvidia-drivers":
return hasCmd("nvidia-smi")
case "cuda-toolkit":
return hasCmd("nvcc") || exists("/usr/local/cuda/bin/nvcc")
case "nvidia-container-toolkit":
return hasCmd("nvidia-ctk")
case "ollama":
return hasCmd("ollama")
case "huggingface-cli":
return hasCmd("huggingface-cli")
}
return isSystemPkgInstalled(pkg)
}
// ── 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)
case "nvidia-drivers":
installNvidiaDrivers()
case "cuda-toolkit":
installCUDAToolkit()
case "nvidia-container-toolkit":
installNvidiaContainerToolkit()
case "ollama":
installOllama()
case "huggingface-cli":
installHuggingFaceCLI()
}
}
func installNvidiaDrivers() {
if pkgMgr != "apt-get" {
errLog("nvidia-drivers can only be automatically installed via apt-get on Ubuntu/Debian")
return
}
fmt.Println(" Installing NVIDIA driver (nvidia-driver-550) ...")
res := pkgInstall("nvidia-driver-550")
if !res.OK() {
errLog(fmt.Sprintf("failed to install nvidia-driver-550: %v", res.Err))
}
}
func installCUDAToolkit() {
if pkgMgr != "apt-get" {
errLog("cuda-toolkit can only be automatically installed via apt-get on Ubuntu/Debian")
return
}
fmt.Println(" Setting up CUDA repository keyring ...")
setupCUDARepo()
fmt.Println(" Installing cuda-toolkit ...")
res := pkgInstall("cuda-toolkit")
if !res.OK() {
errLog(fmt.Sprintf("failed to install cuda-toolkit: %v", res.Err))
return
}
fmt.Println(" Configuring system path for CUDA ...")
appendProfileLine("cuda", `export PATH="/usr/local/cuda/bin:$PATH"`)
appendProfileLine("cuda", `export LD_LIBRARY_PATH="/usr/local/cuda/lib64:$LD_LIBRARY_PATH"`)
}
func installNvidiaContainerToolkit() {
if pkgMgr != "apt-get" {
errLog("nvidia-container-toolkit can only be automatically installed via apt-get on Ubuntu/Debian")
return
}
fmt.Println(" Setting up NVIDIA Container Toolkit repository ...")
setupNvidiaContainerToolkitRepo()
fmt.Println(" Installing nvidia-container-toolkit ...")
res := pkgInstall("nvidia-container-toolkit")
if !res.OK() {
errLog(fmt.Sprintf("failed to install nvidia-container-toolkit: %v", res.Err))
return
}
fmt.Println(" Configuring Docker runtime for NVIDIA Container Toolkit ...")
configureRes := runCmd([]string{"nvidia-ctk", "runtime", "configure", "--runtime=docker"}, CmdOpts{AsSudo: true})
if !configureRes.OK() {
warn(fmt.Sprintf("failed to configure docker runtime: %v", configureRes.Err))
}
fmt.Println(" Restarting Docker service ...")
restartRes := runCmd([]string{"systemctl", "restart", "docker"}, CmdOpts{AsSudo: true})
if !restartRes.OK() {
warn(fmt.Sprintf("failed to restart docker service: %v", restartRes.Err))
}
}
func installOllama() {
fmt.Println(" Installing Ollama via official install script ...")
res := runShell("curl -fsSL https://ollama.com/install.sh | sh", CmdOpts{})
if !res.OK() {
errLog(fmt.Sprintf("Ollama installation failed: %v", res.Err))
return
}
isCI := os.Getenv("BOOTSTRAP_CI") == "true"
if isCI {
fmt.Println(" [CI] Skipping pulling large Ollama models in integration test container.")
return
}
// Pull Gemma 4 E4B and Qwen2.5-Coder 7B
fmt.Println(" Pulling Gemma 4 E4B and Qwen2.5-Coder 7B models ...")
serverRunning := false
// Check if Ollama server is already responding
for i := 0; i < 5; i++ {
r := runCmd([]string{"ollama", "list"}, CmdOpts{Capture: true})
if r.OK() {
serverRunning = true
break
}
if isTesting {
break
}
time.Sleep(1 * time.Second)
}
var cmd *exec.Cmd
if !serverRunning && !isTesting {
fmt.Println(" Ollama server not running. Starting in background for model pulling ...")
cmd = exec.Command("ollama", "serve")
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
warn(fmt.Sprintf("Failed to start Ollama server in background: %v", err))
} else {
// Wait up to 30 seconds for server to start
for i := 0; i < 30; i++ {
r := runCmd([]string{"ollama", "list"}, CmdOpts{Capture: true})
if r.OK() {
serverRunning = true
break
}
time.Sleep(1 * time.Second)
}
}
}
if serverRunning || isTesting {
fmt.Println(" Pulling Gemma 4 E4B (gemma4:e4b) ...")
pull1 := runCmd([]string{"ollama", "pull", "gemma4:e4b"}, CmdOpts{})
if !pull1.OK() {
errLog(fmt.Sprintf("Failed to pull gemma4:e4b: %v", pull1.Err))
}
fmt.Println(" Pulling Qwen2.5-Coder 7B (qwen2.5-coder:7b) ...")
pull2 := runCmd([]string{"ollama", "pull", "qwen2.5-coder:7b"}, CmdOpts{})
if !pull2.OK() {
errLog(fmt.Sprintf("Failed to pull qwen2.5-coder:7b: %v", pull2.Err))
}
} else {
errLog("Ollama server failed to start — cannot pull models")
}
if cmd != nil && cmd.Process != nil {
fmt.Println(" Stopping background Ollama server ...")
cmd.Process.Kill()
}
}
func installHuggingFaceCLI() {
if !hasCmd("pipx") {
errLog("pipx is not installed — cannot install huggingface-cli")
return
}
fmt.Println(" Installing huggingface-cli via pipx ...")
res := runCmd([]string{"pipx", "install", "huggingface_hub[cli]"}, CmdOpts{})
if !res.OK() {
errLog(fmt.Sprintf("failed to install huggingface-cli: %v", res.Err))
}
}
// 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})
}
+662
View File
@@ -0,0 +1,662 @@
package main
import (
"os"
"strings"
"testing"
)
func TestSpecialPkgs(t *testing.T) {
defer resetMocks()
isMacOS = true
resMac := specialPkgs()
if len(resMac) != 0 {
t.Errorf("expected no special packages on macOS, got %v", resMac)
}
isMacOS = false
resLinux := specialPkgs()
if len(resLinux) == 0 {
t.Error("expected special packages on Linux")
}
if !resLinux["minikube"] || !resLinux["pulumi"] {
t.Error("expected minikube and pulumi to be special packages on Linux")
}
}
func TestIsSpecialPkgInstalled(t *testing.T) {
defer resetMocks()
isMacOS = false
osStat = func(name string) (os.FileInfo, error) {
if strings.Contains(name, "obsidian") || strings.Contains(name, "minikube") {
return nil, nil // exists
}
return nil, os.ErrNotExist
}
if !isSpecialPkgInstalled("obsidian") {
t.Error("expected obsidian to be detected as installed via path")
}
if !isSpecialPkgInstalled("minikube") {
t.Error("expected minikube to be detected as installed via path")
}
// Test poetry command-based lookup
hasCmd = func(name string) bool {
return name == "poetry"
}
if !isSpecialPkgInstalled("poetry") {
t.Error("expected poetry to be detected as installed via hasCmd")
}
}
func TestInstallGitHubDesktop(t *testing.T) {
defer resetMocks()
// Mock fetchJSON to return GitHub Desktop release
fetchJSON = func(url string, v any) bool {
if strings.Contains(url, "shiftkey/desktop") {
rel := v.(*ghRelease)
rel.TagName = "v3.1.2"
rel.Assets = []ghAsset{
{Name: "GitHubDesktop-linux-amd64.rpm", BrowserDownloadURL: "http://download.rpm"},
{Name: "GitHubDesktop-linux-amd64.deb", BrowserDownloadURL: "http://download.deb"},
}
return true
}
return false
}
var downloadedURL string
download = func(url, dest string) bool {
downloadedURL = url
return true
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
// Case 1: DNF
pkgMgr = "dnf"
archName = "x86_64"
installGitHubDesktop("/tmp")
if downloadedURL != "http://download.rpm" {
t.Errorf("expected rpm download URL, got %q", downloadedURL)
}
if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "dnf" || runCmdCalls[0][1] != "install" {
t.Errorf("expected dnf install command, got %v", runCmdCalls)
}
// Case 2: APT
resetMocks()
fetchJSON = func(url string, v any) bool {
if strings.Contains(url, "shiftkey/desktop") {
rel := v.(*ghRelease)
rel.TagName = "v3.1.2"
rel.Assets = []ghAsset{
{Name: "GitHubDesktop-linux-amd64.deb", BrowserDownloadURL: "http://download.deb"},
}
return true
}
return false
}
pkgMgr = "apt-get"
archName = "x86_64"
downloadedURL = ""
download = func(url, dest string) bool {
downloadedURL = url
return true
}
var runCmdCallsApt [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCallsApt = append(runCmdCallsApt, argv)
return CmdResult{ExitCode: 0}
}
installGitHubDesktop("/tmp")
if downloadedURL != "http://download.deb" {
t.Errorf("expected deb download URL, got %q", downloadedURL)
}
}
func TestInstallZoom(t *testing.T) {
defer resetMocks()
var downloadedURL string
download = func(url, dest string) bool {
downloadedURL = url
return true
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
// AMD64 RHEL
pkgMgr = "dnf"
archName = "x86_64"
installZoom("/tmp")
if downloadedURL != "https://zoom.us/client/latest/zoom_x86_64.rpm" {
t.Errorf("unexpected Zoom rpm download URL: %q", downloadedURL)
}
// ARM64 RHEL (unsupported, should skip)
resetMocks()
archName = "aarch64"
downloadedURL = ""
download = func(url, dest string) bool {
downloadedURL = url
return true
}
installZoom("/tmp")
if downloadedURL != "" {
t.Error("expected zoom download to be skipped on ARM64 Linux")
}
}
func TestInstallObsidian(t *testing.T) {
defer resetMocks()
fetchJSON = func(url string, v any) bool {
rel := v.(*ghRelease)
rel.Assets = []ghAsset{
{Name: "Obsidian-1.4.16-arm64.AppImage", BrowserDownloadURL: "http://obs-arm64"},
{Name: "Obsidian-1.4.16-amd64.AppImage", BrowserDownloadURL: "http://obs-x86_64"},
}
return true
}
var downloadedURL string
download = func(url, dest string) bool {
downloadedURL = url
return true
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
archName = "x86_64"
installObsidian("/tmp")
if downloadedURL != "http://obs-x86_64" {
t.Errorf("expected x86_64 AppImage URL, got %q", downloadedURL)
}
if len(runCmdCalls) != 2 || runCmdCalls[0][0] != "cp" || runCmdCalls[1][0] != "chmod" {
t.Errorf("expected cp and chmod calls, got %v", runCmdCalls)
}
// Obsidian publishes the x86_64 AppImage without an arch token
// (e.g. "Obsidian-1.12.7.AppImage"). It should still match on x86_64.
fetchJSON = func(url string, v any) bool {
rel := v.(*ghRelease)
rel.Assets = []ghAsset{
{Name: "Obsidian-1.12.7-arm64.AppImage", BrowserDownloadURL: "http://obs-arm64"},
{Name: "Obsidian-1.12.7.AppImage", BrowserDownloadURL: "http://obs-default"},
}
return true
}
downloadedURL = ""
archName = "x86_64"
installObsidian("/tmp")
if downloadedURL != "http://obs-default" {
t.Errorf("expected token-less AppImage to be selected for x86_64, got %q", downloadedURL)
}
downloadedURL = ""
archName = "aarch64"
installObsidian("/tmp")
if downloadedURL != "http://obs-arm64" {
t.Errorf("expected arm64 AppImage for aarch64, got %q", downloadedURL)
}
}
func TestInstallMinikube(t *testing.T) {
defer resetMocks()
var downloadedURL string
download = func(url, dest string) bool {
downloadedURL = url
// Write dummy file for SHA256Of
os.WriteFile(dest, []byte("minikube-bytes"), 0644)
return true
}
fetchText = func(url string) string {
// Mock SHA256 checksum file
// SHA256 of "minikube-bytes" is 479665cc15daa7633ab1510306fea5002d4ea534c3768c76d2387ce453a43e80
return "479665cc15daa7633ab1510306fea5002d4ea534c3768c76d2387ce453a43e80 minikube"
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
archName = "x86_64"
installMinikube("/tmp")
if downloadedURL != "https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64" {
t.Errorf("unexpected minikube download URL: %q", downloadedURL)
}
}
func TestInstallPulumi(t *testing.T) {
defer resetMocks()
fetchText = func(url string) string {
if strings.Contains(url, "latest-version") {
return "3.90.0"
}
// Checksums
// SHA256 of "pulumi-bytes" is cbdf1e1564757c6b9e4a3055d7b57b9c904323214b7e8020626db4c207fae820
// but actual sha256 is 0162652d56a64a2de5cbb5520a19aa76826fdf1af0b214d7bef6718119c6ee3c
return "0162652d56a64a2de5cbb5520a19aa76826fdf1af0b214d7bef6718119c6ee3c pulumi-v3.90.0-linux-x64.tar.gz"
}
download = func(url, dest string) bool {
os.WriteFile(dest, []byte("pulumi-bytes"), 0644)
return true
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
archName = "x86_64"
osName = "linux"
installPulumi("/tmp")
if len(runCmdCalls) < 3 {
t.Fatalf("expected mkdir, rm, and tar commands, got calls: %v", runCmdCalls)
}
}
func TestInstallPipxAndPoetry(t *testing.T) {
defer resetMocks()
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
hasCmd = func(name string) bool {
return true // Python & pipx are available
}
installPipx("/tmp")
if len(runCmdCalls) != 2 || runCmdCalls[1][0] != "pipx" {
t.Errorf("expected pipx ensurepath, got calls: %v", runCmdCalls)
}
runCmdCalls = nil
installPoetry("/tmp")
if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "pipx" || runCmdCalls[0][2] != "poetry" {
t.Errorf("expected pipx install poetry, got calls: %v", runCmdCalls)
}
}
func TestAppendProfileLine(t *testing.T) {
defer resetMocks()
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
isMacOS = false
appendProfileLine("test-script", "export VAL=1")
if len(runCmdCalls) != 1 || !strings.Contains(runCmdCalls[0][2], "/etc/profile.d/test-script.sh") {
t.Errorf("expected write to profile.d on Linux, got calls: %v", runCmdCalls)
}
resetMocks()
isMacOS = true
runCmdCalls = nil
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
appendProfileLine("test-script", "export VAL=1")
if len(runCmdCalls) != 1 || !strings.Contains(runCmdCalls[0][2], "/etc/zprofile") {
t.Errorf("expected write to zprofile on macOS, got calls: %v", runCmdCalls)
}
}
func TestInstallSystemPackages(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
hasCmd = func(name string) bool {
return true // pipx exists
}
installSystemPackages([]string{"git", "lazy-git"}, []string{"pipx"})
// DNF case: should run dnf install git, then dnf install lazy-git, then installSpecialPkg for pipx
hasGit := false
hasPipx := false
for _, call := range runCmdCalls {
if len(call) >= 4 && call[0] == "dnf" && call[1] == "install" {
if call[3] == "git" {
hasGit = true
}
}
if len(call) >= 2 && call[0] == "pipx" {
hasPipx = true
}
}
_ = hasGit
_ = hasPipx
}
func TestSystemGoEdgeCases(t *testing.T) {
defer resetMocks()
isMacOS = false
osStat = func(name string) (os.FileInfo, error) {
return nil, nil
}
hasCmd = func(name string) bool { return true }
if !isSpecialPkgInstalled("pulumi") {
t.Error("expected pulumi to be installed")
}
if !isSpecialPkgInstalled("pipx") {
t.Error("expected pipx to be installed")
}
if !isSpecialPkgInstalled("poetry") {
t.Error("expected poetry to be installed")
}
pkgMgr = "brew"
installSpecialPkg("github-desktop", "/tmp")
pkgMgr = "dnf"
fetchJSON = func(url string, v any) bool { return false }
installSpecialPkg("github-desktop", "/tmp")
fetchJSON = func(url string, v any) bool {
rel := v.(*ghRelease)
rel.Assets = []ghAsset{{Name: "bad.exe"}}
return true
}
installSpecialPkg("github-desktop", "/tmp")
resetMocks()
pkgMgr = "apt-get"
archName = "x86_64"
var downloadURL string
download = func(url, dest string) bool {
downloadURL = url
return true
}
runCmd = func(argv []string, opts CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
installSpecialPkg("zoom", "/tmp")
if downloadURL != "https://zoom.us/client/latest/zoom_amd64.deb" {
t.Errorf("expected zoom deb URL, got %q", downloadURL)
}
resetMocks()
fetchJSON = func(url string, v any) bool { return false }
installSpecialPkg("obsidian", "/tmp")
fetchJSON = func(url string, v any) bool {
rel := v.(*ghRelease)
rel.Assets = []ghAsset{{Name: "bad.exe"}}
return true
}
installSpecialPkg("obsidian", "/tmp")
fetchJSON = func(url string, v any) bool {
rel := v.(*ghRelease)
rel.Assets = []ghAsset{{Name: "Obsidian-1.4.16-amd64.AppImage"}}
return true
}
download = func(url, dest string) bool { return false }
installSpecialPkg("obsidian", "/tmp")
resetMocks()
download = func(url, dest string) bool { return false }
installSpecialPkg("minikube", "/tmp")
download = func(url, dest string) bool { return true }
fetchText = func(url string) string { return "mismatch-checksum minikube" }
installSpecialPkg("minikube", "/tmp")
resetMocks()
fetchText = func(url string) string { return "some-sha pulumi-v3.90.0-linux-x64.tar.gz" }
download = func(url, dest string) bool { return false }
installSpecialPkg("pulumi", "/tmp")
download = func(url, dest string) bool { return true }
installSpecialPkg("pulumi", "/tmp")
resetMocks()
hasCmd = func(name string) bool { return false }
installSpecialPkg("pipx", "/tmp")
resetMocks()
hasCmd = func(name string) bool {
if name == "python3" { return true }
return false
}
runCmd = func(argv []string, opts CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
installSpecialPkg("pipx", "/tmp")
resetMocks()
hasCmd = func(name string) bool { return false }
installSpecialPkg("poetry", "/tmp")
resetMocks()
pkgMgr = "brew"
runCmd = func(argv []string, opts CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
pkgInstall("github-desktop")
pkgInstall("git")
pkgMgr = "pacman"
pkgInstall("git")
pkgMgr = "apt-get"
pkgInstall("git")
resetMocks()
pkgMgr = "brew"
runCmd = func(argv []string, opts CmdOpts) CmdResult { return CmdResult{ExitCode: 0} }
installSystemPackages([]string{"git"}, []string{})
resetMocks()
pkgMgr = "dnf"
osStat = func(name string) (os.FileInfo, error) { return nil, os.ErrNotExist }
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 0}
}
installSystemPackages([]string{"docker-ce"}, []string{})
}
func TestInstallSystemPackagesAria2First(t *testing.T) {
defer resetMocks()
pkgMgr = "dnf"
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
hasCmd = func(name string) bool {
if name == "aria2c" {
return false
}
return true
}
installSystemPackages([]string{"git", "aria2", "tmux"}, []string{})
if len(runCmdCalls) < 2 {
t.Fatalf("expected at least 2 command calls, got %d: %v", len(runCmdCalls), runCmdCalls)
}
firstCall := runCmdCalls[0]
if len(firstCall) < 4 || firstCall[0] != "dnf" || firstCall[1] != "install" || firstCall[3] != "aria2" {
t.Errorf("expected first call to be installing aria2, got: %v", firstCall)
}
secondCall := runCmdCalls[1]
if len(secondCall) < 5 || secondCall[0] != "dnf" || secondCall[1] != "install" || secondCall[3] != "git" || secondCall[4] != "tmux" {
t.Errorf("expected second call to install remaining packages, got: %v", secondCall)
}
}
func TestInstallLocalAISpecialPkgs(t *testing.T) {
defer resetMocks()
pkgMgr = "apt-get"
isMacOS = false
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
var runShellCalls []string
runShell = func(cmd string, opts CmdOpts) CmdResult {
runShellCalls = append(runShellCalls, cmd)
return CmdResult{ExitCode: 0}
}
// 1. nvidia-drivers
installSpecialPkg("nvidia-drivers", "/tmp")
if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "apt-get" || runCmdCalls[0][3] != "nvidia-driver-550" {
t.Errorf("expected apt-get install nvidia-driver-550, got calls: %v", runCmdCalls)
}
// 2. cuda-toolkit
runCmdCalls = nil
runShellCalls = nil
installSpecialPkg("cuda-toolkit", "/tmp")
if len(runCmdCalls) < 1 || runCmdCalls[0][3] != "cuda-toolkit" {
t.Errorf("expected apt-get install cuda-toolkit, got calls: %v", runCmdCalls)
}
if len(runShellCalls) != 1 || !strings.Contains(runShellCalls[0], "cuda-keyring") {
t.Errorf("expected setupCUDARepo runShell call, got calls: %v", runShellCalls)
}
// 3. nvidia-container-toolkit
runCmdCalls = nil
runShellCalls = nil
installSpecialPkg("nvidia-container-toolkit", "/tmp")
if len(runCmdCalls) < 3 || runCmdCalls[0][3] != "nvidia-container-toolkit" {
t.Errorf("expected apt-get install nvidia-container-toolkit, got calls: %v", runCmdCalls)
}
if len(runShellCalls) != 1 || !strings.Contains(runShellCalls[0], "nvidia-container-toolkit.list") {
t.Errorf("expected setupNvidiaContainerToolkitRepo runShell call, got calls: %v", runShellCalls)
}
// 4. ollama
runCmdCalls = nil
runShellCalls = nil
installSpecialPkg("ollama", "/tmp")
if len(runShellCalls) != 1 || !strings.Contains(runShellCalls[0], "ollama.com/install.sh") {
t.Errorf("expected ollama installer runShell call, got calls: %v", runShellCalls)
}
// 5. huggingface-cli
runCmdCalls = nil
runShellCalls = nil
hasCmd = func(name string) bool {
return name == "pipx"
}
installSpecialPkg("huggingface-cli", "/tmp")
if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "pipx" || runCmdCalls[0][2] != "huggingface_hub[cli]" {
t.Errorf("expected pipx install huggingface_hub[cli], got calls: %v", runCmdCalls)
}
}
func TestIsSpecialPkgInstalledLocalAI(t *testing.T) {
defer resetMocks()
isMacOS = false
// Test case 1: None of the commands/files exist
hasCmd = func(name string) bool { return false }
osStat = func(name string) (os.FileInfo, error) { return nil, os.ErrNotExist }
pkgs := []string{"nvidia-drivers", "cuda-toolkit", "nvidia-container-toolkit", "ollama", "huggingface-cli"}
for _, p := range pkgs {
if isSpecialPkgInstalled(p) {
t.Errorf("expected %s to be not installed", p)
}
}
// Test case 2: Check nvidia-drivers
hasCmd = func(name string) bool { return name == "nvidia-smi" }
if !isSpecialPkgInstalled("nvidia-drivers") {
t.Error("expected nvidia-drivers to be installed when nvidia-smi exists")
}
// Test case 3: Check cuda-toolkit via hasCmd
hasCmd = func(name string) bool { return name == "nvcc" }
if !isSpecialPkgInstalled("cuda-toolkit") {
t.Error("expected cuda-toolkit to be installed when nvcc command exists")
}
// Test case 4: Check cuda-toolkit via path existence
hasCmd = func(name string) bool { return false }
osStat = func(name string) (os.FileInfo, error) {
if name == "/usr/local/cuda/bin/nvcc" {
return nil, nil // exists
}
return nil, os.ErrNotExist
}
if !isSpecialPkgInstalled("cuda-toolkit") {
t.Error("expected cuda-toolkit to be installed when /usr/local/cuda/bin/nvcc exists")
}
// Test case 5: Check nvidia-container-toolkit
resetMocks()
isMacOS = false
hasCmd = func(name string) bool { return name == "nvidia-ctk" }
if !isSpecialPkgInstalled("nvidia-container-toolkit") {
t.Error("expected nvidia-container-toolkit to be installed when nvidia-ctk command exists")
}
// Test case 6: Check ollama
resetMocks()
isMacOS = false
hasCmd = func(name string) bool { return name == "ollama" }
if !isSpecialPkgInstalled("ollama") {
t.Error("expected ollama to be installed when ollama command exists")
}
// Test case 7: Check huggingface-cli
resetMocks()
isMacOS = false
hasCmd = func(name string) bool { return name == "huggingface-cli" }
if !isSpecialPkgInstalled("huggingface-cli") {
t.Error("expected huggingface-cli to be installed when huggingface-cli command exists")
}
}
+102
View File
@@ -0,0 +1,102 @@
package main
import (
"fmt"
"io"
"runtime"
"strconv"
"strings"
"sync"
)
// Goroutine-local task output routing.
//
// Why: install handlers in custom.go and post.go are deeply nested calls
// that use fmt.Printf/Println directly and pass CmdOpts to runCmd. To route
// their output into a per-task buffer (so parallel workers don't interleave
// on os.Stdout), we'd otherwise need to thread an io.Writer through every
// signature — ~30 call sites of churn including tests.
//
// Instead we keep a sync.Map keyed by goroutine id. The parallel orchestrator
// associates a taskOutput with its worker goroutine before invoking the
// handler; helpers below check the map and route output to the active task
// when present, falling back to direct stdout otherwise. Sequential callers
// observe no behavior change.
//
// goid() uses runtime.Stack — a small hack, but stable and idiomatic for
// goroutine-local state where context.Context threading would dwarf the
// surrounding work.
var activeTaskByGoroutine sync.Map // map[uint64]*taskOutput
func goid() uint64 {
var buf [64]byte
n := runtime.Stack(buf[:], false)
s := string(buf[:n])
s = strings.TrimPrefix(s, "goroutine ")
end := strings.IndexByte(s, ' ')
if end < 0 {
return 0
}
id, _ := strconv.ParseUint(s[:end], 10, 64)
return id
}
// withTaskOutput pins tOut to the current goroutine for the duration of fn,
// then unpins. Re-entrant calls overwrite the previous binding and restore
// it on return. A nil tOut is treated as "no binding" (sequential mode).
func withTaskOutput(tOut *taskOutput, fn func()) {
if tOut == nil {
fn()
return
}
id := goid()
prev, hadPrev := activeTaskByGoroutine.Load(id)
activeTaskByGoroutine.Store(id, tOut)
defer func() {
if hadPrev {
activeTaskByGoroutine.Store(id, prev)
} else {
activeTaskByGoroutine.Delete(id)
}
}()
fn()
}
// currentTask returns the taskOutput pinned to the current goroutine, or
// nil if none. Cheap enough to call per print (~microseconds).
func currentTask() *taskOutput {
v, ok := activeTaskByGoroutine.Load(goid())
if !ok {
return nil
}
return v.(*taskOutput)
}
// taskPrintf routes via the active task (if any) or directly to stdout.
func taskPrintf(format string, args ...any) {
if t := currentTask(); t != nil {
t.Printf(format, args...)
return
}
fmt.Printf(format, args...)
}
// taskPrintln routes via the active task (if any) or directly to stdout.
func taskPrintln(args ...any) {
if t := currentTask(); t != nil {
t.Println(args...)
return
}
fmt.Println(args...)
}
// taskOut returns the io.Writer that runCmd / runShell should target via
// CmdOpts.Out for the active task. Returns nil when there is no active task,
// which preserves runCmd's default streamed-to-stdout behavior.
func taskOut() io.Writer {
if t := currentTask(); t != nil {
return t.Writer()
}
return nil
}
+18
View File
@@ -0,0 +1,18 @@
package main
import (
"io"
"os"
"testing"
)
// TestMain silences issue-log output for the entire test binary.
// Tests still exercise error paths and assert via errorCount; we just
// don't want the human-facing "[ERROR] ..." prints polluting CI logs
// (GitHub Actions auto-annotates "[ERROR]" lines as workflow errors).
func TestMain(m *testing.M) {
isTesting = true
issueLogWriter = io.Discard
disableProgressTracking = true
os.Exit(m.Run())
}