diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index b990056..8c7b6a9 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6830226..eab70c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..4f673ec --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -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 ./... diff --git a/Makefile b/Makefile index 93d0fbc..10adc98 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/ci/ci b/ci/ci new file mode 100755 index 0000000..b3279b0 Binary files /dev/null and b/ci/ci differ diff --git a/ci/main.go b/ci/main.go index 2e5308e..b57500f 100644 --- a/ci/main.go +++ b/ci/main.go @@ -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) diff --git a/custom.go b/custom.go index bb9ca74..6e087c3 100644 --- a/custom.go +++ b/custom.go @@ -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) diff --git a/custom_test.go b/custom_test.go index 4be233c..362f4ba 100644 --- a/custom_test.go +++ b/custom_test.go @@ -398,3 +398,4 @@ func TestResolveLatestEdgeCases(t *testing.T) { resolveLatest(pkgSame) } + diff --git a/helper_test.go b/helper_test.go index 283306a..db6bdb9 100644 --- a/helper_test.go +++ b/helper_test.go @@ -38,5 +38,6 @@ func resetMocks() { issuesMu.Lock() issues = nil notices = nil + errorCount = 0 issuesMu.Unlock() } diff --git a/issues.go b/issues.go index f1a0076..7c7b301 100644 --- a/issues.go +++ b/issues.go @@ -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() diff --git a/issues_test.go b/issues_test.go index 0ed99db..7c2d09f 100644 --- a/issues_test.go +++ b/issues_test.go @@ -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() diff --git a/main.go b/main.go index c5e19af..4b27ba0 100644 --- a/main.go +++ b/main.go @@ -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") { diff --git a/packages.go b/packages.go index 21f3bf7..ac1e3e0 100644 --- a/packages.go +++ b/packages.go @@ -140,5 +140,7 @@ func customPackages() []CustomPackage { {Name: "claude"}, {Name: "codex"}, {Name: "copilot"}, + {Name: "playwright"}, + {Name: "gh-repo-bootstrap"}, } } diff --git a/pkgmgr.go b/pkgmgr.go index 7efae1e..3b840a3 100644 --- a/pkgmgr.go +++ b/pkgmgr.go @@ -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(), diff --git a/pkgmgr_test.go b/pkgmgr_test.go index 1e74e5a..aaf8d58 100644 --- a/pkgmgr_test.go +++ b/pkgmgr_test.go @@ -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) { diff --git a/post.go b/post.go index 32a5277..6f6857d 100644 --- a/post.go +++ b/post.go @@ -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)) + } +} + diff --git a/post_test.go b/post_test.go index 429ece1..e5279dc 100644 --- a/post_test.go +++ b/post_test.go @@ -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() diff --git a/repos.go b/repos.go index 1f8265a..ad3fb9d 100644 --- a/repos.go +++ b/repos.go @@ -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}, } } diff --git a/repos_test.go b/repos_test.go index 012abfa..d3f0eff 100644 --- a/repos_test.go +++ b/repos_test.go @@ -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)) } } diff --git a/system.go b/system.go index 02dc52b..57443ee 100644 --- a/system.go +++ b/system.go @@ -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 } diff --git a/system_test.go b/system_test.go index bf9a502..7bc76c1 100644 --- a/system_test.go +++ b/system_test.go @@ -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) { diff --git a/testmain_test.go b/testmain_test.go new file mode 100644 index 0000000..4145616 --- /dev/null +++ b/testmain_test.go @@ -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()) +}