added repo bootstrap tool #19
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ./...
|
||||
@@ -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
|
||||
|
||||
|
||||
+10
-2
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -398,3 +398,4 @@ func TestResolveLatestEdgeCases(t *testing.T) {
|
||||
resolveLatest(pkgSame)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -38,5 +38,6 @@ func resetMocks() {
|
||||
issuesMu.Lock()
|
||||
issues = nil
|
||||
notices = nil
|
||||
errorCount = 0
|
||||
issuesMu.Unlock()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -140,5 +140,7 @@ func customPackages() []CustomPackage {
|
||||
{Name: "claude"},
|
||||
{Name: "codex"},
|
||||
{Name: "copilot"},
|
||||
{Name: "playwright"},
|
||||
{Name: "gh-repo-bootstrap"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMain silences issue-log output for the entire test binary.
|
||||
// Tests still exercise error paths and assert via errorCount; we just
|
||||
// don't want the human-facing "[ERROR] ..." prints polluting CI logs
|
||||
// (GitHub Actions auto-annotates "[ERROR]" lines as workflow errors).
|
||||
func TestMain(m *testing.M) {
|
||||
issueLogWriter = io.Discard
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
Reference in New Issue
Block a user