added repo bootstrap tool #19

Merged
JMR-dev merged 13 commits from feat-add-repo-bootstrap-tool into main 2026-05-26 20:20:28 +00:00
22 changed files with 599 additions and 48 deletions
+6 -1
View File
@@ -29,6 +29,8 @@ jobs:
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 }}
@@ -53,8 +55,11 @@ jobs:
mkdir -p ~/.pyenv
- name: Run bootstrap tool
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo y | ./bootstrap_environment --only custom --no-vm --no-ai
echo y | ./bootstrap_environment --gui
conclusion:
name: Final Status Check
+69 -6
View File
@@ -15,8 +15,11 @@ permissions:
contents: write
jobs:
release:
resolve:
runs-on: ubuntu-latest
outputs:
ref: ${{ steps.ref.outputs.ref }}
tag: ${{ steps.ref.outputs.tag }}
steps:
- name: Resolve ref
id: ref
@@ -29,10 +32,35 @@ jobs:
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: ${{ steps.ref.outputs.ref }}
ref: ${{ needs.resolve.outputs.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Go
@@ -41,12 +69,47 @@ jobs:
go-version-file: go.mod
check-latest: true
- name: Build all targets
run: make build-all
- name: Build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOARM: ${{ matrix.goarm }}
CGO_ENABLED: '0'
run: |
mkdir -p dist
out="dist/bootstrap_environment-${{ matrix.suffix }}"
echo "==> Building $out (GOOS=$GOOS GOARCH=$GOARCH GOARM=${GOARM:-n/a})"
go build -trimpath -ldflags="-s -w" -o "$out" .
- name: Upload artifact
uses: actions/upload-artifact@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
@@ -54,8 +117,8 @@ jobs:
uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ steps.ref.outputs.tag }}
name: ${{ steps.ref.outputs.tag }}
tag_name: ${{ needs.resolve.outputs.tag }}
name: ${{ needs.resolve.outputs.tag }}
target_commitish: ${{ github.sha }}
generate_release_notes: true
fail_on_unmatched_files: true
+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 ./...
+15 -5
View File
@@ -8,6 +8,7 @@ GOFLAGS := -trimpath -ldflags="-s -w"
TARGETS := \
linux/amd64 \
linux/arm64 \
linux/arm/6 \
darwin/amd64 \
darwin/arm64
@@ -18,13 +19,22 @@ all: build
build:
go build $(GOFLAGS) -o $(BINARY) $(PKG)
# Cross-compile native binaries for each supported (OS, arch) pair into dist/.
# Cross-compile native binaries for each supported (OS, arch[, GOARM]) triple
# into dist/. Targets formatted as "os/arch" produce "$(BINARY)-os-arch";
# "os/arm/N" produces "$(BINARY)-os-armvN" with GOARM=N.
build-all: $(DIST)
@for t in $(TARGETS); do \
os=$${t%/*}; arch=$${t#*/}; \
out=$(DIST)/$(BINARY)-$$os-$$arch; \
echo "==> $$os/$$arch -> $$out"; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch \
os=$$(echo $$t | cut -d/ -f1); \
arch=$$(echo $$t | cut -d/ -f2); \
goarm=$$(echo $$t | cut -s -d/ -f3); \
if [ -n "$$goarm" ]; then \
suffix=$$arch"v"$$goarm; \
else \
suffix=$$arch; \
fi; \
out=$(DIST)/$(BINARY)-$$os-$$suffix; \
echo "==> $$os/$$arch$${goarm:+ GOARM=$$goarm} -> $$out"; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch GOARM=$$goarm \
go build $(GOFLAGS) -o $$out $(PKG) || exit 1; \
done
Executable
BIN
View File
Binary file not shown.
+10 -2
View File
@@ -88,10 +88,18 @@ func main() {
WithFile("/usr/local/bin/bootstrap_environment", binaryFile).
WithWorkdir("/tmp")
// 4. Run bootstrap binary with safe args: --only custom --no-vm --no-ai
// 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 --only custom --no-vm --no-ai"})
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)
+11 -1
View File
@@ -55,7 +55,8 @@ var defaultInstallPaths = map[string]string{
"pyenv": "~/.pyenv",
"neovim": "/usr/local/bin/nvim",
"oh-my-zsh": "~/.oh-my-zsh",
"agy": "~/.local/bin/agy",
"agy": "~/.local/bin/agy",
"gh-repo-bootstrap": "~/.local/share/gh/extensions/gh-repo-bootstrap",
}
func expandHome(p string) string {
@@ -122,6 +123,9 @@ func isCustomPkgInstalled(pkg *CustomPackage) (bool, string) {
if name == "copilot" {
return npmInstalled("copilot")
}
if name == "playwright" {
return npmInstalled("playwright")
}
raw := pkg.InstallPath
if raw == "" {
raw = defaultInstallPath(pkg)
@@ -519,6 +523,12 @@ func installCustomPackages(toInstall []*CustomPackage) {
case "copilot":
installNpmPackage("@github/copilot")
continue
case "playwright":
installPlaywright()
continue
case "gh-repo-bootstrap":
installGHExtension("JMR-dev/gh-repo-bootstrap")
continue
}
resolveLatest(pkg)
+1
View File
@@ -398,3 +398,4 @@ func TestResolveLatestEdgeCases(t *testing.T) {
resolveLatest(pkgSame)
}
+1
View File
@@ -38,5 +38,6 @@ func resetMocks() {
issuesMu.Lock()
issues = nil
notices = nil
errorCount = 0
issuesMu.Unlock()
}
+18 -4
View File
@@ -2,6 +2,7 @@ package main
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
@@ -13,21 +14,34 @@ import (
// when there's something to report.
var (
issuesMu sync.Mutex
issues []string
notices []string
issuesMu sync.Mutex
issues []string
notices []string
errorCount int
// issueLogWriter is the destination for human-facing issue log lines.
// Overridden during tests to suppress intentional error-path output.
issueLogWriter io.Writer = os.Stdout
)
func logIssue(level, msg string) {
issuesMu.Lock()
defer issuesMu.Unlock()
fmt.Printf(" [%s] %s\n", level, msg)
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()
+32 -11
View File
@@ -12,32 +12,29 @@ func TestIssuesLogging(t *testing.T) {
defer resetMocks()
resetMocks()
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
// 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")
w.Close()
os.Stdout = oldStdout
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
if !strings.Contains(output, "[WARN] something is deprecated") {
t.Errorf("stdout missing warning: %q", output)
t.Errorf("output missing warning: %q", output)
}
if !strings.Contains(output, "[ERROR] something failed") {
t.Errorf("stdout missing error: %q", output)
t.Errorf("output missing error: %q", output)
}
issuesMu.Lock()
issueLen := len(issues)
noticeLen := len(notices)
errCount := errorCount
issuesMu.Unlock()
if issueLen != 2 {
@@ -46,6 +43,12 @@ func TestIssuesLogging(t *testing.T) {
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) {
@@ -83,6 +86,24 @@ func TestWriteRunLog(t *testing.T) {
}
}
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()
+5
View File
@@ -169,6 +169,11 @@ func runMain(args []string) {
printNotices()
fmt.Println("\nDone.")
if hasErrors() {
osExit(1)
return
}
home, _ := os.UserHomeDir()
zshrc := filepath.Join(home, ".zshrc")
if hasCmd("zsh") {
+2
View File
@@ -140,5 +140,7 @@ func customPackages() []CustomPackage {
{Name: "claude"},
{Name: "codex"},
{Name: "copilot"},
{Name: "playwright"},
{Name: "gh-repo-bootstrap"},
}
}
+5 -2
View File
@@ -105,7 +105,8 @@ var packageOverrides = map[string]map[string]overrideEntry{
"containerd.io": replace("containerd"),
"docker-ce": replace("docker"),
"docker-ce-cli": skipOverride(), // covered by docker
"docker-ce-rootless-extras": replace("docker-rootless-extras"),
"docker-ce-rootless-extras": skipOverride(), // AUR-only
"pipx": replace("python-pipx"),
"docker-buildx-plugin": replace("docker-buildx"),
"docker-compose-plugin": replace("docker-compose"),
"dotnet-sdk-10.0": replace("dotnet-sdk"),
@@ -121,7 +122,7 @@ var packageOverrides = map[string]map[string]overrideEntry{
"rg": replace("ripgrep"),
"shutter": skipOverride(), // AUR-only
"temurin-25-jdk": replace("jdk-openjdk"),
"vagrant": replace("vagrant"),
"vagrant": skipOverride(), // AUR-only
"vivaldi-stable": replace("vivaldi"),
"webcamoid": skipOverride(), // AUR-only; provided via Flatpak when --gui
"wireshark": replace("wireshark-qt"),
@@ -164,6 +165,8 @@ var packageOverrides = map[string]map[string]overrideEntry{
"rg": replace("ripgrep"),
"temurin-25-jdk": replace("temurin"),
"vivaldi-stable": replace("vivaldi"),
"bashtop": replace("btop"),
"buildah": skipOverride(),
"shutter": skipOverride(),
"virt-manager": skipOverride(),
"webcamoid": skipOverride(),
+17
View File
@@ -84,6 +84,23 @@ func TestResolveSystemPkgs(t *testing.T) {
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) {
+62 -8
View File
@@ -245,8 +245,13 @@ func ensureNodeLTS() {
}
fmt.Println("[NVM] Enabling corepack and installing latest pnpm ...")
if !runShell(`bash -c 'source ~/.nvm/nvm.sh && corepack enable && corepack prepare pnpm@latest --activate && pnpm config set global-bin-dir "$HOME/.local/share/pnpm/bin"'`, CmdOpts{}).OK() {
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")
}
}
@@ -384,9 +389,10 @@ func userLoginShell(uid string) string {
func cloneNvimConfig() {
home, _ := os.UserHomeDir()
configDir := filepath.Join(home, ".config", "nvim")
repoURL := "git@github.com:JMR-dev/nvim-config.git"
const sshURL = "git@github.com:JMR-dev/nvim-config.git"
const httpsURL = "https://github.com/JMR-dev/nvim-config.git"
fmt.Printf("\n[Neovim] Setting up configuration from %s ...\n", repoURL)
fmt.Printf("\n[Neovim] Setting up configuration from %s ...\n", sshURL)
if _, err := osStat(configDir); err == nil {
n := 1
@@ -408,14 +414,18 @@ func cloneNvimConfig() {
osMkdirAll(filepath.Dir(configDir), 0o755)
repoName := strings.TrimSuffix(filepath.Base(repoURL), ".git")
repoName := strings.TrimSuffix(filepath.Base(sshURL), ".git")
tempClone := filepath.Join(filepath.Dir(configDir), repoName)
osRemoveAll(tempClone)
fmt.Printf(" Cloning to %s ...\n", configDir)
if !runCmd([]string{"git", "clone", repoURL, tempClone}, CmdOpts{}).OK() {
errLog("Neovim configuration clone failed")
return
if !runCmd([]string{"git", "clone", sshURL, tempClone}, CmdOpts{}).OK() {
fmt.Printf(" SSH clone failed; falling back to HTTPS (%s) ...\n", httpsURL)
osRemoveAll(tempClone)
if !runCmd([]string{"git", "clone", httpsURL, tempClone}, CmdOpts{}).OK() {
errLog("Neovim configuration clone failed")
return
}
}
if tempClone != configDir {
fmt.Printf(" Renaming %s to %s ...\n", filepath.Base(tempClone), filepath.Base(configDir))
@@ -468,6 +478,13 @@ func installAgy() {
}
}
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 {
@@ -476,8 +493,45 @@ func installNpmPackage(pkgName string) {
}
ensureNodeLTS()
fmt.Printf(" Installing %s via pnpm ...\n", pkgName)
if !runShell(fmt.Sprintf(`bash -c "source ~/.nvm/nvm.sh && pnpm add -g %s"`, pkgName), CmdOpts{}).OK() {
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))
}
}
+203
View File
@@ -231,6 +231,63 @@ func TestCloneNvimConfig(t *testing.T) {
}
}
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()
@@ -304,6 +361,25 @@ func TestInstallPipAgyNpm(t *testing.T) {
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) {
@@ -350,14 +426,21 @@ func TestEnsurePythonAndNode(t *testing.T) {
}
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) {
@@ -465,6 +548,9 @@ func TestEnsureZshDefaultEdgeCases(t *testing.T) {
}
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()
@@ -590,6 +676,39 @@ 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()
@@ -599,6 +718,90 @@ func TestInstallNpmPackageEdgeCases(t *testing.T) {
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()
+13
View File
@@ -208,6 +208,18 @@ func setupDotnetRepo() {
)
}
func setupLazygitCoprRepo() {
if pkgMgr != "dnf" {
return
}
if repoFileExists("/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:dejan:lazygit.repo") {
return
}
// dnf-plugins-core provides the `copr` subcommand on dnf4; on dnf5 it's a no-op.
runCmd([]string{"dnf", "install", "-y", "dnf-plugins-core"}, CmdOpts{AsSudo: true})
runCmd([]string{"dnf", "copr", "enable", "-y", "dejan/lazygit"}, CmdOpts{AsSudo: true})
}
type repoGroup struct {
members map[string]bool
setup func()
@@ -229,5 +241,6 @@ func repoGroups() []repoGroup {
{mk("vivaldi-stable"), setupVivaldiRepo},
{mk("temurin-25-jdk"), setupTemurinRepo},
{mk("dotnet-sdk-10.0"), setupDotnetRepo},
{mk("lazygit"), setupLazygitCoprRepo},
}
}
+47 -2
View File
@@ -193,10 +193,55 @@ func TestSetupDotnetRepo(t *testing.T) {
}
}
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) != 6 {
t.Errorf("expected 6 repo groups, got %d", len(groups))
if len(groups) != 7 {
t.Errorf("expected 7 repo groups, got %d", len(groups))
}
}
+15 -6
View File
@@ -170,18 +170,27 @@ func installObsidian(tmp string) {
hostTokens := archTokens[archName]
otherTokens := archTokens[otherArch()]
hasAnyToken := func(n string, tokens []string) bool {
for _, t := range tokens {
if strings.Contains(n, t) {
return true
}
}
return false
}
matches := func(name string) bool {
n := strings.ToLower(name)
if !strings.HasSuffix(n, ".appimage") {
return false
}
matched := false
for _, t := range hostTokens {
if strings.Contains(n, t) {
matched = true
break
}
// Obsidian publishes the x86_64 AppImage without an arch suffix
// (e.g. "Obsidian-1.12.7.AppImage") and the arm64 build as
// "Obsidian-1.12.7-arm64.AppImage". Treat a token-less AppImage as x86_64.
if !hasAnyToken(n, hostTokens) && !hasAnyToken(n, otherTokens) {
return archName == "x86_64"
}
matched := hasAnyToken(n, hostTokens)
if !matched {
return false
}
+24
View File
@@ -191,6 +191,30 @@ func TestInstallObsidian(t *testing.T) {
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) {
+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())
}