added repo bootstrap tool

This commit is contained in:
2026-05-26 13:21:31 -05:00
parent ee95457e7d
commit 4d99bba06f
7 changed files with 225 additions and 12 deletions
+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
+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
+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)
}
+2
View File
@@ -140,5 +140,7 @@ func customPackages() []CustomPackage {
{Name: "claude"},
{Name: "codex"},
{Name: "copilot"},
{Name: "playwright"},
{Name: "gh-repo-bootstrap"},
}
}
+33
View File
@@ -481,3 +481,36 @@ func installNpmPackage(pkgName string) {
}
}
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 ...")
if !runShell(`bash -c "source ~/.nvm/nvm.sh && pnpm add -g playwright"`, CmdOpts{}).OK() {
errLog("playwright installation failed")
return
}
pnpmBin := filepath.Join(home, ".local/share/pnpm/bin")
if !strings.Contains(os.Getenv("PATH"), pnpmBin) {
appendProfileLine("pnpm", `export PATH="$HOME/.local/share/pnpm/bin:$PATH"`)
}
fmt.Println(" Installing Playwright browsers with dependencies ...")
if !runShell(fmt.Sprintf(`bash -c "source ~/.nvm/nvm.sh && PATH=%s:$PATH pnpx playwright install --with-deps"`, pnpmBin), 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))
}
}
+94
View File
@@ -465,6 +465,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 +593,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 +635,64 @@ 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
resetMocks()
osStat = func(name string) (os.FileInfo, error) {
return nil, nil // nvm exists
}
var runShellCmds []string
runShell = func(cmd string, opts CmdOpts) CmdResult {
runShellCmds = append(runShellCmds, cmd)
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, got: %v", runShellCmds)
}
// 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()