Compare commits

...
Author SHA1 Message Date
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
Claude 10963c848b Fix Neovim not found after install by symlinking /usr/local/bin/nvim
The PATH line written to /etc/profile.d/neovim.sh was only sourced by
login shells via /etc/profile. zsh — set as the default shell by this
script — uses /etc/zsh/zprofile which sources /etc/profile only for
login shells, and terminal emulators typically launch non-login
interactive shells. The result on Debian 13 ARM64 (and other distros):
nvim installed correctly to /opt/nvim-linux-arm64 but was not on PATH
in a normal terminal session.

Symlink the binary into /usr/local/bin/nvim instead — that directory
is always on the default PATH on Linux and macOS (Apple Silicon and
Intel) regardless of shell type. The Zig install already follows this
pattern. Also switch the install-detection probe to the symlink so it
verifies an actually-callable nvim, not just the extracted directory.

https://claude.ai/code/session_01VP9hCLH1c5F5iv468jSkqs
2026-05-22 18:27:10 +00:00
Jason Ross e5448b71ba Merge pull request #9 from JMR-dev/claude/pulumi-repo-setup-failure-KEncj
Fix Pulumi repo setup failure by installing from official tarball
2026-05-22 13:14:27 -05:00
Claude 6661aba3d6 Fix Pulumi repo setup failure by installing from official tarball
Pulumi does not publish apt or yum repositories — the URLs the setup
function referenced (api.pulumi.com/releases/sdk/{apt,rpm}-keyring.gpg
and {apt,yum}.releases.pulumi.com) return 404 / do not resolve, which
broke `bootstrap_environment.py` on every fresh Linux run.

Replace `setup_pulumi_repo` with a special-package installer that
downloads the official GitHub release tarball, verifies the SHA256
against the published checksums file, extracts to /opt/pulumi, and
exposes the binaries via PATH — the same pattern used for Neovim.
macOS continues to install Pulumi via brew.

https://claude.ai/code/session_018L1gFoc8L3CYqLE2wNjpy6
2026-05-22 18:13:43 +00:00
Jason Ross 16f058f83c Merge pull request #8 from JMR-dev/claude/debian-arm64-install-errors-ekxEt
Fix five Debian arm64 install failures
2026-05-22 13:05:53 -05:00
44 changed files with 9557 additions and 2531 deletions
+78
View File
@@ -0,0 +1,78 @@
name: Integration Tests
on:
push:
branches:
- main
- dev
pull_request:
branches:
- main
- dev
jobs:
test-linux:
name: Linux Integration - ${{ matrix.os }}
runs-on: ubuntu-latest
strategy:
matrix:
os: [debian, arch, fedora]
fail-fast: false
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
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@v4
- name: Setup Go
uses: actions/setup-go@v5
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!"
+61
View File
@@ -0,0 +1,61 @@
name: macOS Manual Integration
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@v4
with:
ref: ${{ inputs.branch }}
- name: Setup Go
uses: actions/setup-go@v5
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
+127
View File
@@ -0,0 +1,127 @@
name: Release
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@v4
with:
ref: ${{ needs.resolve.outputs.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Go
uses: actions/setup-go@v5
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@v4
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@v4
with:
ref: ${{ needs.resolve.outputs.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
path: dist
pattern: bootstrap_environment-*
merge-multiple: true
- name: Generate SHA256SUMS
working-directory: dist
run: |
ls -l
sha256sum bootstrap_environment-* > SHA256SUMS
cat SHA256SUMS
- name: Create release
uses: softprops/action-gh-release@v2
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
+27
View File
@@ -0,0 +1,27 @@
name: Unit Tests
on:
pull_request:
branches:
- main
- dev
jobs:
unit-test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
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)
+43 -6
View File
@@ -1,8 +1,45 @@
# Usage
# bootstrap_dev_env
``` shell
curl -L -o bootstrap.zip https://github.com/JMR-dev/bootstrap_dev_env/archive/refs/heads/main.zip && \
unzip bootstrap.zip && \
cd bootstrap_dev_env-main && \
python3 bootstrap_environment.py
A Go-based bootstrap tool that installs a development environment across
macOS, Debian/Ubuntu, RHEL/Fedora, and Arch Linux on `x86_64` and `aarch64`.
It installs system packages, optional Flatpak GUI apps, and a set of custom
third-party tools (Go, Neovim, Zig, NVM, pyenv, oh-my-zsh, Firecracker on
Linux, agy, claude, codex, and copilot).
## Install
Download the native binary for your platform from a release, or build from
source:
```shell
git clone https://github.com/JMR-dev/bootstrap_dev_env.git
cd bootstrap_dev_env
make build # builds ./bootstrap_environment for the host
```
To produce native binaries for all four supported targets at once:
```shell
make build-all # writes dist/bootstrap_environment-{linux,darwin}-{amd64,arm64}
```
## Usage
```shell
# Linux (do NOT use sudo on macOS — Homebrew refuses to run as root)
sudo ./bootstrap_environment [--only system|flatpak|custom] [--gui]
# macOS
./bootstrap_environment [--only system|custom] [--gui] [--no-vm]
```
Flags:
- `--only` — restrict to one section (`system`, `flatpak`, or `custom`).
- `--gui` — include GUI applications and the Flatpak section. Default is
headless: both are skipped.
- `--no-vm` — macOS only: skip provisioning the Fedora-on-QEMU/VirtualBox VM
that backs the `firecracker()` zsh wrapper.
- `--no-ai` — skip installation of AI/LLM CLI tools (agy, claude, codex, copilot).
Package lists live in `packages.go`. Edit and rebuild.
File diff suppressed because it is too large Load Diff
+177
View File
@@ -0,0 +1,177 @@
package main
import (
"fmt"
"strings"
)
type systemCheckResult struct {
toInstallRegular []string
toInstallSpecial []string
alreadyInstalled []string
skipped []string
remapped []remap // for display only
}
type remap struct {
From string
To []string
}
type flatpakCheckResult struct {
toInstall []string
alreadyInstalled []string
}
type customCheckResult struct {
toInstall []*CustomPackage
alreadyInstalled []customStatus
}
type customStatus struct {
pkg *CustomPackage
path string
}
func checkSystemPackages(names []string) systemCheckResult {
overrides := packageOverrides[pkgMgr]
resolved, skipped := resolveSystemPkgs(names)
var remapped []remap
for _, n := range names {
if ov, ok := overrides[n]; ok && !ov.Skip {
remapped = append(remapped, remap{From: n, To: ov.Replacement})
}
}
specials := specialPkgs()
var special, regular []string
for _, p := range resolved {
if specials[p] {
special = append(special, p)
} else {
regular = append(regular, p)
}
}
var toR, alreadyR []string
for _, p := range regular {
if isSystemPkgInstalled(p) {
alreadyR = append(alreadyR, p)
} else {
toR = append(toR, p)
}
}
var toS, alreadyS []string
for _, p := range special {
if isSpecialPkgInstalled(p) {
alreadyS = append(alreadyS, p)
} else {
toS = append(toS, p)
}
}
return systemCheckResult{
toInstallRegular: toR,
toInstallSpecial: toS,
alreadyInstalled: append(alreadyR, alreadyS...),
skipped: skipped,
remapped: remapped,
}
}
func checkFlatpakPackages(ids []string) flatpakCheckResult {
var to, already []string
for _, p := range ids {
if isFlatpakInstalled(p) {
already = append(already, p)
} else {
to = append(to, p)
}
}
return flatpakCheckResult{toInstall: to, alreadyInstalled: already}
}
func checkCustomPackages(pkgs []*CustomPackage) customCheckResult {
var to []*CustomPackage
var already []customStatus
for _, p := range pkgs {
installed, path := isCustomPkgInstalled(p)
if installed {
already = append(already, customStatus{pkg: p, path: path})
} else {
to = append(to, p)
}
}
return customCheckResult{toInstall: to, alreadyInstalled: already}
}
func fmtList(items []string, limit int) string {
if len(items) <= limit {
return strings.Join(items, " ")
}
return strings.Join(items[:limit], " ") + fmt.Sprintf(" … +%d more", len(items)-limit)
}
func printCheckSummary(sys systemCheckResult, flat flatpakCheckResult, cust customCheckResult, only string) int {
total := 0
if only == "" || only == "system" {
toR := sys.toInstallRegular
toS := sys.toInstallSpecial
ok := sys.alreadyInstalled
fmt.Println("\nSystem packages:")
if len(ok) > 0 {
fmt.Printf(" [OK] %3d already installed\n", len(ok))
}
n := len(toR) + len(toS)
if n > 0 {
combined := append([]string{}, toR...)
combined = append(combined, toS...)
fmt.Printf(" [INSTALL] %3d to install: %s\n", n, fmtList(combined, 6))
}
if len(sys.skipped) > 0 {
fmt.Printf(" [SKIP] %3d overridden (→ skip): %s\n", len(sys.skipped), fmtList(sys.skipped, 6))
}
if len(sys.remapped) > 0 {
var parts []string
for _, r := range sys.remapped {
parts = append(parts, fmt.Sprintf("%s→%s", r.From, strings.Join(r.To, ",")))
}
fmt.Printf(" [REMAP] remapped: %s\n", strings.Join(parts, " "))
}
total += n
}
if (only == "" || only == "flatpak") && (len(flat.toInstall) > 0 || len(flat.alreadyInstalled) > 0) {
fmt.Println("\nFlatpak packages:")
if len(flat.alreadyInstalled) > 0 {
fmt.Printf(" [OK] %3d already installed\n", len(flat.alreadyInstalled))
}
if len(flat.toInstall) > 0 {
fmt.Printf(" [INSTALL] %3d to install: %s\n", len(flat.toInstall), fmtList(flat.toInstall, 6))
}
total += len(flat.toInstall)
}
if only == "" || only == "custom" {
fmt.Println("\nCustom packages:")
for _, s := range cust.alreadyInstalled {
suffix := ""
if s.path != "" {
suffix = fmt.Sprintf(" (%s)", s.path)
}
fmt.Printf(" [OK] %s%s\n", s.pkg.displayName(), suffix)
}
for _, p := range cust.toInstall {
_, path := isCustomPkgInstalled(p)
suffix := ""
if path != "" {
suffix = fmt.Sprintf(" → %s", path)
}
fmt.Printf(" [INSTALL] %s%s\n", p.displayName(), suffix)
}
total += len(cust.toInstall)
}
return total
}
Executable
BIN
View File
Binary file not shown.
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"strings"
"dagger.io/dagger"
"golang.org/x/sync/errgroup"
)
func main() {
osFlag := flag.String("os", "all", "OS to test (debian, arch, fedora, or all)")
flag.Parse()
ctx := context.Background()
// Initialize Dagger Client
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr))
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to connect to Dagger: %v\n", err)
os.Exit(1)
}
defer client.Close()
// Get reference to the project source directory
src := client.Host().Directory(".")
// Build the bootstrapping binary inside Go container
fmt.Println("Building bootstrap_environment binary for Linux...")
builder := client.Container().
From("golang:1.25").
WithMountedDirectory("/src", src).
WithWorkdir("/src").
WithExec([]string{"go", "build", "-o", "bootstrap_environment", "."})
binaryFile := builder.File("bootstrap_environment")
// Target OS list
var targets []string
switch strings.ToLower(*osFlag) {
case "debian":
targets = []string{"debian:latest"}
case "arch":
targets = []string{"archlinux:latest"}
case "fedora":
targets = []string{"fedora:latest"}
case "all":
targets = []string{"debian:latest", "archlinux:latest", "fedora:latest"}
default:
fmt.Fprintf(os.Stderr, "Unsupported OS: %s. Supported: debian, arch, fedora, all\n", *osFlag)
os.Exit(1)
}
g, ctx := errgroup.WithContext(ctx)
for _, target := range targets {
target := target // capture loop variable
g.Go(func() error {
fmt.Printf("=== Starting integration test on target OS: %s ===\n", target)
// 1. Prepare target container base and setup script based on OS distro
var testContainer *dagger.Container
if strings.Contains(target, "debian") {
testContainer = client.Container().
From(target).
WithExec([]string{"apt-get", "update"}).
WithExec([]string{"apt-get", "install", "-y", "sudo", "curl", "git", "wget", "tar", "unzip", "xz-utils", "make", "python3", "which"})
} else if strings.Contains(target, "fedora") {
testContainer = client.Container().
From(target).
WithExec([]string{"dnf", "install", "-y", "sudo", "curl", "git", "wget", "tar", "unzip", "xz", "make", "python3", "which"})
} else if strings.Contains(target, "archlinux") {
testContainer = client.Container().
From(target).
WithExec([]string{"pacman", "-Sy", "--noconfirm", "sudo", "curl", "git", "wget", "tar", "unzip", "xz", "make", "python", "which"})
} else {
return fmt.Errorf("unsupported target OS: %s", target)
}
// 2. Pre-create the ~/.pyenv directory to skip python source compilation in integration test (saves ~10 minutes)
testContainer = testContainer.WithExec([]string{"mkdir", "-p", "/root/.pyenv"})
// 3. Mount the built binary
testContainer = testContainer.
WithFile("/usr/local/bin/bootstrap_environment", binaryFile).
WithWorkdir("/tmp")
// Forward GitHub token so API calls are authenticated (avoids 403 rate-limits)
if tok := os.Getenv("GITHUB_TOKEN"); tok != "" {
secret := client.SetSecret("github-token", tok)
testContainer = testContainer.
WithSecretVariable("GITHUB_TOKEN", secret).
WithSecretVariable("GH_TOKEN", secret)
}
// 4. Run bootstrap binary against the full package set (no scope flags).
// We pipe 'y' to satisfy the "Proceed? [y/N]" prompt.
fmt.Printf("[%s] Executing bootstrap_environment...\n", target)
testContainer = testContainer.WithExec([]string{"sh", "-c", "echo y | bootstrap_environment --gui"})
// 5. Verify all installed custom packages return a path and zero exit code from version command
fmt.Printf("[%s] Verifying package installations on PATH and running version checks...\n", target)
verifyCmd := []string{
"sh", "-c",
"export PATH=$PATH:/usr/local/go/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 {
return fmt.Errorf("verification failed on %s: package not found on PATH or exited with error (%w)", 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!")
}
+34
View File
@@ -0,0 +1,34 @@
package main
import (
"io"
"os"
)
var (
// Exec redirects
runCmd = runCmdReal
runShell = runShellReal
hasCmd = hasCmdReal
probe = probeReal
// Net redirects
download = downloadReal
fetchJSON = fetchJSONReal
fetchText = fetchTextReal
// OS redirects
osStat = os.Stat
osReadFile = os.ReadFile
osWriteFile = os.WriteFile
osMkdirAll = os.MkdirAll
osRemove = os.Remove
osRemoveAll = os.RemoveAll
osRename = os.Rename
osExit = os.Exit
stdin io.Reader = os.Stdin
// Filesystem paths
osReleasePath = "/etc/os-release"
passwdPath = "/etc/passwd"
)
+1049
View File
File diff suppressed because it is too large Load Diff
+571
View File
@@ -0,0 +1,571 @@
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",
}
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")
}
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
}
fmt.Println(" SHA256 OK")
return true
}
if sigURL := pkg.resolveSHA256URL(); sigURL != "" {
sigPath := filepath.Join(filepath.Dir(archive), filepath.Base(sigURL))
if !download(sigURL, sigPath) {
return false
}
if !hasCmd("minisign") {
warn(fmt.Sprintf("minisign not installed — skipping signature verification for %s", pkg.Name))
return true
}
cmd := []string{"minisign", "-Vm", archive, "-x", sigPath}
if pkg.MinisignKey != "" {
cmd = append(cmd, "-P", pkg.MinisignKey)
}
if !runCmd(cmd, CmdOpts{}).OK() {
errLog(fmt.Sprintf("minisign verification failed for %s", pkg.Name))
return false
}
fmt.Println(" minisign OK")
}
return true
}
func urlArchOK(pkg *CustomPackage) bool {
url := pkg.resolveURL()
if url == "" {
return true
}
if archMatches(url, archName) {
return true
}
if hasOtherArchToken(url) {
warn(fmt.Sprintf("%s: URL targets %s but host is %s. Update packages.go with a matching URL/SHA256.",
pkg.Name, otherArch(), archName))
return false
}
return true
}
// ── per-package install handlers ────────────────────────────────────────
func installGo(archive string) {
goRoot := "/usr/local/go"
if _, err := osStat(goRoot); err == nil {
fmt.Printf(" Removing existing Go at %s ...\n", goRoot)
runCmd([]string{"rm", "-rf", goRoot}, CmdOpts{AsSudo: true})
}
runCmd([]string{"tar", "-C", "/usr/local", "-xzf", archive}, CmdOpts{AsSudo: true})
appendProfileLine("local_go", "export PATH=$PATH:/usr/local/go/bin")
fmt.Printf(" Go installed to %s\n", goRoot)
}
func installFirecracker(archive, tmp string) {
if !runCmd([]string{"tar", "-C", tmp, "-xzf", archive}, CmdOpts{}).OK() {
errLog("firecracker tar extraction failed")
return
}
var binary string
filepath.Walk(tmp, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
name := info.Name()
if strings.HasSuffix(name, ".tgz") || strings.HasSuffix(name, ".tar.gz") {
return nil
}
if !strings.HasPrefix(name, "firecracker") {
return nil
}
if strings.HasSuffix(name, ".debug") || strings.Contains(name, "debug") {
return nil
}
if binary == "" {
binary = path
}
return nil
})
if binary == "" {
errLog("firecracker binary not found in archive")
return
}
dest := "/usr/local/bin/firecracker"
runCmd([]string{"cp", binary, dest}, CmdOpts{AsSudo: true})
runCmd([]string{"chmod", "755", dest}, CmdOpts{AsSudo: true})
fmt.Printf(" firecracker installed to %s\n", dest)
}
func installZig(pkg *CustomPackage, archive string) {
parent := "/usr/local"
zigDir := filepath.Join(parent, "zig-"+pkg.Version)
if _, err := osStat(zigDir); err == nil {
runCmd([]string{"rm", "-rf", zigDir}, CmdOpts{AsSudo: true})
}
runCmd([]string{"tar", "-C", parent, "-xJf", archive}, CmdOpts{AsSudo: true})
pattern := filepath.Join(parent, fmt.Sprintf("zig-%s-%s*", archName, osZig[osName]))
matches, _ := filepath.Glob(pattern)
for _, m := range matches {
if m != zigDir {
runCmd([]string{"mv", m, zigDir}, CmdOpts{AsSudo: true})
break
}
}
symlink := "/usr/local/bin/zig"
runCmd([]string{"ln", "-sf", filepath.Join(zigDir, "zig"), symlink}, CmdOpts{AsSudo: true})
fmt.Printf(" Zig installed to %s, symlinked at %s\n", zigDir, symlink)
}
func installNeovim(_ *CustomPackage, tmp string) {
var rel ghRelease
if !fetchJSON("https://api.github.com/repos/neovim/neovim/releases/latest", &rel) {
return
}
archTok := archNvim[archName]
osTok := osNvim[osName]
assetName := fmt.Sprintf("nvim-%s-%s.tar.gz", osTok, archTok)
var asset *ghAsset
for i := range rel.Assets {
if rel.Assets[i].Name == assetName {
asset = &rel.Assets[i]
break
}
}
if asset == nil {
errLog(fmt.Sprintf("Neovim asset %s not found", assetName))
return
}
if !strings.HasPrefix(asset.Digest, "sha256:") {
errLog("Neovim asset digest missing or invalid")
return
}
expected := strings.TrimPrefix(asset.Digest, "sha256:")
dest := filepath.Join(tmp, assetName)
if !download(asset.BrowserDownloadURL, dest) {
return
}
actual, err := sha256Of(dest)
if err != nil {
errLog(fmt.Sprintf("Neovim hash failed: %v", err))
return
}
if actual != expected {
errLog(fmt.Sprintf("Neovim SHA256 mismatch: expected %s, got %s", expected, actual))
return
}
fmt.Println(" SHA256 OK")
installDir := fmt.Sprintf("/opt/nvim-%s-%s", osTok, archTok)
fmt.Println(" Extracting Neovim to /opt ...")
runCmd([]string{"mkdir", "-p", "/opt"}, CmdOpts{AsSudo: true})
runCmd([]string{"rm", "-rf", installDir}, CmdOpts{AsSudo: true})
runCmd([]string{"tar", "-C", "/opt", "-xzf", dest}, CmdOpts{AsSudo: true})
runCmd([]string{"mkdir", "-p", "/usr/local/bin"}, CmdOpts{AsSudo: true})
symlink := "/usr/local/bin/nvim"
runCmd([]string{"ln", "-sf", filepath.Join(installDir, "bin", "nvim"), symlink}, CmdOpts{AsSudo: true})
fmt.Printf(" Neovim installed to %s, symlinked at %s\n", installDir, symlink)
}
// ── latest-version resolvers ────────────────────────────────────────────
func resolveLatestGo(_ *CustomPackage) (string, string, bool) {
var raw json.RawMessage
if !fetchJSON("https://go.dev/dl/?mode=json", &raw) {
return "", "", false
}
// API returns an array; first element is the latest stable release.
type goFile struct {
Filename string `json:"filename"`
Kind string `json:"kind"`
SHA256 string `json:"sha256"`
}
type goRelease struct {
Version string `json:"version"`
Files []goFile `json:"files"`
}
var releases []goRelease
if err := json.Unmarshal(raw, &releases); err != nil || len(releases) == 0 {
var single goRelease
if err := json.Unmarshal(raw, &single); err != nil {
return "", "", false
}
releases = []goRelease{single}
}
latest := releases[0]
version := strings.TrimPrefix(latest.Version, "go")
if version == "" {
return "", "", false
}
archiveName := fmt.Sprintf("go%s.%s-%s.tar.gz", version, osGo[osName], archGo[archName])
for _, f := range latest.Files {
if f.Filename == archiveName && f.Kind == "archive" && f.SHA256 != "" {
return version, f.SHA256, true
}
}
return "", "", false
}
func resolveLatestFirecracker(_ *CustomPackage) (string, string, bool) {
if isMacOS {
return "", "", false
}
var rel ghRelease
if !fetchJSON("https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest", &rel) {
return "", "", false
}
version := strings.TrimPrefix(rel.TagName, "v")
if version == "" {
return "", "", false
}
archiveName := fmt.Sprintf("firecracker-v%s-%s.tgz", version, archName)
shaAssetName := archiveName + ".sha256.txt"
for _, a := range rel.Assets {
if a.Name == shaAssetName {
sha := fetchText(a.BrowserDownloadURL)
if sha == "" {
return "", "", false
}
return version, strings.Fields(sha)[0], true
}
}
return "", "", false
}
var zigVersionRe = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
func resolveLatestZig(_ *CustomPackage) (string, string, bool) {
var data map[string]map[string]any
if !fetchJSON("https://ziglang.org/download/index.json", &data) {
return "", "", false
}
var stable []string
for k := range data {
if k != "master" && zigVersionRe.MatchString(k) {
stable = append(stable, k)
}
}
if len(stable) == 0 {
return "", "", false
}
sort.Slice(stable, func(i, j int) bool {
return cmpSemver(stable[i], stable[j]) < 0
})
version := stable[len(stable)-1]
key := archName + "-" + osZig[osName]
entry, ok := data[version][key].(map[string]any)
if !ok {
return "", "", false
}
sha, _ := entry["shasum"].(string)
if sha == "" {
return "", "", false
}
return version, sha, true
}
func cmpSemver(a, b string) int {
pa := strings.Split(a, ".")
pb := strings.Split(b, ".")
for i := 0; i < len(pa) && i < len(pb); i++ {
ai, _ := strconv.Atoi(pa[i])
bi, _ := strconv.Atoi(pb[i])
if ai != bi {
if ai < bi {
return -1
}
return 1
}
}
return len(pa) - len(pb)
}
var latestResolvers = map[string]func(*CustomPackage) (string, string, bool){
"go": resolveLatestGo,
"firecracker": resolveLatestFirecracker,
"zig": resolveLatestZig,
}
// resolveLatest best-effort upgrades pkg.Version/SHA256 to the latest release.
// On any failure, warns and leaves the pinned values in place.
func resolveLatest(pkg *CustomPackage) {
resolver, ok := latestResolvers[pkg.FetchLatest]
if !ok {
return
}
fmt.Printf(" Checking latest version for %s ...\n", pkg.Name)
defer func() {
if r := recover(); r != nil {
warn(fmt.Sprintf("%s: latest-version lookup panicked %v; falling back to pinned version %s",
pkg.Name, r, pkg.Version))
}
}()
version, sha, found := resolver(pkg)
if !found {
warn(fmt.Sprintf("%s: could not resolve latest version; falling back to pinned version %s",
pkg.Name, pkg.Version))
return
}
if version == pkg.Version {
fmt.Printf(" Pinned version %s is already the latest.\n", pkg.Version)
return
}
fmt.Printf(" Latest is %s (pinned was %s); using latest.\n", version, pkg.Version)
pkg.Version = version
pkg.SHA256 = strings.ToLower(sha)
pkg.SHA256URLTemplate = "" // prefer the freshly resolved sha256
}
// ── orchestration ───────────────────────────────────────────────────────
func installCustomPackages(toInstall []*CustomPackage) {
fmt.Println("\n=== Custom Packages ===")
ensureNodeLTS()
for _, pkg := range toInstall {
name := strings.ToLower(pkg.Name)
_, checkPath := isCustomPkgInstalled(pkg)
extra := ""
if checkPath != "" {
extra = fmt.Sprintf(" (install path: %s)", checkPath)
}
fmt.Printf("\n Installing %s ...%s\n", pkg.displayName(), extra)
if checkPath == "" && name != "pip" {
warn(fmt.Sprintf("%s: no known install path — script will not detect future installs", pkg.Name))
}
if name == "firecracker" && isMacOS {
warn(fmt.Sprintf("%s: Linux-only — skipping on macOS", pkg.Name))
continue
}
switch name {
case "nvm":
installNVM()
ensureNodeLTS()
continue
case "pyenv":
installPyenv()
continue
case "pip":
installPip()
continue
case "oh-my-zsh":
installOhMyZsh()
continue
case "neovim":
tmp, err := os.MkdirTemp("", "bootstrap-nvim-")
if err != nil {
errLog(fmt.Sprintf("neovim tmp dir failed: %v", err))
continue
}
installNeovim(pkg, tmp)
osRemoveAll(tmp)
continue
case "agy":
installAgy()
continue
case "claude":
installNpmPackage("@anthropic-ai/claude-code")
continue
case "codex":
installNpmPackage("@openai/codex")
continue
case "copilot":
installNpmPackage("@github/copilot")
continue
case "playwright":
installPlaywright()
continue
case "gh-repo-bootstrap":
installGHExtension("JMR-dev/gh-repo-bootstrap")
continue
}
resolveLatest(pkg)
url := pkg.resolveURL()
if url == "" {
warn(fmt.Sprintf("No URL or install handler for '%s' — skipping", pkg.Name))
continue
}
if !urlArchOK(pkg) {
continue
}
tmp, err := os.MkdirTemp("", "bootstrap-custom-")
if err != nil {
errLog(fmt.Sprintf("tmp dir failed for %s: %v", pkg.Name, err))
continue
}
archive := filepath.Join(tmp, filepath.Base(url))
if !download(url, archive) {
osRemoveAll(tmp)
continue
}
if !verifyArchive(archive, pkg) {
osRemoveAll(tmp)
continue
}
switch name {
case "go":
installGo(archive)
case "firecracker":
installFirecracker(archive, tmp)
case "zig":
installZig(pkg, archive)
default:
warn(fmt.Sprintf("No install handler for '%s' — skipping", pkg.Name))
}
osRemoveAll(tmp)
}
}
+401
View File
@@ -0,0 +1,401 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"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 runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
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)
// 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)
}
+174
View File
@@ -0,0 +1,174 @@
package main
import (
"fmt"
"os"
"runtime"
"strings"
)
// OS / architecture detection.
//
// Vendors disagree on canonical OS/arch tokens used in download URLs, so we
// keep our own normalized values ("linux"/"macos", "x86_64"/"aarch64") and
// translate at URL-construction time.
var (
osName string // "linux" or "macos"
archName string // "x86_64" or "aarch64"
isMacOS bool
isRHELFamily bool
isArchFamily bool
)
func init() {
osName = detectOS()
archName = detectArch()
isMacOS = osName == "macos"
}
func detectOS() string {
switch runtime.GOOS {
case "linux":
return "linux"
case "darwin":
return "macos"
default:
fmt.Fprintf(os.Stderr, "Unsupported OS: %s (supports Linux, Darwin)\n", runtime.GOOS)
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"}
archTokens = map[string][]string{
"x86_64": {"x86_64", "amd64", "x64"},
"aarch64": {"aarch64", "arm64"},
}
)
// formatURL interpolates {version}, {arch}, {arch_go}, {os}, {os_go},
// {os_zig}, {os_nvim} into a download URL template.
func formatURL(template, version string) string {
r := strings.NewReplacer(
"{version}", version,
"{arch}", archName,
"{arch_go}", archGo[archName],
"{os}", osName,
"{os_go}", osGo[osName],
"{os_zig}", osZig[osName],
"{os_nvim}", osNvim[osName],
)
return r.Replace(template)
}
func otherArch() string {
if archName == "x86_64" {
return "aarch64"
}
return "x86_64"
}
func archMatches(name, arch string) bool {
n := strings.ToLower(name)
for _, tok := range archTokens[arch] {
if strings.Contains(n, tok) {
return true
}
}
return false
}
func hasOtherArchToken(name string) bool {
n := strings.ToLower(name)
for _, tok := range archTokens[otherArch()] {
if strings.Contains(n, tok) {
return true
}
}
return false
}
// osReleaseField returns the value of /etc/os-release's NAME=value pair,
// stripped of surrounding quotes. Returns "" if the file is missing or the
// field is absent.
func osReleaseField(field string) string {
data, err := 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")
}
}
+181
View File
@@ -0,0 +1,181 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// Default per-call cap for runCmd and runShell. Generous enough for heavy
// installs (apt, brew, large downloads) but bounded so a stuck command can't
// hang the bootstrap forever. Override per-call for genuinely longer
// operations (e.g. pyenv compiles).
const defaultSubprocessTimeout = 30 * time.Minute
// CmdOpts captures the optional knobs on runCmd / runShell.
type CmdOpts struct {
AsSudo bool
Check bool // exit on failure (kept for parity but treated as advisory — we return the error instead)
Input []byte
Capture bool
Cwd string
Timeout time.Duration // zero = defaultSubprocessTimeout
}
// CmdResult holds the outcome of a subprocess invocation.
type CmdResult struct {
ExitCode int
Stdout []byte
Stderr []byte
Err error
}
func (r CmdResult) OK() bool { return r.Err == nil && r.ExitCode == 0 }
// runCmdReal executes argv with the supplied options.
func runCmdReal(argv []string, opts CmdOpts) CmdResult {
if opts.Timeout == 0 {
opts.Timeout = defaultSubprocessTimeout
}
if opts.AsSudo && os.Geteuid() != 0 {
argv = append([]string{"sudo"}, argv...)
}
fmt.Printf(" $ %s\n", strings.Join(argv, " "))
ctx, cancel := context.WithTimeout(context.Background(), opts.Timeout)
defer cancel()
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
if opts.Input != nil {
cmd.Stdin = bytes.NewReader(opts.Input)
}
var stdout, stderr bytes.Buffer
if opts.Capture {
cmd.Stdout = &stdout
cmd.Stderr = &stderr
} else {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
err := cmd.Run()
res := CmdResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
if ctx.Err() == context.DeadlineExceeded {
warn(fmt.Sprintf("%q timed out after %s", argv[0], opts.Timeout))
res.ExitCode = 124
res.Err = ctx.Err()
return res
}
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
res.ExitCode = exitErr.ExitCode()
res.Err = err
return res
}
warn(fmt.Sprintf("error launching %q: %v", argv[0], err))
res.ExitCode = 1
res.Err = err
}
return res
}
// 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
}
fmt.Printf(" $ %s\n", cmd)
ctx, cancel := context.WithTimeout(context.Background(), opts.Timeout)
defer cancel()
c := exec.CommandContext(ctx, "/bin/sh", "-c", cmd)
if opts.Cwd != "" {
c.Dir = opts.Cwd
}
if opts.Input != nil {
c.Stdin = bytes.NewReader(opts.Input)
}
var stdout, stderr bytes.Buffer
if opts.Capture {
c.Stdout = &stdout
c.Stderr = &stderr
} else {
c.Stdout = os.Stdout
c.Stderr = os.Stderr
}
err := c.Run()
res := CmdResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
if ctx.Err() == context.DeadlineExceeded {
warn(fmt.Sprintf("shell command timed out after %s", opts.Timeout))
res.ExitCode = 124
res.Err = ctx.Err()
return res
}
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
res.ExitCode = exitErr.ExitCode()
res.Err = err
return res
}
warn(fmt.Sprintf("OSError in shell command: %v", err))
res.ExitCode = 1
res.Err = err
}
return res
}
// 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")
}
}
+33
View File
@@ -0,0 +1,33 @@
package main
import "fmt"
func installFlatpakPackages(toInstall []string) {
fmt.Println("\n=== Flatpak Packages ===")
if !hasCmd("flatpak") {
fmt.Println(" flatpak is not installed.")
if !askYN(" Install flatpak now? [y/N] ") {
warn("flatpak not installed — skipping Flatpak section")
return
}
res := pkgInstall("flatpak")
if !res.OK() || !hasCmd("flatpak") {
errLog("flatpak installation failed — skipping Flatpak section")
return
}
}
runCmd([]string{
"flatpak", "remote-add", "--if-not-exists", "flathub",
"https://dl.flathub.org/repo/flathub.flatpakrepo",
}, CmdOpts{AsSudo: true})
for _, pkgID := range toInstall {
fmt.Printf("\n Installing %s ...\n", pkgID)
res := runCmd([]string{"flatpak", "install", "--noninteractive", "flathub", pkgID}, CmdOpts{})
if !res.OK() {
errLog(fmt.Sprintf("Flatpak failed to install: %s", pkgID))
}
}
}
+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"},
]
+24
View File
@@ -0,0 +1,24 @@
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.42.0 // indirect
)
+51
View File
@@ -0,0 +1,51 @@
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=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"os"
)
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
osRename = os.Rename
osExit = os.Exit
stdin = os.Stdin
// Reset global state variables to safe defaults
isMacOS = false
pkgMgr = "dnf"
isRHELFamily = true
isArchFamily = false
osName = "linux"
archName = "x86_64"
osReleasePath = "/etc/os-release"
passwdPath = "/etc/passwd"
issuesMu.Lock()
issues = nil
notices = nil
errorCount = 0
issuesMu.Unlock()
}
+88
View File
@@ -0,0 +1,88 @@
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()
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)
}
}
+659
View File
@@ -0,0 +1,659 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// ── Xcode + Homebrew prereqs ────────────────────────────────────────────
func brewPrefix() string {
if archName == "aarch64" {
return "/opt/homebrew"
}
return "/usr/local"
}
func ensureXcodeCLT() {
if !isMacOS {
return
}
res, ok := probe([]string{"xcode-select", "-p"}, 10*time.Second)
if ok && res.ExitCode == 0 {
fmt.Printf("[Xcode CLT] Already installed at %s\n", strings.TrimSpace(string(res.Stdout)))
return
}
fmt.Println("[Xcode CLT] Installing Xcode Command Line Tools ...")
fmt.Println(" A GUI dialog will appear — click 'Install' to proceed.")
runCmd([]string{"xcode-select", "--install"}, CmdOpts{Timeout: 30 * time.Second})
fmt.Println(" Waiting for installation to complete ...")
for {
r, ok := probe([]string{"xcode-select", "-p"}, 10*time.Second)
if ok && r.ExitCode == 0 {
break
}
time.Sleep(5 * time.Second)
}
fmt.Println("[Xcode CLT] Installation complete.")
}
func ensureHomebrew() {
if !isMacOS {
return
}
if hasCmd("brew") {
path, _ := exec.LookPath("brew")
fmt.Printf("[Homebrew] Already installed at %s\n", path)
return
}
fmt.Println("[Homebrew] Installing Homebrew ...")
installer := `NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"`
if !runShell(installer, CmdOpts{}).OK() {
fmt.Fprintln(os.Stderr, "Homebrew installation failed")
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
}
time.Sleep(5 * time.Second)
}
return false
}
func waitForFirecrackerInVM(privKey string, timeout time.Duration) bool {
fmt.Printf(" Waiting for cloud-init to install firecracker inside the VM (up to %s) ...\n", timeout)
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if sshToVM(privKey, []string{"test", "-f", "/var/lib/firecracker-ready"}, 0).OK() {
fmt.Println(" firecracker is installed inside the VM.")
return true
}
time.Sleep(10 * time.Second)
}
return false
}
func firecrackerZshFunction(privKey string) string {
return fmt.Sprintf(`%s
firecracker() {
local vm_dir="%s"
if [[ ! -f "$vm_dir/%s" ]]; then
echo "firecracker: Fedora VM not provisioned (expected $vm_dir/%s)." >&2
return 1
fi
if ! "$vm_dir/vm-start.sh"; then
echo "firecracker: failed to start backing VM (see $vm_dir/vm.log)." >&2
return 1
fi
local i
for i in $(seq 1 60); do
ssh -q -i "%s" -p %d \
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=2 -o LogLevel=ERROR \
%s@127.0.0.1 true && break
sleep 1
done
local args=() a
for a in "$@"; do args+=("$(printf %%q "$a")"); done
ssh -t -q -i "%s" -p %d \
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR \
%s@127.0.0.1 "sudo /usr/local/bin/firecracker ${args[*]}"
}
%s
`,
firecrackerFnBeg,
vmDir(),
vmQcow2Name, vmQcow2Name,
privKey, vmSSHPort, vmUser,
privKey, vmSSHPort, vmUser,
firecrackerFnEnd,
)
}
func installFirecrackerZshFunction(content string) {
home, _ := os.UserHomeDir()
zshrc := filepath.Join(home, ".zshrc")
existing := ""
if b, err := 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()
}
+209
View File
@@ -0,0 +1,209 @@
// bootstrap_environment ports the Python bootstrap script to Go.
//
// Sections handled:
//
// System Packages — installed via dnf, apt-get, pacman, or brew (macOS)
// Flatpak Packages — installed via flatpak from Flathub (Linux only;
// skipped by default and skipped entirely on macOS; use --gui)
// Custom Packages — downloaded, verified, extracted
// macOS firecracker VM — provisions a Fedora cloud image under a hypervisor
// that supports nested virtualization. Suppress with --no-vm.
//
// OS detection is automatic. On macOS the first actions are to install the
// Xcode Command Line Tools and Homebrew, which is then used as the system
// package manager.
//
// Usage:
//
// Linux: sudo bootstrap_environment [--only system|flatpak|custom] [--gui]
// macOS: bootstrap_environment [--only system|custom] [--gui] [--no-vm]
// (do NOT use sudo on macOS — Homebrew refuses to run as root)
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
func main() {
runMain(os.Args)
}
func runMain(args []string) {
fs := flag.NewFlagSet(args[0], flag.ExitOnError)
only := fs.String("only", "", "Install only the named section (system|flatpak|custom)")
gui := fs.Bool("gui", false, "Include GUI applications (headed environments).")
noVM := fs.Bool("no-vm", false, "macOS only: skip provisioning the Fedora-on-QEMU VM that backs the firecracker() zsh wrapper.")
noAI := fs.Bool("no-ai", false, "Skip installation of LLM/AI CLI tools (agy, claude, codex, copilot).")
_ = fs.Parse(args[1:])
switch *only {
case "", "system", "flatpak", "custom":
default:
fmt.Fprintf(os.Stderr, "invalid --only value %q (use system|flatpak|custom)\n", *only)
osExit(2)
return
}
initPkgMgr()
systemPkgs := append([]string(nil), SystemPackages...)
flatpakPkgs := append([]string(nil), FlatpakPackages...)
custom := customPackages()
customPtrs := make([]*CustomPackage, 0, len(custom))
for i := range custom {
// Drop firecracker on macOS — it's provisioned inside the Fedora VM
// (see setupFirecrackerVM), not on the host.
if isMacOS && strings.ToLower(custom[i].Name) == "firecracker" {
continue
}
if *noAI {
name := strings.ToLower(custom[i].Name)
if name == "agy" || name == "claude" || name == "codex" || name == "copilot" {
continue
}
}
customPtrs = append(customPtrs, &custom[i])
}
fmt.Printf("OS: %s\n", osName)
fmt.Printf("Architecture: %s\n", archName)
fmt.Printf("Package manager: %s\n", pkgMgr)
if !*gui {
fmt.Println("Mode: headless (default) — skipping GUI apps and Flatpak")
}
if isMacOS {
// Refuse to run as root before doing anything (brew won't run as root).
checkSudo()
ensureXcodeCLT()
ensureHomebrew()
}
fmt.Println("Checking installed packages ...")
if !*gui {
var skippedGUI, kept []string
for _, p := range systemPkgs {
if guiSystemPkgs[p] {
skippedGUI = append(skippedGUI, p)
} else {
kept = append(kept, p)
}
}
systemPkgs = kept
if len(skippedGUI) > 0 {
fmt.Printf(" [HEADLESS] Skipping GUI system packages: %s\n", fmtList(skippedGUI, 6))
}
flatpakPkgs = nil
}
doFlatpak := (*only == "" || *only == "flatpak") && *gui && !isMacOS
var sysCheck systemCheckResult
var flatCheck flatpakCheckResult
var custCheck customCheckResult
if *only == "" || *only == "system" {
sysCheck = checkSystemPackages(systemPkgs)
}
if doFlatpak {
flatCheck = checkFlatpakPackages(flatpakPkgs)
}
if *only == "" || *only == "custom" {
custCheck = checkCustomPackages(customPtrs)
}
total := printCheckSummary(sysCheck, flatCheck, custCheck, *only)
if total == 0 {
fmt.Println("\nAll packages already installed.")
writeRunLog()
return
}
if !askYN(fmt.Sprintf("\n%d item(s) to install. Proceed? [y/N] ", total)) {
fmt.Fprintln(os.Stderr, "Aborted.")
osExit(1)
return
}
checkSudo()
if *only == "" || *only == "system" {
installSystemPackages(sysCheck.toInstallRegular, sysCheck.toInstallSpecial)
ensureZshDefault()
}
if doFlatpak {
installFlatpakPackages(flatCheck.toInstall)
}
var pyenvWG interface{ Wait() }
if *only == "" || *only == "custom" {
installCustomPackages(custCheck.toInstall)
ensureNodeLTS()
if wg := ensurePythonLatest(); wg != nil {
pyenvWG = wg
}
}
if *only == "" {
checkAndSetupSSH()
cloneNvimConfig()
if isMacOS && !*noVM {
setupFirecrackerVM()
}
}
if pyenvWG != nil {
fmt.Println("\n[pyenv] Waiting for background Python install to finish ...")
pyenvWG.Wait()
}
writeRunLog()
printNotices()
fmt.Println("\nDone.")
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{})
}
}
}
func checkSudo() {
if os.Geteuid() == 0 {
if isMacOS {
fmt.Fprintln(os.Stderr, "Do not run this with sudo on macOS — Homebrew refuses to run as root. "+
"Re-run as your regular user; the tool will request sudo for the operations that need it.")
osExit(1)
return
}
return
}
if !hasCmd("sudo") {
fmt.Fprintln(os.Stderr, "sudo is required but not installed.")
osExit(1)
return
}
fmt.Println("Validating sudo access ...")
r := runCmd([]string{"sudo", "-v"}, CmdOpts{Timeout: 2 * time.Minute})
if r.ExitCode != 0 {
fmt.Fprintln(os.Stderr, "sudo authentication failed.")
osExit(1)
return
}
}
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"os"
"strings"
"testing"
"time"
)
func TestRunMainInvalidOnly(t *testing.T) {
defer resetMocks()
var exited bool
var exitCode int
osExit = func(code int) {
exited = true
exitCode = code
}
runMain([]string{"bootstrap_environment", "--only", "invalid"})
if !exited {
t.Error("expected runMain with invalid --only value to exit")
}
if exitCode != 2 {
t.Errorf("expected exit code 2, got %d", exitCode)
}
}
func TestRunMainAllInstalled(t *testing.T) {
defer resetMocks()
// All packages are already installed (total packages to install = 0)
osStat = func(name string) (os.FileInfo, error) {
return nil, nil // all custom paths exist
}
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "rpm" || argv[0] == "dpkg-query" || argv[0] == "pacman" {
return CmdResult{ExitCode: 0, Stdout: []byte("install ok installed")}, true
}
if argv[0] == "flatpak" {
return CmdResult{ExitCode: 0}, true
}
return CmdResult{ExitCode: 0}, true
}
hasCmd = func(name string) bool {
return true
}
var exited bool
osExit = func(code int) {
exited = true
}
// We only run custom to keep it short & avoid other logic dependencies
runMain([]string{"bootstrap_environment", "--only", "custom"})
if exited {
t.Error("expected program to complete successfully without exiting")
}
}
func TestRunMainInstallAbort(t *testing.T) {
defer resetMocks()
// Simulating some packages to install, but user selects N
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist // custom paths missing -> need install
}
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 1}, true // packages not installed
}
hasCmd = func(name string) bool {
return true
}
// Mock stdin to say "n" to abort the prompt "Proceed? [y/N]"
stdin = strings.NewReader("n\n")
var exited bool
var exitCode int
osExit = func(code int) {
exited = true
exitCode = code
}
runMain([]string{"bootstrap_environment", "--only", "custom"})
if !exited {
t.Error("expected program to exit on user abort")
}
if exitCode != 1 {
t.Errorf("expected exit code 1, got %d", exitCode)
}
}
func TestRunMainMacos(t *testing.T) {
defer resetMocks()
isMacOS = true
pkgMgr = "brew"
osStat = func(name string) (os.FileInfo, error) {
return nil, nil // brew etc exist
}
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 0}, true
}
hasCmd = func(name string) bool {
return true
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 0}
}
runShell = func(cmd string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 0}
}
var exited bool
osExit = func(code int) {
exited = true
}
// Run with --only custom, --no-vm, --no-ai
runMain([]string{"bootstrap_environment", "--only", "custom", "--no-vm", "--no-ai"})
if exited {
t.Error("expected program to complete successfully on macOS mock run")
}
}
+116
View File
@@ -0,0 +1,116 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
const httpClientTimeout = 30 * time.Minute
var httpClient = &http.Client{Timeout: httpClientTimeout}
// downloadReal streams url -> dest. Returns true on success.
func downloadReal(url, dest string) bool {
fmt.Printf(" Downloading %s ...\n", filepath.Base(url))
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
errLog(fmt.Sprintf("Download failed for %s: %v", url, err))
return false
}
resp, err := httpClient.Do(req)
if err != nil {
errLog(fmt.Sprintf("Download failed for %s: %v", url, err))
return false
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
errLog(fmt.Sprintf("Download failed for %s: HTTP %d", url, resp.StatusCode))
return false
}
f, err := os.Create(dest)
if err != nil {
errLog(fmt.Sprintf("Download failed for %s: %v", url, err))
return false
}
defer f.Close()
if _, err := io.Copy(f, resp.Body); err != nil {
errLog(fmt.Sprintf("Download failed for %s: %v", url, err))
return false
}
return true
}
// 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
}
+238
View File
@@ -0,0 +1,238 @@
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")
}
}
+146
View File
@@ -0,0 +1,146 @@
package main
// Package definitions consumed by the bootstrap entry point.
//
// SystemPackages and FlatpakPackages are flat name lists. CustomPackages
// declare a URL template plus an optional FetchLatest hint; at install time
// the resolver attempts to look up the most recent release and falls back to
// the pinned (Version, sha256) tuple on failure.
//
// URL templates use the substitutions described in formatURL.
var SystemPackages = []string{
"ansible",
"ansible-core",
"aria2",
"bashtop",
"build-essential",
"buildah",
"containerd.io",
"docker-buildx-plugin",
"docker-ce-cli",
"docker-ce-rootless-extras",
"docker-ce",
"docker-compose-plugin",
"dotnet-sdk-10.0",
"ffmpeg-free",
"gcc",
"gh",
"git",
"github-desktop",
"google-chrome-stable",
"lazygit",
"lua",
"minisign",
"minikube",
"obs-studio",
"obsidian",
"pipx",
"poetry",
"pulumi",
"podman",
"qemu",
"restic",
"rg",
"shutter",
"temurin-25-jdk",
"vagrant",
"virt-manager",
"vivaldi-stable",
"webcamoid",
"wireshark",
"yt-dlp",
"zoom",
"zsh",
"bzip2",
"bzip2-devel",
"curl",
"gdbm-libs",
"libffi-devel",
"libnsl2",
"libuuid-devel",
"libxml2-devel",
"libzstd-devel",
"make",
"ncurses-devel",
"openssl-devel",
"patch",
"readline-devel",
"sqlite",
"sqlite-devel",
"tk-devel",
"xmlsec1-devel",
"xz",
"xz-devel",
"zlib-devel",
}
var FlatpakPackages = []string{
"com.obsproject.Studio",
"fr.handbrake.ghb",
"io.github.webcamoid.Webcamoid",
"one.ablaze.floorp",
"com.vivaldi.Vivaldi",
"org.darktable.Darktable",
}
// CustomPackage describes a third-party tarball/binary we fetch directly
// (i.e. not via the host package manager).
type CustomPackage struct {
Name string
Version string // pinned fallback version
URLTemplate string // see formatURL for substitutions
SHA256 string // single-arch hex digest (set by resolveLatest)
SHA256Map map[string]string // per-platform pinned digests: {"os-arch": hex}
SHA256URLTemplate string // template for a .minisig URL
MinisignKey string // base64 public key for minisign verification
FetchLatest string // latest-version resolver hint ("go", "firecracker", "zig")
InstallPath string // override the default install-check path
}
func customPackages() []CustomPackage {
return []CustomPackage{
{
Name: "go",
Version: "1.26.3",
URLTemplate: "https://go.dev/dl/go{version}.{os_go}-{arch_go}.tar.gz",
SHA256Map: map[string]string{
"linux-x86_64": "2b2cfc7148493da5e73981bffbf3353af381d5f93e789c82c79aff64962eb556",
"linux-aarch64": "9d89a3ea57d141c2b22d70083f2c8459ba3890f2d9e818e7e933b75614936565",
"macos-x86_64": "278d580b32e299fe4a9c990fcf2d02acfe538c7e551a6ee18f9c7164573d2c63",
"macos-aarch64": "875cf54a15311eee2c99b9dd67c68c4a49351d489ab622bf2cfd28c8f2078d3c",
},
FetchLatest: "go",
},
{Name: "neovim"},
{
Name: "firecracker",
Version: "1.15.1",
URLTemplate: "https://github.com/firecracker-microvm/firecracker/releases/download/" +
"v{version}/firecracker-v{version}-{arch}.tgz",
SHA256Map: map[string]string{
"linux-x86_64": "d4a32ab2322d887ca1bc4a4e7afa9cc35393e6362dfc2b3becb389d362e4275a",
"linux-aarch64": "00654ac1e702a22744121ea9f10a4f792ebd7c3a744cba587dfac9fcb79b41a5",
},
FetchLatest: "firecracker",
},
{
Name: "zig",
Version: "0.16.0",
URLTemplate: "https://ziglang.org/download/{version}/zig-{arch}-{os_zig}-{version}.tar.xz",
SHA256URLTemplate: "https://ziglang.org/download/{version}/zig-{arch}-{os_zig}-{version}.tar.xz.minisig",
MinisignKey: "RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U",
FetchLatest: "zig",
},
{Name: "nvm"},
{Name: "pyenv"},
{Name: "pip"},
{Name: "oh-my-zsh"},
{Name: "agy"},
{Name: "claude"},
{Name: "codex"},
{Name: "copilot"},
{Name: "playwright"},
{Name: "gh-repo-bootstrap"},
}
}
+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": {
"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"),
"bashtop": replace("btop"),
"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: bashtop -> btop (replace), buildah -> skipped
pkgMgr = "brew"
resolved, skipped = resolveSystemPkgs([]string{"bashtop", "buildah", "rg"})
if len(resolved) != 2 || resolved[0] != "btop" || resolved[1] != "ripgrep" {
t.Errorf("expected [btop 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")
}
}
+537
View File
@@ -0,0 +1,537 @@
package main
import (
"fmt"
"io"
"os"
"os/user"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// ── pyenv / Python ──────────────────────────────────────────────────────
func installPyenv() {
fmt.Println(" Installing pyenv via curl ...")
if !runShell("curl https://pyenv.run | bash", CmdOpts{}).OK() {
errLog("pyenv installation failed")
return
}
fmt.Println(" pyenv installed to ~/.pyenv")
}
func python3DecimalOK() bool {
if !hasCmd("python3") {
return false
}
r, ok := probe([]string{"python3", "-c", "from decimal import Decimal"}, 10*time.Second)
return ok && r.ExitCode == 0
}
func fixPython3Decimal() bool {
switch pkgMgr {
case "apt-get":
runCmd([]string{"apt-get", "install", "-y", "python3-full"}, CmdOpts{AsSudo: true})
case "dnf":
runCmd([]string{"dnf", "install", "-y", "python3-libs"}, CmdOpts{AsSudo: true})
case "pacman":
runCmd([]string{"pacman", "-S", "--noconfirm", "--needed", "python"}, CmdOpts{AsSudo: true})
}
return python3DecimalOK()
}
func installPip() {
if !hasCmd("python3") {
errLog("python3 is not installed — cannot install pip")
return
}
if !python3DecimalOK() {
warn("Python 3 _decimal C extension failed to import — attempting fix ...")
if fixPython3Decimal() {
fmt.Println(" Python 3 _decimal extension restored.")
} else {
errLog("Python 3 _decimal C extension could not be fixed. " +
"Run: sudo apt-get install python3-full (Debian/Ubuntu), " +
"sudo dnf install python3-libs (Fedora/RHEL), or " +
"sudo pacman -S python (Arch)")
return
}
}
fmt.Println(" Bootstrapping pip via 'python3 -m ensurepip --upgrade' ...")
bootstrap := runCmd([]string{"python3", "-m", "ensurepip", "--upgrade"}, CmdOpts{AsSudo: true})
if !bootstrap.OK() {
switch pkgMgr {
case "apt-get":
warn("ensurepip unavailable in system Python — installing python3-pip via apt-get")
if !runCmd([]string{"apt-get", "install", "-y", "python3-pip"}, CmdOpts{AsSudo: true}).OK() {
errLog("python3-pip failed to install via apt-get — skipping pip bootstrap")
return
}
case "pacman":
warn("ensurepip unavailable in system Python — installing python-pip via pacman")
if !runCmd([]string{"pacman", "-S", "--noconfirm", "--needed", "python-pip"}, CmdOpts{AsSudo: true}).OK() {
errLog("python-pip failed to install via pacman — skipping pip bootstrap")
return
}
default:
errLog("python3 -m ensurepip failed (system Python may need a distro 'python3-pip' package)")
return
}
}
fmt.Println(" Upgrading pip to the latest version ...")
upgrade := runCmd([]string{"python3", "-m", "pip", "install", "--upgrade", "pip"}, CmdOpts{AsSudo: true})
if !upgrade.OK() {
warn("pip self-upgrade failed (likely PEP 668 externally-managed); ensurepip-provided pip remains")
}
}
func latestStablePython(pyenvBin string) string {
r, ok := probe([]string{pyenvBin, "install", "--list"}, 2*time.Minute)
if !ok {
errLog("pyenv install --list failed")
return ""
}
if r.ExitCode != 0 {
errLog("pyenv install --list failed")
return ""
}
stableRe := regexp.MustCompile(`^\s*(\d+)\.(\d+)\.(\d+)\s*$`)
type ver struct{ a, b, c int }
var versions []ver
for _, line := range strings.Split(string(r.Stdout), "\n") {
m := stableRe.FindStringSubmatch(line)
if m == nil {
continue
}
a, _ := strconv.Atoi(m[1])
b, _ := strconv.Atoi(m[2])
c, _ := strconv.Atoi(m[3])
if a >= 3 {
versions = append(versions, ver{a, b, c})
}
}
if len(versions) == 0 {
return ""
}
sort.Slice(versions, func(i, j int) bool {
if versions[i].a != versions[j].a {
return versions[i].a < versions[j].a
}
if versions[i].b != versions[j].b {
return versions[i].b < versions[j].b
}
return versions[i].c < versions[j].c
})
v := versions[len(versions)-1]
return fmt.Sprintf("%d.%d.%d", v.a, v.b, v.c)
}
// ensurePythonLatest installs the latest stable Python via pyenv if not
// present, then sets it as global. Returns a wait group if an install was
// kicked off in the background; the caller must call .Wait() before exiting.
func ensurePythonLatest() *sync.WaitGroup {
home, _ := os.UserHomeDir()
pyenvDir := filepath.Join(home, ".pyenv")
if _, err := 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)
fmt.Printf(" Installing NVM %s via curl ...\n", version)
if !runShell(fmt.Sprintf("curl -o- %s | bash", installURL), CmdOpts{}).OK() {
errLog("NVM installation failed")
return
}
fmt.Printf(" NVM %s installed to ~/.nvm\n", version)
}
func ensureNodeLTS() {
home, _ := os.UserHomeDir()
if _, err := 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 {
fmt.Printf(" oh-my-zsh already present at %s; updating theme only\n", target)
} else {
fmt.Println(" Installing oh-my-zsh via the official installer ...")
installer := `sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended`
if !runShell(installer, CmdOpts{}).OK() {
errLog("oh-my-zsh installer failed")
return
}
}
zshrc := filepath.Join(home, ".zshrc")
data, err := 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
}
fmt.Println(` Set ZSH_THEME="gnzh" in ~/.zshrc`)
} else {
fmt.Println(` ~/.zshrc already has ZSH_THEME="gnzh"`)
}
}
// ── default-shell + neovim config + gh auth ─────────────────────────────
func invokingUser() string {
if u := os.Getenv("SUDO_USER"); u != "" {
return u
}
if u, err := user.Current(); err == nil {
return u.Username
}
return ""
}
func ensureZshDefault() {
if !hasCmd("zsh") {
warn("zsh not installed — skipping default-shell change")
return
}
zshPath := "/bin/zsh"
if r, ok := probe([]string{"which", "zsh"}, 5*time.Second); ok && r.ExitCode == 0 {
if p := strings.TrimSpace(string(r.Stdout)); p != "" {
zshPath = p
}
}
username := invokingUser()
if username == "" {
warn("could not determine invoking user; skipping default-shell change")
return
}
u, err := user.Lookup(username)
if err != nil {
warn(fmt.Sprintf("user %s not found in passwd; skipping default-shell change", username))
return
}
current := userLoginShell(u.Uid)
if current == zshPath {
fmt.Printf("\n[zsh] %s's default shell is already %s.\n", username, zshPath)
return
}
family := "Debian-family"
if isRHELFamily {
family = "RHEL-family"
} else if isArchFamily {
family = "Arch-family"
} else if isMacOS {
family = "macOS"
}
fmt.Printf("\n[zsh] Setting default shell for %s to %s (%s) ...\n", username, zshPath, family)
var cmd []string
if isRHELFamily {
cmd = []string{"usermod", "-s", zshPath, username}
} else {
cmd = []string{"chsh", "-s", zshPath, username}
}
if !runCmd(cmd, CmdOpts{AsSudo: true}).OK() {
errLog(fmt.Sprintf("Failed to set default shell to zsh for %s", username))
} else {
fmt.Println("[zsh] Default shell updated. Log out and back in for it to take effect.")
}
}
// userLoginShell returns the login shell for uid by parsing /etc/passwd. On
// macOS the shell may be set by dscl; getent isn't available either, so we
// just read passwd directly which works on every supported platform.
func userLoginShell(uid string) string {
data, err := osReadFile(passwdPath)
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
parts := strings.Split(line, ":")
if len(parts) < 7 {
continue
}
if parts[2] == uid {
return parts[6]
}
}
return ""
}
func cloneNvimConfig() {
home, _ := os.UserHomeDir()
configDir := filepath.Join(home, ".config", "nvim")
const sshURL = "git@github.com:JMR-dev/nvim-config.git"
const httpsURL = "https://github.com/JMR-dev/nvim-config.git"
fmt.Printf("\n[Neovim] Setting up configuration from %s ...\n", sshURL)
if _, err := osStat(configDir); err == nil {
n := 1
var backup string
for {
backup = filepath.Join(filepath.Dir(configDir), fmt.Sprintf("nvim-%d", n))
if _, err := osStat(backup); os.IsNotExist(err) {
break
}
n++
}
fmt.Printf(" Renaming existing %s → %s ...\n", configDir, backup)
if err := osRename(configDir, backup); err != nil {
errLog(fmt.Sprintf("could not back up existing nvim config: %v", err))
return
}
notice(fmt.Sprintf("Previous Neovim config preserved at %s", backup))
}
osMkdirAll(filepath.Dir(configDir), 0o755)
repoName := strings.TrimSuffix(filepath.Base(sshURL), ".git")
tempClone := filepath.Join(filepath.Dir(configDir), repoName)
osRemoveAll(tempClone)
fmt.Printf(" Cloning to %s ...\n", configDir)
if !runCmd([]string{"git", "clone", sshURL, tempClone}, CmdOpts{}).OK() {
fmt.Printf(" SSH clone failed; falling back to HTTPS (%s) ...\n", httpsURL)
osRemoveAll(tempClone)
if !runCmd([]string{"git", "clone", httpsURL, tempClone}, CmdOpts{}).OK() {
errLog("Neovim configuration clone failed")
return
}
}
if tempClone != configDir {
fmt.Printf(" Renaming %s to %s ...\n", filepath.Base(tempClone), filepath.Base(configDir))
osRename(tempClone, configDir)
}
fmt.Printf(" Neovim configuration ready at %s\n", configDir)
}
func ghLoggedIn() bool {
r, ok := probe([]string{"gh", "auth", "status"}, 30*time.Second)
return ok && r.ExitCode == 0
}
func checkAndSetupSSH() {
if !hasCmd("gh") {
fmt.Println("\n[GitHub CLI] gh not installed — skipping authentication.")
return
}
if ghLoggedIn() {
fmt.Println("\n[GitHub CLI] Already authenticated.")
return
}
if !askYN("\n[GitHub CLI] Would you like to authenticate the GitHub CLI? [y/N] ") {
return
}
res := runCmd([]string{"gh", "auth", "login"}, CmdOpts{Timeout: 15 * time.Minute})
if !res.OK() {
errLog("gh auth login failed — skipping key upload.")
return
}
}
// askYN prompts on stdin. Returns true only for an exact "y" (case-insensitive).
func askYN(prompt string) bool {
fmt.Print(prompt)
var buf [256]byte
n, err := stdin.Read(buf[:])
if err != nil && err != io.EOF {
fmt.Println()
return false
}
answer := strings.ToLower(strings.TrimSpace(string(buf[:n])))
return answer == "y"
}
func installAgy() {
fmt.Println(" Installing agy via curl ...")
if !runShell("curl -fsSL https://antigravity.google/cli/install.sh | bash", CmdOpts{}).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()
fmt.Printf(" 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{}).OK() {
errLog(fmt.Sprintf("%s installation failed", pkgName))
}
}
func installPlaywright() {
home, _ := os.UserHomeDir()
if _, err := osStat(filepath.Join(home, ".nvm")); err != nil {
errLog("NVM is not installed — cannot install playwright")
return
}
ensureNodeLTS()
fmt.Println(" Installing playwright via pnpm ...")
addCmd := fmt.Sprintf(`bash -c '%ssource ~/.nvm/nvm.sh && pnpm add -g playwright'`, pnpmEnvPrefix())
if !runShell(addCmd, CmdOpts{}).OK() {
errLog("playwright installation failed")
return
}
installCmd := "pnpx playwright install"
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 installGHExtension(repo string) {
if !hasCmd("gh") {
errLog("gh CLI is not installed — cannot install extension " + repo)
return
}
fmt.Printf(" Installing gh extension %s ...\n", repo)
if !runCmd([]string{"gh", "extension", "install", repo}, CmdOpts{}).OK() {
errLog(fmt.Sprintf("gh extension install %s failed", repo))
}
}
+908
View File
@@ -0,0 +1,908 @@
package main
import (
"fmt"
"os"
"os/user"
"path/filepath"
"strings"
"testing"
"time"
)
func TestInstallPyenv(t *testing.T) {
defer resetMocks()
var runShellCmd string
runShell = func(cmd string, opts CmdOpts) CmdResult {
runShellCmd = cmd
return CmdResult{ExitCode: 0}
}
installPyenv()
if !strings.Contains(runShellCmd, "pyenv.run | bash") {
t.Errorf("unexpected shell command: %q", runShellCmd)
}
}
func TestPython3DecimalOK(t *testing.T) {
defer resetMocks()
hasCmd = func(name string) bool { return true }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 0}, true
}
if !python3DecimalOK() {
t.Error("expected decimal OK")
}
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 1}, true
}
if python3DecimalOK() {
t.Error("expected decimal NOT OK")
}
}
func TestFixPython3Decimal(t *testing.T) {
defer resetMocks()
pkgMgr = "apt-get"
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 }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
// Mock python3DecimalOK to return true after fix is called
return CmdResult{ExitCode: 0}, true
}
ok := fixPython3Decimal()
if !ok {
t.Error("expected fix to succeed")
}
if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "apt-get" || runCmdCalls[0][3] != "python3-full" {
t.Errorf("expected apt-get install python3-full, got: %v", runCmdCalls)
}
}
func TestLatestStablePython(t *testing.T) {
defer resetMocks()
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
output := `
3.10.1
3.11.2
3.12.0
3.12.1
3.12.2
2.7.18
system
`
return CmdResult{ExitCode: 0, Stdout: []byte(output)}, true
}
latest := latestStablePython("/path/to/pyenv")
if latest != "3.12.2" {
t.Errorf("expected 3.12.2, got %q", latest)
}
}
func TestInstallNVM(t *testing.T) {
defer resetMocks()
fetchJSON = func(url string, v any) bool {
rel := v.(*ghRelease)
rel.TagName = "v0.39.5"
return true
}
var runShellCmd string
runShell = func(cmd string, opts CmdOpts) CmdResult {
runShellCmd = cmd
return CmdResult{ExitCode: 0}
}
installNVM()
if !strings.Contains(runShellCmd, "nvm/v0.39.5/install.sh") {
t.Errorf("unexpected NVM install script: %q", runShellCmd)
}
}
func TestInstallOhMyZsh(t *testing.T) {
defer resetMocks()
hasCmd = func(name string) bool { return true }
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist // Not present, trigger install
}
var runShellCmd string
runShell = func(cmd string, opts CmdOpts) CmdResult {
runShellCmd = cmd
return CmdResult{ExitCode: 0}
}
tmp := t.TempDir()
t.Setenv("HOME", tmp)
zshrc := filepath.Join(tmp, ".zshrc")
osWriteFile(zshrc, []byte("ZSH_THEME=\"robbyrussell\""), 0644)
osReadFile = func(name string) ([]byte, error) {
if name == zshrc {
return []byte("ZSH_THEME=\"robbyrussell\""), 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
}
installOhMyZsh()
if !strings.Contains(runShellCmd, "ohmyzsh/master/tools/install.sh") {
t.Errorf("unexpected installer: %q", runShellCmd)
}
if !strings.Contains(writtenContent, "ZSH_THEME=\"gnzh\"") {
t.Errorf("expected theme replaced to gnzh, got: %q", writtenContent)
}
}
func TestEnsureZshDefault(t *testing.T) {
defer resetMocks()
hasCmd = func(name string) bool { return true }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 0, Stdout: []byte("/bin/zsh")}, true
}
u, err := user.Current()
if err != nil {
t.Fatal(err)
}
t.Setenv("SUDO_USER", u.Username)
passwdPath = filepath.Join(t.TempDir(), "passwd")
passwdContent := fmt.Sprintf("%s:x:%s:%s::/home/%s:/bin/bash\n", u.Username, u.Uid, u.Gid, u.Username)
osWriteFile(passwdPath, []byte(passwdContent), 0644)
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
isRHELFamily = false
ensureZshDefault()
if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "chsh" || runCmdCalls[0][2] != "/bin/zsh" {
t.Errorf("expected chsh call to zsh, got: %v", runCmdCalls)
}
}
func TestCloneNvimConfig(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
t.Setenv("HOME", tmp)
var statCalls []string
osStat = func(name string) (os.FileInfo, error) {
statCalls = append(statCalls, name)
if strings.HasSuffix(name, "nvim") {
return nil, nil // config directory exists
}
return nil, os.ErrNotExist // backup folder doesn't exist
}
var renameCalls [][]string
osRename = func(oldpath, newpath string) error {
renameCalls = append(renameCalls, []string{oldpath, newpath})
return nil
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
cloneNvimConfig()
// Should have at least one rename call (for backup or tempClone rename)
if len(renameCalls) < 2 {
t.Fatalf("expected at least 2 rename calls, got: %v", renameCalls)
}
if !strings.HasSuffix(renameCalls[0][0], "nvim") || !strings.Contains(renameCalls[0][1], "nvim-1") {
t.Errorf("expected backup rename first, got: %v", renameCalls[0])
}
if !strings.HasSuffix(renameCalls[1][0], "nvim-config") || !strings.HasSuffix(renameCalls[1][1], "nvim") {
t.Errorf("expected temp clone rename second, got: %v", renameCalls[1])
}
if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "git" || runCmdCalls[0][1] != "clone" {
t.Errorf("expected git clone call, got: %v", runCmdCalls)
}
}
func TestCloneNvimConfigSSHFallback(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
t.Setenv("HOME", tmp)
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist // nothing exists
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
// First clone (SSH) fails; second (HTTPS) succeeds.
if len(runCmdCalls) == 1 {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
cloneNvimConfig()
if len(runCmdCalls) != 2 {
t.Fatalf("expected 2 git clone attempts (SSH then HTTPS), got: %v", runCmdCalls)
}
if !strings.HasPrefix(runCmdCalls[0][2], "git@github.com:") {
t.Errorf("expected first attempt to use SSH URL, got: %v", runCmdCalls[0])
}
if !strings.HasPrefix(runCmdCalls[1][2], "https://github.com/") {
t.Errorf("expected fallback to use HTTPS URL, got: %v", runCmdCalls[1])
}
if hasErrors() {
t.Errorf("expected no errors logged when HTTPS fallback succeeds")
}
}
func TestCloneNvimConfigBothFail(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
t.Setenv("HOME", tmp)
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 1}
}
cloneNvimConfig()
if !hasErrors() {
t.Errorf("expected error logged when both SSH and HTTPS clones fail")
}
}
func TestCheckAndSetupSSH(t *testing.T) {
defer resetMocks()
hasCmd = func(name string) bool { return true }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
// Mock ghLoggedIn to return false (requires auth)
return CmdResult{ExitCode: 1}, true
}
stdin = strings.NewReader("y\n") // agree to login
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
checkAndSetupSSH()
if len(runCmdCalls) != 1 || runCmdCalls[0][0] != "gh" || runCmdCalls[0][2] != "login" {
t.Errorf("expected gh auth login command, got: %v", runCmdCalls)
}
}
func TestInstallPipAgyNpm(t *testing.T) {
defer resetMocks()
// Case 1: python3 not installed for pip
hasCmd = func(name string) bool { return false }
installPip() // should log error and return
// Case 2: installPip when python3 is installed
resetMocks()
hasCmd = func(name string) bool { return true }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 0}, true // decimal OK and pip check OK
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
installPip()
if len(runCmdCalls) < 2 {
t.Errorf("expected ensurepip and upgrade pip calls, got: %v", runCmdCalls)
}
// Test installAgy
resetMocks()
var runShellCmd string
runShell = func(cmd string, opts CmdOpts) CmdResult {
runShellCmd = cmd
return CmdResult{ExitCode: 0}
}
installAgy()
if !strings.Contains(runShellCmd, "antigravity.google/cli/install.sh") {
t.Errorf("unexpected agy install command: %q", runShellCmd)
}
// Test installNpmPackage
resetMocks()
var runShellCmds []string
runShell = func(cmd string, opts CmdOpts) CmdResult {
runShellCmds = append(runShellCmds, cmd)
return CmdResult{ExitCode: 0}
}
osStat = func(name string) (os.FileInfo, error) {
return nil, nil // nvm exists
}
installNpmPackage("my-package")
if len(runShellCmds) < 1 || !strings.Contains(runShellCmds[len(runShellCmds)-1], "pnpm add -g my-package") {
t.Errorf("unexpected npm install command: %v", runShellCmds)
}
if !strings.Contains(runShellCmds[len(runShellCmds)-1], `PNPM_HOME=`) {
t.Errorf("expected PNPM_HOME export, got: %v", runShellCmds)
}
// macOS uses ~/Library/pnpm
resetMocks()
isMacOS = true
runShellCmds = nil
runShell = func(cmd string, opts CmdOpts) CmdResult {
runShellCmds = append(runShellCmds, cmd)
return CmdResult{ExitCode: 0}
}
osStat = func(name string) (os.FileInfo, error) {
return nil, nil
}
installNpmPackage("my-package")
if len(runShellCmds) < 1 || !strings.Contains(runShellCmds[len(runShellCmds)-1], `Library/pnpm`) {
t.Errorf("expected macOS PNPM_HOME path, got: %v", runShellCmds)
}
}
func TestEnsurePythonAndNode(t *testing.T) {
defer resetMocks()
// Test ensurePythonLatest
osStat = func(name string) (os.FileInfo, error) {
return nil, nil // pyenvDir and pyenvBin exist
}
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[1] == "install" {
return CmdResult{ExitCode: 0, Stdout: []byte(" 3.12.0\n")}, true
}
if argv[1] == "versions" {
return CmdResult{ExitCode: 0, Stdout: []byte(" 3.12.0\n")}, true // already installed
}
return CmdResult{ExitCode: 0}, true
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
wg := ensurePythonLatest()
if wg != nil {
t.Error("expected nil waitgroup since Python 3.12.0 is already installed")
}
if len(runCmdCalls) != 1 || runCmdCalls[0][1] != "global" {
t.Errorf("expected global 3.12.0 command, got: %v", runCmdCalls)
}
// Test ensureNodeLTS
resetMocks()
osStat = func(name string) (os.FileInfo, error) {
return nil, nil // nvm exists
}
var runShellCmds []string
runShell = func(cmd string, opts CmdOpts) CmdResult {
runShellCmds = append(runShellCmds, cmd)
if strings.Contains(cmd, "nvm version lts/*") {
return CmdResult{ExitCode: 0, Stdout: []byte("N/A")} // Not installed yet
}
return CmdResult{ExitCode: 0}
}
ensureNodeLTS()
hasInstall := false
hasPnpmSetup := false
for _, cmd := range runShellCmds {
if strings.Contains(cmd, "nvm install --lts") {
hasInstall = true
}
if strings.Contains(cmd, "pnpm setup") {
hasPnpmSetup = true
}
}
if !hasInstall {
t.Errorf("expected nvm install --lts, got: %v", runShellCmds)
}
if !hasPnpmSetup {
t.Errorf("expected pnpm setup, got: %v", runShellCmds)
}
}
func TestInstallPipEdgeCases(t *testing.T) {
defer resetMocks()
hasCmd = func(name string) bool { return true }
var probeCalls [][]string
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
probeCalls = append(probeCalls, argv)
if len(probeCalls) == 1 {
return CmdResult{ExitCode: 1}, true
}
return CmdResult{ExitCode: 0}, true
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
pkgMgr = "dnf"
installPip()
if len(runCmdCalls) < 3 {
t.Errorf("expected python3-libs fix, ensurepip, and upgrade, got: %v", runCmdCalls)
}
resetMocks()
hasCmd = func(name string) bool { return true }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 1}, true
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 0}
}
installPip()
resetMocks()
pkgMgr = "apt-get"
hasCmd = func(name string) bool { return true }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 0}, true
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[0] == "python3" && argv[2] == "ensurepip" {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
installPip()
resetMocks()
pkgMgr = "pacman"
hasCmd = func(name string) bool { return true }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 0}, true
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[0] == "python3" && argv[2] == "ensurepip" {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
installPip()
resetMocks()
pkgMgr = "brew"
hasCmd = func(name string) bool { return true }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 0}, true
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[0] == "python3" && argv[2] == "ensurepip" {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
installPip()
resetMocks()
hasCmd = func(name string) bool { return true }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 0}, true
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[0] == "python3" && argv[3] == "install" {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
installPip()
}
func TestEnsureZshDefaultEdgeCases(t *testing.T) {
defer resetMocks()
hasCmd = func(name string) bool { return false }
ensureZshDefault()
resetMocks()
hasCmd = func(name string) bool {
return name == "zsh"
}
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[0] == "which" {
return CmdResult{ExitCode: 1}, true
}
return CmdResult{ExitCode: 0, Stdout: []byte("")}, true
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 0}
}
passwdPath = filepath.Join(t.TempDir(), "passwd")
ensureZshDefault()
resetMocks()
isRHELFamily = true
hasCmd = func(name string) bool { return true }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 0, Stdout: []byte("/bin/zsh")}, true
}
u, _ := user.Current()
t.Setenv("SUDO_USER", u.Username)
passwdPath = filepath.Join(t.TempDir(), "passwd")
passwdContent := fmt.Sprintf("%s:x:%s:%s::/home/%s:/bin/zsh\n", u.Username, u.Uid, u.Gid, u.Username)
osWriteFile(passwdPath, []byte(passwdContent), 0644)
ensureZshDefault()
resetMocks()
isRHELFamily = true
hasCmd = func(name string) bool { return true }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 0, Stdout: []byte("/bin/zsh")}, true
}
t.Setenv("SUDO_USER", u.Username)
passwdContent = fmt.Sprintf("%s:x:%s:%s::/home/%s:/bin/bash\n", u.Username, u.Uid, u.Gid, u.Username)
osWriteFile(passwdPath, []byte(passwdContent), 0644)
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 1}
}
ensureZshDefault()
}
func TestUserLoginShellEdgeCases(t *testing.T) {
defer resetMocks()
passwdPath = "/nonexistent"
if shell := userLoginShell("1000"); shell != "" {
t.Errorf("expected empty shell, got %q", shell)
}
passwdPath = filepath.Join(t.TempDir(), "passwd")
content := "root:x:0:0:root:/root\nmalformed:line\n"
osWriteFile(passwdPath, []byte(content), 0644)
if shell := userLoginShell("0"); shell != "" {
t.Errorf("expected empty shell for malformed/short lines, got %q", shell)
}
}
func TestCloneNvimConfigEdgeCases(t *testing.T) {
defer resetMocks()
tmp := t.TempDir()
t.Setenv("HOME", tmp)
osStat = func(name string) (os.FileInfo, error) {
if strings.HasSuffix(name, "nvim") {
return nil, nil
}
return nil, os.ErrNotExist
}
osRename = func(oldpath, newpath string) error {
return fmt.Errorf("rename failed error")
}
cloneNvimConfig()
resetMocks()
t.Setenv("HOME", tmp)
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 1}
}
cloneNvimConfig()
}
func TestCheckAndSetupSSHEdgeCases(t *testing.T) {
defer resetMocks()
hasCmd = func(name string) bool {
return name != "gh"
}
checkAndSetupSSH()
resetMocks()
hasCmd = func(name string) bool { return true }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 0}, true
}
checkAndSetupSSH()
resetMocks()
hasCmd = func(name string) bool { return true }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 1}, true
}
stdin = strings.NewReader("n\n")
checkAndSetupSSH()
resetMocks()
hasCmd = func(name string) bool { return true }
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
return CmdResult{ExitCode: 1}, true
}
stdin = strings.NewReader("y\n")
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 1}
}
checkAndSetupSSH()
}
func TestAskYNEdgeCases(t *testing.T) {
defer resetMocks()
stdin = &errReader{}
if askYN("Prompt: ") {
t.Error("expected false for stdin error")
}
}
type errReader struct{}
func (e *errReader) Read(p []byte) (n int, err error) {
return 0, fmt.Errorf("simulated read error")
}
func TestInstallGHExtension(t *testing.T) {
defer resetMocks()
// gh not installed
hasCmd = func(name string) bool { return false }
installGHExtension("JMR-dev/gh-repo-bootstrap") // should log error and return
// gh installed, install succeeds
resetMocks()
hasCmd = func(name string) bool { return true }
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
installGHExtension("JMR-dev/gh-repo-bootstrap")
if len(runCmdCalls) != 1 ||
runCmdCalls[0][0] != "gh" ||
runCmdCalls[0][1] != "extension" ||
runCmdCalls[0][2] != "install" ||
runCmdCalls[0][3] != "JMR-dev/gh-repo-bootstrap" {
t.Errorf("expected gh extension install command, got: %v", runCmdCalls)
}
// gh installed, install fails
resetMocks()
hasCmd = func(name string) bool { return true }
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 1}
}
installGHExtension("JMR-dev/gh-repo-bootstrap") // should log error
}
func TestInstallNpmPackageEdgeCases(t *testing.T) {
defer resetMocks()
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
installNpmPackage("test-pkg")
}
func TestInstallPlaywright(t *testing.T) {
defer resetMocks()
// Edge case: NVM not installed
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
installPlaywright() // should log error and return
// Happy path on apt-get: should pass --with-deps
resetMocks()
pkgMgr = "apt-get"
osStat = func(name string) (os.FileInfo, error) {
return nil, nil // nvm exists
}
var runShellCmds []string
runShell = func(cmd string, opts CmdOpts) CmdResult {
runShellCmds = append(runShellCmds, cmd)
return CmdResult{ExitCode: 0}
}
installPlaywright()
hasPnpmInstall := false
hasPlaywrightBrowsers := false
for _, cmd := range runShellCmds {
if strings.Contains(cmd, "pnpm add -g playwright") {
hasPnpmInstall = true
}
if strings.Contains(cmd, "pnpx playwright install --with-deps") {
hasPlaywrightBrowsers = true
}
}
if !hasPnpmInstall {
t.Errorf("expected pnpm add -g playwright, got: %v", runShellCmds)
}
if !hasPlaywrightBrowsers {
t.Errorf("expected pnpx playwright install --with-deps on apt-get, got: %v", runShellCmds)
}
// Happy path on non-apt (dnf/pacman/brew): must NOT pass --with-deps
resetMocks()
pkgMgr = "dnf"
osStat = func(name string) (os.FileInfo, error) {
return nil, nil
}
var dnfShellCmds []string
runShell = func(cmd string, opts CmdOpts) CmdResult {
dnfShellCmds = append(dnfShellCmds, cmd)
return CmdResult{ExitCode: 0}
}
installPlaywright()
hasPlainBrowsers := false
for _, cmd := range dnfShellCmds {
if strings.Contains(cmd, "--with-deps") {
t.Errorf("did not expect --with-deps on dnf, got: %v", dnfShellCmds)
}
if strings.Contains(cmd, "pnpx playwright install") {
hasPlainBrowsers = true
}
}
if !hasPlainBrowsers {
t.Errorf("expected pnpx playwright install (without --with-deps) on dnf, got: %v", dnfShellCmds)
}
// Failure path: pnpm install fails, browser install should be skipped
resetMocks()
osStat = func(name string) (os.FileInfo, error) {
return nil, nil
}
var failShellCmds []string
runShell = func(cmd string, opts CmdOpts) CmdResult {
failShellCmds = append(failShellCmds, cmd)
if strings.Contains(cmd, "pnpm add -g playwright") {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
installPlaywright()
for _, cmd := range failShellCmds {
if strings.Contains(cmd, "pnpx playwright install") {
t.Errorf("browser install should be skipped when pnpm install fails, got: %v", failShellCmds)
}
}
}
func TestEnsureNodeLTSEdgeCases(t *testing.T) {
defer resetMocks()
osStat = func(name string) (os.FileInfo, error) {
return nil, nil
}
var runShellCalls []string
runShell = func(cmd string, opts CmdOpts) CmdResult {
runShellCalls = append(runShellCalls, cmd)
if strings.Contains(cmd, "nvm version lts/*") {
return CmdResult{ExitCode: 0, Stdout: []byte("N/A")}
}
if strings.Contains(cmd, "nvm install --lts") {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
ensureNodeLTS()
resetMocks()
osStat = func(name string) (os.FileInfo, error) {
return nil, nil
}
runShell = func(cmd string, opts CmdOpts) CmdResult {
if strings.Contains(cmd, "nvm version lts/*") {
return CmdResult{ExitCode: 0, Stdout: []byte("v20.0.0")}
}
if strings.Contains(cmd, "nvm alias default") {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
ensureNodeLTS()
resetMocks()
osStat = func(name string) (os.FileInfo, error) {
return nil, nil
}
runShell = func(cmd string, opts CmdOpts) CmdResult {
if strings.Contains(cmd, "nvm version lts/*") {
return CmdResult{ExitCode: 0, Stdout: []byte("v20.0.0")}
}
if strings.Contains(cmd, "corepack enable") {
return CmdResult{ExitCode: 1}
}
return CmdResult{ExitCode: 0}
}
ensureNodeLTS()
}
func TestEnsurePythonLatestBackgroundInstall(t *testing.T) {
defer resetMocks()
osStat = func(name string) (os.FileInfo, error) {
return nil, nil
}
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[1] == "install" {
return CmdResult{ExitCode: 0, Stdout: []byte(" 3.12.0\n")}, true
}
if argv[1] == "versions" {
return CmdResult{ExitCode: 0, Stdout: []byte(" 3.11.0\n")}, true
}
return CmdResult{ExitCode: 0}, true
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
wg := ensurePythonLatest()
if wg == nil {
t.Fatal("expected waitgroup since Python 3.12.0 needs install")
}
wg.Wait()
// Failure case
probe = func(argv []string, timeout time.Duration) (CmdResult, bool) {
if argv[1] == "install" {
return CmdResult{ExitCode: 0, Stdout: []byte(" 3.12.0\n")}, true
}
if argv[1] == "versions" {
return CmdResult{ExitCode: 0, Stdout: []byte(" 3.11.0\n")}, true
}
return CmdResult{ExitCode: 0}, true
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
if argv[1] == "install" {
return CmdResult{ExitCode: 1, Stderr: []byte("error lines\nline 2")}
}
return CmdResult{ExitCode: 0}
}
wg = ensurePythonLatest()
if wg != nil {
wg.Wait()
}
}
+246
View File
@@ -0,0 +1,246 @@
package main
import (
"fmt"
"strings"
)
// repoFileExists returns true if any of the given paths exists.
func repoFileExists(paths ...string) bool {
for _, p := range paths {
if _, err := osStat(p); err == nil {
return true
}
}
return false
}
func writeDNFRepo(name, displayName, baseurl, gpgkey string) {
content := fmt.Sprintf(
"[%s]\nname=%s\nbaseurl=%s\nenabled=1\ngpgcheck=1\ngpgkey=%s\n",
name, displayName, baseurl, gpgkey,
)
path := "/etc/yum.repos.d/" + name + ".repo"
runCmd([]string{"tee", path}, CmdOpts{AsSudo: true, Input: []byte(content), Capture: true})
}
func setupDockerRepo() {
switch pkgMgr {
case "dnf":
if repoFileExists("/etc/yum.repos.d/docker-ce.repo") {
return
}
runCmd([]string{"dnf", "config-manager", "addrepo", "--from-repofile",
"https://download.docker.com/linux/fedora/docker-ce.repo"}, CmdOpts{AsSudo: true})
case "apt-get":
if repoFileExists("/etc/apt/sources.list.d/docker.list") {
return
}
runCmd([]string{"apt-get", "update"}, CmdOpts{AsSudo: true})
runCmd([]string{"apt-get", "install", "-y", "ca-certificates", "curl", "gnupg"}, CmdOpts{AsSudo: true})
distroID := osReleaseField("ID")
dockerDistro := "ubuntu"
if distroID == "debian" || distroID == "ubuntu" {
dockerDistro = distroID
}
runShell(
"install -m 0755 -d /etc/apt/keyrings && "+
"curl -fsSL https://download.docker.com/linux/"+dockerDistro+"/gpg | "+
"sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg && "+
"sudo chmod a+r /etc/apt/keyrings/docker.gpg",
CmdOpts{},
)
codenameRes := runShell(". /etc/os-release && echo $VERSION_CODENAME",
CmdOpts{Capture: true})
codename := strings.TrimSpace(string(codenameRes.Stdout))
debArch := archDeb[archName]
runCmd(
[]string{"tee", "/etc/apt/sources.list.d/docker.list"},
CmdOpts{
AsSudo: true,
Input: []byte(fmt.Sprintf("deb [arch=%s signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/%s %s stable\n", debArch, dockerDistro, codename)),
Capture: true,
},
)
runCmd([]string{"apt-get", "update"}, CmdOpts{AsSudo: true})
}
// pacman: docker is in official repos — no extra repo needed.
}
func setupGHRepo() {
switch pkgMgr {
case "dnf":
if repoFileExists("/etc/yum.repos.d/gh-cli.repo") {
return
}
runCmd([]string{"dnf", "config-manager", "addrepo", "--from-repofile",
"https://cli.github.com/packages/rpm/gh-cli.repo"}, CmdOpts{AsSudo: true})
case "apt-get":
if repoFileExists("/etc/apt/sources.list.d/github-cli.list") {
return
}
debArch := archDeb[archName]
runShell(
"curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | "+
"sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && "+
"sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg && "+
fmt.Sprintf("echo 'deb [arch=%s signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main' | ", debArch)+
"sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null",
CmdOpts{},
)
runCmd([]string{"apt-get", "update"}, CmdOpts{AsSudo: true})
}
// pacman: github-cli is in community repo.
}
func setupChromeRepo() {
if archName != "x86_64" {
warn("Google Chrome has no Linux build for this arch — skipping repo")
return
}
switch pkgMgr {
case "dnf":
if repoFileExists("/etc/yum.repos.d/google-chrome.repo") {
return
}
writeDNFRepo(
"google-chrome", "Google Chrome",
"https://dl.google.com/linux/chrome/rpm/stable/x86_64",
"https://dl.google.com/linux/linux_signing_key.pub",
)
case "apt-get":
if repoFileExists("/etc/apt/sources.list.d/google-chrome.list") {
return
}
runShell(
"curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | "+
"sudo gpg --dearmor -o /etc/apt/keyrings/google-chrome.gpg && "+
"echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/google-chrome.gpg] "+
"https://dl.google.com/linux/chrome/deb/ stable main' | "+
"sudo tee /etc/apt/sources.list.d/google-chrome.list > /dev/null && "+
"sudo apt-get update",
CmdOpts{},
)
}
}
func setupVivaldiRepo() {
if archName != "x86_64" {
warn("Vivaldi repo on this arch is not supported by this script — skipping")
return
}
switch pkgMgr {
case "dnf":
if repoFileExists("/etc/yum.repos.d/vivaldi.repo") {
return
}
writeDNFRepo(
"vivaldi", "Vivaldi",
"https://repo.vivaldi.com/archive/rpm/x86_64",
"https://repo.vivaldi.com/archive/linux_signing_key.pub",
)
case "apt-get":
if repoFileExists("/etc/apt/sources.list.d/vivaldi.list") {
return
}
runShell(
"curl -fsSL https://repo.vivaldi.com/archive/linux_signing_key.pub | "+
"sudo gpg --dearmor -o /etc/apt/keyrings/vivaldi.gpg && "+
"echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/vivaldi.gpg] "+
"https://repo.vivaldi.com/archive/deb/ stable main' | "+
"sudo tee /etc/apt/sources.list.d/vivaldi.list > /dev/null && "+
"sudo apt-get update",
CmdOpts{},
)
}
}
func setupTemurinRepo() {
switch pkgMgr {
case "dnf":
if repoFileExists("/etc/yum.repos.d/adoptium.repo") {
return
}
writeDNFRepo(
"Adoptium", "Adoptium",
"https://packages.adoptium.net/artifactory/rpm/fedora/$releasever/$basearch",
"https://packages.adoptium.net/artifactory/api/gpg/key/public",
)
case "apt-get":
if repoFileExists("/etc/apt/sources.list.d/adoptium.list") {
return
}
runShell(
"wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public | "+
"sudo gpg --dearmor | sudo tee /etc/apt/keyrings/adoptium.gpg > /dev/null && "+
`echo "deb [signed-by=/etc/apt/keyrings/adoptium.gpg] `+
`https://packages.adoptium.net/artifactory/deb/ `+
`$(awk -F= '/^VERSION_CODENAME/{print$2}' /etc/os-release) main" | `+
"sudo tee /etc/apt/sources.list.d/adoptium.list > /dev/null && "+
"sudo apt-get update",
CmdOpts{},
)
}
}
func setupDotnetRepo() {
// .NET is in Fedora repos directly — no extra repo needed.
if pkgMgr != "apt-get" {
return
}
if repoFileExists(
"/etc/apt/sources.list.d/microsoft-prod.list",
"/etc/apt/sources.list.d/dotnet.list",
) {
return
}
distroID := strings.Trim(osReleaseField("ID"), `"`)
versionID := strings.Trim(osReleaseField("VERSION_ID"), `"`)
debURL := fmt.Sprintf(
"https://packages.microsoft.com/config/%s/%s/packages-microsoft-prod.deb",
distroID, versionID,
)
runShell(
fmt.Sprintf("curl -fsSL %s -o /tmp/packages-microsoft-prod.deb && "+
"sudo dpkg -i /tmp/packages-microsoft-prod.deb && "+
"sudo apt-get update", debURL),
CmdOpts{},
)
}
func setupLazygitCoprRepo() {
if pkgMgr != "dnf" {
return
}
if repoFileExists("/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:dejan:lazygit.repo") {
return
}
// dnf-plugins-core provides the `copr` subcommand on dnf4; on dnf5 it's a no-op.
runCmd([]string{"dnf", "install", "-y", "dnf-plugins-core"}, CmdOpts{AsSudo: true})
runCmd([]string{"dnf", "copr", "enable", "-y", "dejan/lazygit"}, CmdOpts{AsSudo: true})
}
type repoGroup struct {
members map[string]bool
setup func()
}
func repoGroups() []repoGroup {
mk := func(names ...string) map[string]bool {
m := make(map[string]bool, len(names))
for _, n := range names {
m[n] = true
}
return m
}
return []repoGroup{
{mk("containerd.io", "docker-buildx-plugin", "docker-ce-cli",
"docker-ce-rootless-extras", "docker-ce", "docker-compose-plugin"), setupDockerRepo},
{mk("gh"), setupGHRepo},
{mk("google-chrome-stable"), setupChromeRepo},
{mk("vivaldi-stable"), setupVivaldiRepo},
{mk("temurin-25-jdk"), setupTemurinRepo},
{mk("dotnet-sdk-10.0"), setupDotnetRepo},
{mk("lazygit"), setupLazygitCoprRepo},
}
}
+299
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))
}
}
+453
View File
@@ -0,0 +1,453 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Special packages: installed outside the regular package manager because
// they're not in standard repos, or because they need extra setup. Linux only;
// on macOS brew covers all of these.
func specialPkgs() map[string]bool {
if isMacOS {
return map[string]bool{}
}
return map[string]bool{
"github-desktop": true, "zoom": true, "obsidian": true,
"minikube": true, "bashtop": true, "pipx": true,
"poetry": true, "pulumi": true,
}
}
// guiSystemPkgs are skipped by default (headless mode) and included only
// when --gui is passed.
var guiSystemPkgs = map[string]bool{
"github-desktop": true,
"google-chrome-stable": true,
"obs-studio": true,
"obsidian": true,
"shutter": true,
"virt-manager": true,
"vivaldi-stable": true,
"webcamoid": true,
"wireshark": true,
"zoom": true,
}
func isSpecialPkgInstalled(pkg string) bool {
home, _ := os.UserHomeDir()
exists := func(p string) bool { _, err := osStat(p); return err == nil }
switch pkg {
case "obsidian":
return exists("/usr/local/bin/obsidian")
case "minikube":
return exists("/usr/local/bin/minikube") || hasCmd("minikube")
case "bashtop":
return exists("/usr/local/bin/bashtop") || exists(filepath.Join(home, "bashtop"))
case "pulumi":
return exists("/opt/pulumi/pulumi") || hasCmd("pulumi")
case "pipx":
return hasCmd("pipx")
case "poetry":
return hasCmd("poetry")
}
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 installBashtop(_ string) {
home, _ := os.UserHomeDir()
cloneDir := filepath.Join(home, "bashtop")
if _, err := osStat(cloneDir); err == nil {
fmt.Printf(" Updating existing clone at %s ...\n", cloneDir)
if !runCmd([]string{"git", "-C", cloneDir, "pull"}, CmdOpts{}).OK() {
errLog("bashtop git pull failed")
return
}
} else {
fmt.Printf(" Cloning bashtop to %s ...\n", cloneDir)
if !runCmd([]string{"git", "clone", "https://github.com/aristocratos/bashtop.git", cloneDir}, CmdOpts{}).OK() {
errLog("bashtop git clone failed")
return
}
}
if !runCmd([]string{"make", "install"}, CmdOpts{AsSudo: true, Cwd: cloneDir}).OK() {
errLog("bashtop 'make install' failed")
return
}
appendProfileLine("bashtop", "export PATH=$PATH:"+cloneDir)
fmt.Printf(" bashtop installed. Clone at %s, binary at /usr/local/bin/bashtop\n", cloneDir)
}
func installPulumi(tmp string) {
version := fetchText("https://www.pulumi.com/latest-version")
if version == "" {
errLog("Could not determine latest Pulumi version")
return
}
osTok := osGo[osName]
archTok := archPulumi[archName]
tarball := fmt.Sprintf("pulumi-v%s-%s-%s.tar.gz", version, osTok, archTok)
base := fmt.Sprintf("https://github.com/pulumi/pulumi/releases/download/v%s", version)
dest := filepath.Join(tmp, tarball)
if !download(base+"/"+tarball, dest) {
return
}
checksums := fetchText(fmt.Sprintf("%s/pulumi-%s-checksums.txt", base, version))
if checksums == "" {
errLog("Could not fetch Pulumi checksums")
return
}
var expected string
for _, line := range strings.Split(checksums, "\n") {
if strings.HasSuffix(strings.TrimSpace(line), tarball) {
expected = strings.Fields(line)[0]
break
}
}
if expected == "" {
errLog(fmt.Sprintf("No checksum entry for %s", tarball))
return
}
actual, err := sha256Of(dest)
if err != nil {
errLog(fmt.Sprintf("Pulumi hash failed: %v", err))
return
}
if actual != expected {
errLog(fmt.Sprintf("Pulumi SHA256 mismatch: expected %s, got %s", expected, actual))
return
}
fmt.Println(" SHA256 OK")
installDir := "/opt/pulumi"
fmt.Println(" Extracting Pulumi to /opt ...")
runCmd([]string{"mkdir", "-p", "/opt"}, CmdOpts{AsSudo: true})
runCmd([]string{"rm", "-rf", installDir}, CmdOpts{AsSudo: true})
runCmd([]string{"tar", "-C", "/opt", "-xzf", dest}, CmdOpts{AsSudo: true})
appendProfileLine("pulumi", fmt.Sprintf(`export PATH="$PATH:%s"`, installDir))
fmt.Printf(" Pulumi %s installed to %s\n", version, installDir)
}
func installPipx(_ string) {
if !hasCmd("python3") {
errLog("Python 3 is not installed — cannot install pipx")
return
}
pkgInstall("pipx")
if hasCmd("pipx") {
runCmd([]string{"pipx", "ensurepath"}, CmdOpts{})
} else {
errLog("pipx command not found after install")
}
}
func installPoetry(_ string) {
if !hasCmd("pipx") {
errLog("pipx is not installed — cannot install poetry")
return
}
runCmd([]string{"pipx", "install", "poetry"}, CmdOpts{})
}
func installSpecialPkg(pkg, tmp string) {
switch pkg {
case "github-desktop":
installGitHubDesktop(tmp)
case "zoom":
installZoom(tmp)
case "obsidian":
installObsidian(tmp)
case "minikube":
installMinikube(tmp)
case "bashtop":
installBashtop(tmp)
case "pulumi":
installPulumi(tmp)
case "pipx":
installPipx(tmp)
case "poetry":
installPoetry(tmp)
}
}
// pkgInstall invokes the host package manager to install a single name.
// Centralized so pacman's "--noconfirm" doesn't leak everywhere.
func pkgInstall(pkg string) CmdResult {
switch pkgMgr {
case "pacman":
return runCmd([]string{"pacman", "-S", "--noconfirm", "--needed", pkg}, CmdOpts{AsSudo: true})
case "brew":
if brewCasks[pkg] {
return runCmd([]string{"brew", "install", "--cask", pkg}, CmdOpts{})
}
return runCmd([]string{"brew", "install", pkg}, CmdOpts{})
default:
return runCmd([]string{pkgMgr, "install", "-y", pkg}, CmdOpts{AsSudo: true})
}
}
// installSystemPackages installs the regular + special package lists.
func installSystemPackages(regular, special []string) {
fmt.Println("\n=== System Packages ===")
if pkgMgr == "brew" {
for _, pkg := range regular {
res := pkgInstall(pkg)
if !res.OK() {
errLog(fmt.Sprintf("System package failed to install: %s", pkg))
}
}
// No special packages on macOS — brew covers all of them.
return
}
seenRepos := map[int]bool{}
groups := repoGroups()
for _, pkg := range regular {
for i, g := range groups {
if g.members[pkg] && !seenRepos[i] {
fmt.Printf(" [REPO] Setting up repository for %s ...\n", pkg)
g.setup()
seenRepos[i] = true
}
}
}
for _, pkg := range regular {
res := pkgInstall(pkg)
if !res.OK() {
errLog(fmt.Sprintf("System package failed to install: %s", pkg))
}
}
if len(special) > 0 {
tmp, err := os.MkdirTemp("", "bootstrap-special-")
if err != nil {
errLog(fmt.Sprintf("could not create temp dir for special packages: %v", err))
return
}
defer osRemoveAll(tmp)
for _, pkg := range special {
fmt.Printf("\n [SPECIAL] Installing %s ...\n", pkg)
installSpecialPkg(pkg, tmp)
}
}
}
// appendProfileLine adds a PATH/env line to a system-wide login-shell profile,
// idempotently. On Linux uses /etc/profile.d/<name>.sh; macOS uses /etc/zprofile.
func appendProfileLine(scriptName, line string) {
target := fmt.Sprintf("/etc/profile.d/%s.sh", scriptName)
if isMacOS {
target = "/etc/zprofile"
}
cmd := fmt.Sprintf("grep -qxF %q %s 2>/dev/null || echo %q >> %s", line, target, line, target)
runCmd([]string{"bash", "-c", cmd}, CmdOpts{AsSudo: true})
}
+531
View File
@@ -0,0 +1,531 @@
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 TestInstallBashtop(t *testing.T) {
defer resetMocks()
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist // Not cloned yet
}
var runCmdCalls [][]string
runCmd = func(argv []string, opts CmdOpts) CmdResult {
runCmdCalls = append(runCmdCalls, argv)
return CmdResult{ExitCode: 0}
}
installBashtop("/tmp")
// Git clone and make install should be run
if len(runCmdCalls) < 2 {
t.Fatalf("expected git clone and make install, got calls: %v", runCmdCalls)
}
if runCmdCalls[0][1] != "clone" {
t.Errorf("expected git clone, got %v", runCmdCalls[0])
}
if runCmdCalls[1][0] != "make" || runCmdCalls[1][1] != "install" {
t.Errorf("expected make install, got %v", runCmdCalls[1])
}
}
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("bashtop") {
t.Error("expected bashtop to be installed")
}
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()
osStat = func(name string) (os.FileInfo, error) {
return nil, nil
}
runCmd = func(argv []string, opts CmdOpts) CmdResult {
return CmdResult{ExitCode: 1}
}
installSpecialPkg("bashtop", "/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{})
}
+2
View File
@@ -0,0 +1,2 @@
ok github.com/JMR-dev/bootstrap_dev_env 20.206s
? github.com/JMR-dev/bootstrap_dev_env/ci [no test files]
+16
View File
@@ -0,0 +1,16 @@
package main
import (
"io"
"os"
"testing"
)
// TestMain silences issue-log output for the entire test binary.
// Tests still exercise error paths and assert via errorCount; we just
// don't want the human-facing "[ERROR] ..." prints polluting CI logs
// (GitHub Actions auto-annotates "[ERROR]" lines as workflow errors).
func TestMain(m *testing.M) {
issueLogWriter = io.Discard
os.Exit(m.Run())
}