diff --git a/README.md b/README.md index 0ac63b15..aeaff677 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,15 @@ brew tap buildkite/buildkite && brew install buildkite/buildkite/bk Or download a binary from the [releases page](https://github.com/buildkite/cli/releases). +To update a standalone release-binary install later, run: + +```sh +bk update +``` + +If `bk` is managed by Homebrew or mise, `bk update` will tell you how to update +it with that tool instead. + ### Authenticate ```sh diff --git a/cmd/update/update.go b/cmd/update/update.go new file mode 100644 index 00000000..86cb0b4d --- /dev/null +++ b/cmd/update/update.go @@ -0,0 +1,154 @@ +package update + +import ( + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + versionPkg "github.com/buildkite/cli/v3/cmd/version" + "github.com/buildkite/cli/v3/internal/selfupdate" +) + +// UpdateCmd updates the installed bk CLI in place, or prints the right +// instruction when bk is managed by Homebrew or mise. +// +// The unexported fields are dependency-injection seams for tests. Kong +// constructs the command with all fields zero-valued; Run() fills in the +// real implementations. +type UpdateCmd struct { + stdout io.Writer + stderr io.Writer + version string + targetOS string + targetArch string + currentInstallation func() (selfupdate.Installation, error) + latestReleaseVersion func() (string, error) + buildDownloadURL func(version, targetOS, targetArch string) string + buildChecksumURL func(version string) string +} + +func (c *UpdateCmd) Help() string { + return `Update the installed bk CLI. + +If bk is managed by Homebrew or mise, this command prints the right update +instruction for that tool. + +If bk was installed as a standalone release binary, this command downloads the +latest release for the current platform, verifies its checksum, and replaces +that binary in place. +` +} + +func (c *UpdateCmd) Run() error { + c.applyDefaults() + + installation, err := c.currentInstallation() + if err != nil { + return fmt.Errorf("determining current installation: %w", err) + } + + current := strings.TrimPrefix(c.version, "v") + + switch installation.Method { + case selfupdate.InstallMethodHomebrew, selfupdate.InstallMethodMise: + return c.printManagedInstallMessage(installation, current) + } + + if !versionPkg.IsReleaseVersion(current) { + return fmt.Errorf("self-update is only supported for released builds (current version: %s)", c.version) + } + + latest, err := c.latestReleaseVersion() + if err != nil { + return fmt.Errorf("checking latest release: %w", err) + } + if !versionPkg.HasUpdate(current, latest) { + fmt.Fprintf(c.stdout, "bk is already up to date (%s)\n", current) + return nil + } + + if c.targetOS == "windows" { + return fmt.Errorf("self-update is not supported on Windows yet; please download a new release manually") + } + + fmt.Fprintf(c.stdout, "Downloading bk %s for %s/%s...\n", latest, c.targetOS, c.targetArch) + downloadURL := c.buildDownloadURL(latest, c.targetOS, c.targetArch) + archivePath, err := selfupdate.DownloadToTemp(downloadURL) + if err != nil { + return fmt.Errorf("downloading bk: %w", err) + } + defer os.Remove(archivePath) + + fmt.Fprintln(c.stdout, "Verifying checksum...") + expectedHash, err := selfupdate.FetchExpectedSHA256(c.buildChecksumURL(latest), filepath.Base(downloadURL)) + if err != nil { + return fmt.Errorf("fetching checksum: %w", err) + } + if err := selfupdate.VerifySHA256(archivePath, expectedHash); err != nil { + return fmt.Errorf("checksum verification failed: %w", err) + } + + if err := selfupdate.ReplaceBinary(archivePath, installation.TargetPath(), c.targetOS); err != nil { + return fmt.Errorf("installing updated bk: %w", err) + } + + fmt.Fprintf(c.stdout, "Updated bk to version %s\n", latest) + return nil +} + +func (c *UpdateCmd) printManagedInstallMessage(installation selfupdate.Installation, current string) error { + latest, err := c.latestReleaseVersion() + switch { + case err != nil: + fmt.Fprintf(c.stderr, "Warning: could not check for the latest release: %v\n", err) + case versionPkg.IsReleaseVersion(current) && !versionPkg.HasUpdate(current, latest): + fmt.Fprintf(c.stdout, "bk is already up to date (%s)\n", current) + return nil + default: + fmt.Fprintf(c.stdout, "A new version of bk is available: %s\n", latest) + } + + switch installation.Method { + case selfupdate.InstallMethodHomebrew: + fmt.Fprintln(c.stdout, "This installation is managed by Homebrew.") + fmt.Fprintf(c.stdout, "Update it with: %s\n", selfupdate.UpdateInstruction(installation)) + case selfupdate.InstallMethodMise: + fmt.Fprintln(c.stdout, "This installation is managed by mise.") + fmt.Fprintln(c.stdout, "Update it with mise.") + } + + return nil +} + +func (c *UpdateCmd) applyDefaults() { + if c.stdout == nil { + c.stdout = os.Stdout + } + if c.stderr == nil { + c.stderr = os.Stderr + } + if c.version == "" { + c.version = versionPkg.Version + } + if c.targetOS == "" { + c.targetOS = runtime.GOOS + } + if c.targetArch == "" { + c.targetArch = runtime.GOARCH + } + if c.currentInstallation == nil { + c.currentInstallation = selfupdate.CurrentInstallation + } + if c.latestReleaseVersion == nil { + c.latestReleaseVersion = versionPkg.LatestReleaseVersion + } + if c.buildDownloadURL == nil { + c.buildDownloadURL = selfupdate.BuildDownloadURL + } + if c.buildChecksumURL == nil { + c.buildChecksumURL = selfupdate.BuildChecksumURL + } +} diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go new file mode 100644 index 00000000..7853f901 --- /dev/null +++ b/cmd/update/update_test.go @@ -0,0 +1,187 @@ +package update + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/buildkite/cli/v3/internal/selfupdate" +) + +func TestUpdateCmd_RunStandaloneSelfUpdates(t *testing.T) { + t.Parallel() + + target := filepath.Join(t.TempDir(), "bk") + if err := os.WriteFile(target, []byte("old binary"), 0o755); err != nil { + t.Fatal(err) + } + + archiveData := makeTarGz(t, map[string]string{ + "bk_3.2.0_linux_amd64/README.md": "ignore", + "bk_3.2.0_linux_amd64/bk": "new binary", + }) + hash := sha256.Sum256(archiveData) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/bk_3.2.0_linux_amd64.tar.gz": + _, _ = w.Write(archiveData) + case "/bk_3.2.0_checksums.txt": + fmt.Fprintf(w, "%s bk_3.2.0_linux_amd64.tar.gz\n", hex.EncodeToString(hash[:])) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + var output bytes.Buffer + cmd := &UpdateCmd{ + stdout: &output, + version: "3.1.0", + targetOS: "linux", + targetArch: "amd64", + currentInstallation: func() (selfupdate.Installation, error) { + return selfupdate.Installation{Path: target, ResolvedPath: target, Method: selfupdate.InstallMethodStandalone}, nil + }, + latestReleaseVersion: func() (string, error) { return "3.2.0", nil }, + buildDownloadURL: func(string, string, string) string { return server.URL + "/bk_3.2.0_linux_amd64.tar.gz" }, + buildChecksumURL: func(string) string { return server.URL + "/bk_3.2.0_checksums.txt" }, + } + + if err := cmd.Run(); err != nil { + t.Fatalf("Run() error = %v", err) + } + + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(got) != "new binary" { + t.Fatalf("updated binary = %q, want %q", got, "new binary") + } + if !strings.Contains(output.String(), "Updated bk to version 3.2.0") { + t.Fatalf("expected success output, got %q", output.String()) + } +} + +func TestUpdateCmd_RunHomebrewPrintsInstructions(t *testing.T) { + t.Parallel() + + var output bytes.Buffer + cmd := &UpdateCmd{ + stdout: &output, + version: "3.44.0", + currentInstallation: func() (selfupdate.Installation, error) { + return selfupdate.Installation{ + Path: "/opt/homebrew/bin/bk", + ResolvedPath: "/opt/homebrew/Cellar/bk@3/3.44.0/bin/bk", + Method: selfupdate.InstallMethodHomebrew, + BrewFormula: "bk@3", + }, nil + }, + latestReleaseVersion: func() (string, error) { return "3.45.0", nil }, + } + + if err := cmd.Run(); err != nil { + t.Fatalf("Run() error = %v", err) + } + + got := output.String() + if !strings.Contains(got, "managed by Homebrew") { + t.Fatalf("expected Homebrew output, got %q", got) + } + if !strings.Contains(got, "brew upgrade bk@3") { + t.Fatalf("expected brew instruction, got %q", got) + } +} + +func TestUpdateCmd_RunHomebrewWarnsOnLatestReleaseError(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + cmd := &UpdateCmd{ + stdout: &stdout, + stderr: &stderr, + version: "3.44.0", + currentInstallation: func() (selfupdate.Installation, error) { + return selfupdate.Installation{ + Path: "/opt/homebrew/bin/bk", + ResolvedPath: "/opt/homebrew/Cellar/bk@3/3.44.0/bin/bk", + Method: selfupdate.InstallMethodHomebrew, + BrewFormula: "bk@3", + }, nil + }, + latestReleaseVersion: func() (string, error) { return "", fmt.Errorf("network down") }, + } + + if err := cmd.Run(); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if !strings.Contains(stderr.String(), "could not check for the latest release") { + t.Fatalf("expected warning on stderr, got %q", stderr.String()) + } + if !strings.Contains(stdout.String(), "brew upgrade bk@3") { + t.Fatalf("expected brew instruction on stdout, got %q", stdout.String()) + } +} + +func TestUpdateCmd_RunStandaloneDevBuildRefusesSelfUpdate(t *testing.T) { + t.Parallel() + + var output bytes.Buffer + cmd := &UpdateCmd{ + stdout: &output, + version: "DEV", + currentInstallation: func() (selfupdate.Installation, error) { + return selfupdate.Installation{Path: "/tmp/bk", ResolvedPath: "/tmp/bk", Method: selfupdate.InstallMethodStandalone}, nil + }, + latestReleaseVersion: func() (string, error) { + t.Fatal("latestReleaseVersion should not be called for dev builds") + return "", nil + }, + } + + err := cmd.Run() + if err == nil { + t.Fatal("Run() succeeded, want error") + } + if !strings.Contains(err.Error(), "released builds") { + t.Fatalf("expected released-builds error, got %v", err) + } +} + +func makeTarGz(t *testing.T, files map[string]string) []byte { + t.Helper() + + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + for name, content := range files { + if err := tw.WriteHeader(&tar.Header{Name: name, Size: int64(len(content)), Mode: 0o755}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gw.Close(); err != nil { + t.Fatal(err) + } + + return buf.Bytes() +} diff --git a/cmd/version/update_check.go b/cmd/version/update_check.go index 8c14217f..638ff854 100644 --- a/cmd/version/update_check.go +++ b/cmd/version/update_check.go @@ -7,6 +7,8 @@ import ( "strconv" "strings" "time" + + "github.com/buildkite/cli/v3/internal/selfupdate" ) var releaseURL = "https://api.github.com/repos/buildkite/cli/releases/latest" @@ -15,40 +17,58 @@ type githubRelease struct { TagName string `json:"tag_name"` } -// CheckForUpdate checks GitHub for the latest release and returns the latest -// version string and whether it is newer than currentVersion. -// Returns ("", false) silently on any error or if no update is available. -func CheckForUpdate(currentVersion string) (string, bool) { - current := strings.TrimPrefix(currentVersion, "v") - if parseVersion(current) == nil { - return "", false - } - +// LatestReleaseVersion returns the latest released bk version from GitHub. +func LatestReleaseVersion() (string, error) { client := &http.Client{Timeout: 3 * time.Second} resp, err := client.Get(releaseURL) if err != nil { - return "", false + return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", false + return "", fmt.Errorf("GitHub API returned status %d", resp.StatusCode) } var release githubRelease if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", err + } + + return strings.TrimPrefix(release.TagName, "v"), nil +} + +// CheckForUpdate checks GitHub for the latest release and returns the latest +// version string and whether it is newer than currentVersion. +// Returns ("", false) silently on any error or if no update is available. +func CheckForUpdate(currentVersion string) (string, bool) { + current := strings.TrimPrefix(currentVersion, "v") + if !IsReleaseVersion(current) { return "", false } - latest := strings.TrimPrefix(release.TagName, "v") + latest, err := LatestReleaseVersion() + if err != nil { + return "", false + } - if isNewer(latest, current) { + if HasUpdate(current, latest) { return latest, true } return "", false } +// HasUpdate returns true if latestVersion is strictly newer than currentVersion. +func HasUpdate(currentVersion, latestVersion string) bool { + return isNewer(strings.TrimPrefix(latestVersion, "v"), strings.TrimPrefix(currentVersion, "v")) +} + +// IsReleaseVersion reports whether v is a plain major.minor.patch release. +func IsReleaseVersion(v string) bool { + return parseVersion(strings.TrimPrefix(v, "v")) != nil +} + // isNewer returns true if version a is strictly newer than version b. // Both versions are expected to be in "major.minor.patch" format. func isNewer(a, b string) bool { @@ -89,6 +109,15 @@ func parseVersion(v string) []int { } // FormatUpdateNudge returns the nudge message for display. -func FormatUpdateNudge(latestVersion string) string { - return fmt.Sprintf("A new version of bk is available: %s\n", latestVersion) +func FormatUpdateNudge(latestVersion string, installation selfupdate.Installation) string { + message := fmt.Sprintf("A new version of bk is available: %s\n", latestVersion) + + switch installation.Method { + case selfupdate.InstallMethodHomebrew: + return message + fmt.Sprintf("Update it with Homebrew: %s\n", selfupdate.UpdateInstruction(installation)) + case selfupdate.InstallMethodMise: + return message + "Update it with mise.\n" + default: + return message + "Run 'bk update' to update.\n" + } } diff --git a/cmd/version/update_check_test.go b/cmd/version/update_check_test.go index 4bcec457..83735037 100644 --- a/cmd/version/update_check_test.go +++ b/cmd/version/update_check_test.go @@ -4,7 +4,10 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" + + "github.com/buildkite/cli/v3/internal/selfupdate" ) func TestCheckForUpdate_NewerVersionAvailable(t *testing.T) { @@ -140,6 +143,29 @@ func TestIsNewer(t *testing.T) { } } +func TestFormatUpdateNudge(t *testing.T) { + t.Run("standalone points to bk update", func(t *testing.T) { + got := FormatUpdateNudge("3.45.0", selfupdate.Installation{Method: selfupdate.InstallMethodStandalone}) + if !strings.Contains(got, "Run 'bk update' to update.") { + t.Fatalf("expected standalone nudge, got %q", got) + } + }) + + t.Run("homebrew points to brew upgrade", func(t *testing.T) { + got := FormatUpdateNudge("3.45.0", selfupdate.Installation{Method: selfupdate.InstallMethodHomebrew, BrewFormula: "bk@3"}) + if !strings.Contains(got, "brew upgrade bk@3") { + t.Fatalf("expected Homebrew nudge, got %q", got) + } + }) + + t.Run("mise points to mise", func(t *testing.T) { + got := FormatUpdateNudge("3.45.0", selfupdate.Installation{Method: selfupdate.InstallMethodMise}) + if !strings.Contains(got, "Update it with mise.") { + t.Fatalf("expected mise nudge, got %q", got) + } + }) +} + func TestParseVersion(t *testing.T) { tests := []struct { input string diff --git a/cmd/version/version.go b/cmd/version/version.go index c18b290d..26234f6d 100644 --- a/cmd/version/version.go +++ b/cmd/version/version.go @@ -4,6 +4,8 @@ import ( "fmt" "os" "strings" + + "github.com/buildkite/cli/v3/internal/selfupdate" ) var Version = "DEV" @@ -14,7 +16,11 @@ func (c *VersionCmd) Run() error { fmt.Fprintf(os.Stdout, "%s\n", Format(Version)) if latest, ok := CheckForUpdate(Version); ok { - fmt.Fprint(os.Stderr, FormatUpdateNudge(latest)) + installation, err := selfupdate.CurrentInstallation() + if err != nil { + installation = selfupdate.Installation{Method: selfupdate.InstallMethodStandalone} + } + fmt.Fprint(os.Stderr, FormatUpdateNudge(latest, installation)) } return nil diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go new file mode 100644 index 00000000..78879b8f --- /dev/null +++ b/internal/selfupdate/selfupdate.go @@ -0,0 +1,365 @@ +package selfupdate + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// maxDownloadSize caps the size of a release archive we will fetch. The +// largest bk archive at time of writing is well under 50 MiB; 200 MiB +// gives plenty of headroom while preventing a runaway download. Declared +// as a var so tests can shrink it. +var maxDownloadSize int64 = 200 << 20 + +// httpClient is shared across release-fetching calls. ResponseHeaderTimeout +// guards against stalled servers without aborting long-but-progressing +// downloads. +var httpClient = &http.Client{ + Transport: &http.Transport{ + ResponseHeaderTimeout: 30 * time.Second, + }, +} + +type InstallMethod string + +const ( + InstallMethodStandalone InstallMethod = "standalone" + InstallMethodHomebrew InstallMethod = "homebrew" + InstallMethodMise InstallMethod = "mise" +) + +type Installation struct { + Path string + ResolvedPath string + Method InstallMethod + BrewFormula string +} + +var ( + executablePath = os.Executable + evalSymlinks = filepath.EvalSymlinks + releaseDownloadBaseURL = "https://github.com/buildkite/cli/releases/download" +) + +func CurrentInstallation() (Installation, error) { + path, err := executablePath() + if err != nil { + return Installation{}, err + } + + resolved := path + if realPath, err := evalSymlinks(path); err == nil { + resolved = realPath + } + + return DetectInstallation(path, resolved), nil +} + +func DetectInstallation(path, resolvedPath string) Installation { + installation := Installation{ + Path: path, + ResolvedPath: resolvedPath, + Method: InstallMethodStandalone, + } + + for _, candidate := range []string{resolvedPath, path} { + if candidate == "" { + continue + } + normalized := strings.ToLower(filepath.ToSlash(candidate)) + + if isHomebrewPath(normalized) { + installation.Method = InstallMethodHomebrew + installation.BrewFormula = brewFormula(candidate) + return installation + } + if isMisePath(normalized) { + installation.Method = InstallMethodMise + return installation + } + } + + return installation +} + +func (i Installation) TargetPath() string { + if i.ResolvedPath != "" { + return i.ResolvedPath + } + return i.Path +} + +func UpdateInstruction(installation Installation) string { + switch installation.Method { + case InstallMethodHomebrew: + formula := installation.BrewFormula + if formula == "" { + formula = "bk" + } + return fmt.Sprintf("brew upgrade %s", formula) + case InstallMethodMise: + return "update it with mise" + default: + return "bk update" + } +} + +func BuildDownloadURL(version, targetOS, targetArch string) string { + return fmt.Sprintf("%s/v%s/%s", releaseDownloadBaseURL, version, ArchiveName(version, targetOS, targetArch)) +} + +func BuildChecksumURL(version string) string { + return fmt.Sprintf("%s/v%s/bk_%s_checksums.txt", releaseDownloadBaseURL, version, version) +} + +func ArchiveName(version, targetOS, targetArch string) string { + osName := targetOS + extension := "tar.gz" + + switch targetOS { + case "darwin": + osName = "macOS" + extension = "zip" + case "windows": + osName = "windows" + extension = "zip" + } + + return fmt.Sprintf("bk_%s_%s_%s.%s", version, osName, targetArch, extension) +} + +func BinaryName(targetOS string) string { + if targetOS == "windows" { + return "bk.exe" + } + return "bk" +} + +func FetchExpectedSHA256(sumsURL, archiveFilename string) (string, error) { + resp, err := httpClient.Get(sumsURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("fetching checksums failed with status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") { + parts := strings.SplitN(line, " ", 2) + if len(parts) == 2 && parts[1] == archiveFilename { + return parts[0], nil + } + } + + return "", fmt.Errorf("no SHA256 checksum found for %s", archiveFilename) +} + +func VerifySHA256(path, expected string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return err + } + + actual := hex.EncodeToString(h.Sum(nil)) + if actual != expected { + return fmt.Errorf("SHA256 mismatch: expected %s, got %s", expected, actual) + } + + return nil +} + +func DownloadToTemp(url string) (string, error) { + resp, err := httpClient.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download failed with status %d", resp.StatusCode) + } + + if resp.ContentLength > maxDownloadSize { + return "", fmt.Errorf("download size %d exceeds maximum of %d bytes", resp.ContentLength, maxDownloadSize) + } + + tmpFile, err := os.CreateTemp("", "bk-*") + if err != nil { + return "", err + } + defer tmpFile.Close() + + // LimitReader caps the bytes copied; the +1 lets us detect when the + // payload exceeds the limit even if Content-Length was missing or wrong. + written, err := io.Copy(tmpFile, io.LimitReader(resp.Body, maxDownloadSize+1)) + if err != nil { + os.Remove(tmpFile.Name()) + return "", err + } + if written > maxDownloadSize { + os.Remove(tmpFile.Name()) + return "", fmt.Errorf("download exceeded maximum of %d bytes", maxDownloadSize) + } + + return tmpFile.Name(), nil +} + +func ReplaceBinary(archivePath, targetPath, targetOS string) error { + if targetOS == "windows" { + return fmt.Errorf("self-update is not supported on Windows yet; please download a new release manually") + } + + workDir, err := os.MkdirTemp(filepath.Dir(targetPath), ".bk-update-*") + if err != nil { + return err + } + defer os.RemoveAll(workDir) + + if err := ExtractBinary(archivePath, workDir, targetOS); err != nil { + return err + } + + newBinary := filepath.Join(workDir, BinaryName(targetOS)) + if err := os.Chmod(newBinary, 0o755); err != nil { + return err + } + + return os.Rename(newBinary, targetPath) +} + +func ExtractBinary(archivePath, dest, targetOS string) error { + if targetOS == "linux" { + return extractTarGz(archivePath, dest, BinaryName(targetOS)) + } + return extractZip(archivePath, dest, BinaryName(targetOS)) +} + +func extractTarGz(archivePath, dest, binaryName string) error { + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + + if filepath.Base(header.Name) != binaryName { + continue + } + + out, err := os.OpenFile(filepath.Join(dest, binaryName), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return err + } + if _, err := io.Copy(out, tr); err != nil { + out.Close() + return err + } + if err := out.Close(); err != nil { + return err + } + return nil + } + + return fmt.Errorf("%s not found in archive", binaryName) +} + +func extractZip(archivePath, dest, binaryName string) error { + zr, err := zip.OpenReader(archivePath) + if err != nil { + return err + } + defer zr.Close() + + for _, file := range zr.File { + if filepath.Base(file.Name) != binaryName { + continue + } + + in, err := file.Open() + if err != nil { + return err + } + + out, err := os.OpenFile(filepath.Join(dest, binaryName), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + in.Close() + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + in.Close() + return err + } + if err := out.Close(); err != nil { + in.Close() + return err + } + if err := in.Close(); err != nil { + return err + } + return nil + } + + return fmt.Errorf("%s not found in archive", binaryName) +} + +func isHomebrewPath(path string) bool { + return strings.Contains(path, "/cellar/") || + strings.Contains(path, "/homebrew/") || + strings.Contains(path, "/.linuxbrew/") +} + +func isMisePath(path string) bool { + return strings.Contains(path, "/mise/shims/") || + strings.Contains(path, "/mise/installs/") || + strings.Contains(path, "/.local/share/mise/") || + strings.Contains(path, "/library/application support/mise/") +} + +func brewFormula(path string) string { + parts := strings.Split(filepath.ToSlash(path), "/") + for i, part := range parts { + if strings.EqualFold(part, "Cellar") && i+1 < len(parts) { + return parts[i+1] + } + } + return "bk" +} diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go new file mode 100644 index 00000000..0279d957 --- /dev/null +++ b/internal/selfupdate/selfupdate_test.go @@ -0,0 +1,230 @@ +package selfupdate + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDetectInstallation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + resolvedPath string + wantMethod InstallMethod + wantFormula string + }{ + { + name: "homebrew via cellar", + path: "/opt/homebrew/bin/bk", + resolvedPath: "/opt/homebrew/Cellar/bk@3/3.44.0/bin/bk", + wantMethod: InstallMethodHomebrew, + wantFormula: "bk@3", + }, + { + name: "mise shim", + path: "/Users/ben/.local/share/mise/shims/bk", + resolvedPath: "/Users/ben/.local/share/mise/installs/ubi-buildkite-cli/3.44.0/bin/bk", + wantMethod: InstallMethodMise, + }, + { + name: "standalone binary", + path: "/Users/ben/bin/bk", + resolvedPath: "/Users/ben/bin/bk", + wantMethod: InstallMethodStandalone, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := DetectInstallation(tt.path, tt.resolvedPath) + if got.Method != tt.wantMethod { + t.Fatalf("Method = %q, want %q", got.Method, tt.wantMethod) + } + if got.BrewFormula != tt.wantFormula { + t.Fatalf("BrewFormula = %q, want %q", got.BrewFormula, tt.wantFormula) + } + }) + } +} + +func TestBuildURLs(t *testing.T) { + t.Parallel() + + if got, want := BuildDownloadURL("3.44.0", "linux", "amd64"), "https://github.com/buildkite/cli/releases/download/v3.44.0/bk_3.44.0_linux_amd64.tar.gz"; got != want { + t.Fatalf("BuildDownloadURL(linux) = %q, want %q", got, want) + } + if got, want := BuildDownloadURL("3.44.0", "darwin", "arm64"), "https://github.com/buildkite/cli/releases/download/v3.44.0/bk_3.44.0_macOS_arm64.zip"; got != want { + t.Fatalf("BuildDownloadURL(darwin) = %q, want %q", got, want) + } + if got, want := BuildChecksumURL("3.44.0"), "https://github.com/buildkite/cli/releases/download/v3.44.0/bk_3.44.0_checksums.txt"; got != want { + t.Fatalf("BuildChecksumURL() = %q, want %q", got, want) + } +} + +func TestFetchExpectedSHA256(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "abc123 bk_3.44.0_linux_amd64.tar.gz\ndef456 bk_3.44.0_macOS_arm64.zip\n") + })) + defer server.Close() + + got, err := FetchExpectedSHA256(server.URL, "bk_3.44.0_macOS_arm64.zip") + if err != nil { + t.Fatalf("FetchExpectedSHA256() error = %v", err) + } + if got != "def456" { + t.Fatalf("FetchExpectedSHA256() = %q, want def456", got) + } +} + +func TestVerifySHA256(t *testing.T) { + t.Parallel() + + content := []byte("hello bk") + hash := sha256.Sum256(content) + path := filepath.Join(t.TempDir(), "bk") + if err := os.WriteFile(path, content, 0o644); err != nil { + t.Fatal(err) + } + + if err := VerifySHA256(path, hex.EncodeToString(hash[:])); err != nil { + t.Fatalf("VerifySHA256() error = %v", err) + } + + if err := VerifySHA256(path, strings.Repeat("0", 64)); err == nil { + t.Fatal("VerifySHA256() succeeded with wrong hash") + } +} + +func TestDownloadToTemp_RejectsOversizedPayload(t *testing.T) { + originalMax := maxDownloadSize + maxDownloadSize = 16 + t.Cleanup(func() { maxDownloadSize = originalMax }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // No Content-Length so we exercise the LimitReader path. + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write([]byte(strings.Repeat("A", 64))) + })) + defer server.Close() + + path, err := DownloadToTemp(server.URL) + if err == nil { + os.Remove(path) + t.Fatal("DownloadToTemp() succeeded, want size error") + } + if !strings.Contains(err.Error(), "maximum") { + t.Fatalf("expected size error, got %v", err) + } + if _, statErr := os.Stat(path); statErr == nil { + t.Fatalf("temp file %q was not cleaned up after rejection", path) + } +} + +func TestExtractBinary(t *testing.T) { + t.Parallel() + + t.Run("extracts linux tar.gz", func(t *testing.T) { + t.Parallel() + archive := filepath.Join(t.TempDir(), "bk.tar.gz") + createTarGz(t, archive, map[string]string{ + "bk_3.44.0_linux_amd64/README.md": "ignore", + "bk_3.44.0_linux_amd64/bk": "linux binary", + }) + + dest := t.TempDir() + if err := ExtractBinary(archive, dest, "linux"); err != nil { + t.Fatalf("ExtractBinary() error = %v", err) + } + got, err := os.ReadFile(filepath.Join(dest, "bk")) + if err != nil { + t.Fatal(err) + } + if string(got) != "linux binary" { + t.Fatalf("extracted content = %q", got) + } + }) + + t.Run("extracts macOS zip", func(t *testing.T) { + t.Parallel() + archive := filepath.Join(t.TempDir(), "bk.zip") + createZip(t, archive, map[string]string{ + "bk_3.44.0_macOS_arm64/README.md": "ignore", + "bk_3.44.0_macOS_arm64/bk": "darwin binary", + }) + + dest := t.TempDir() + if err := ExtractBinary(archive, dest, "darwin"); err != nil { + t.Fatalf("ExtractBinary() error = %v", err) + } + got, err := os.ReadFile(filepath.Join(dest, "bk")) + if err != nil { + t.Fatal(err) + } + if string(got) != "darwin binary" { + t.Fatalf("extracted content = %q", got) + } + }) +} + +func createTarGz(t *testing.T, path string, files map[string]string) { + t.Helper() + + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gw := gzip.NewWriter(f) + defer gw.Close() + + tw := tar.NewWriter(gw) + defer tw.Close() + + for name, content := range files { + if err := tw.WriteHeader(&tar.Header{Name: name, Size: int64(len(content)), Mode: 0o755}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } +} + +func createZip(t *testing.T, path string, files map[string]string) { + t.Helper() + + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + zw := zip.NewWriter(f) + defer zw.Close() + + for name, content := range files { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } +} diff --git a/main.go b/main.go index 91f572f1..c812937b 100644 --- a/main.go +++ b/main.go @@ -27,6 +27,7 @@ import ( "github.com/buildkite/cli/v3/cmd/queue" "github.com/buildkite/cli/v3/cmd/secret" "github.com/buildkite/cli/v3/cmd/skill" + updatePkg "github.com/buildkite/cli/v3/cmd/update" "github.com/buildkite/cli/v3/cmd/use" "github.com/buildkite/cli/v3/cmd/user" versionPkg "github.com/buildkite/cli/v3/cmd/version" @@ -40,33 +41,34 @@ import ( // Kong CLI structure, with base commands defined as additional commands are defined in their respective files type CLI struct { // Global flags - Yes bool `help:"Skip all confirmation prompts" short:"y"` - NoInput bool `help:"Disable all interactive prompts" name:"no-input"` - Quiet bool `help:"Suppress progress output" short:"q"` - NoPager bool `help:"Disable pager for text output" name:"no-pager"` - Debug bool `help:"Enable debug output for REST API calls"` - Agent AgentCmd `cmd:"" help:"Manage agents"` - Api ApiCmd `cmd:"" help:"Interact with the Buildkite API"` - Artifacts ArtifactsCmd `cmd:"" help:"Manage pipeline build artifacts"` - Auth AuthCmd `cmd:"" help:"Authenticate with Buildkite"` - Build BuildCmd `cmd:"" help:"Manage pipeline builds"` - Cluster ClusterCmd `cmd:"" help:"Manage organization clusters"` - Maintainer MaintainerCmd `cmd:"" help:"Manage cluster maintainers"` - Queue QueueCmd `cmd:"" help:"Manage cluster queues"` - Secret SecretCmd `cmd:"" help:"Manage cluster secrets"` - Skill SkillCmd `cmd:"" help:"Manage Buildkite skills for AI coding agents"` - Config bkConfig.ConfigCmd `cmd:"" help:"Manage CLI configuration"` - Configure ConfigureCmd `cmd:"" help:"Configure Buildkite API token" hidden:""` - Init bkInit.InitCmd `cmd:"" help:"Initialize a pipeline.yaml file"` - Job JobCmd `cmd:"" help:"Manage jobs within a build"` - Organization OrganizationCmd `cmd:"" help:"Manage organizations" aliases:"org"` - Pipeline PipelineCmd `cmd:"" help:"Manage pipelines"` - Package PackageCmd `cmd:"" help:"Manage packages"` - Preflight PreflightCmd `cmd:"" help:"Run a build against a snapshot of the local working tree (experimental)"` - Use use.UseCmd `cmd:"" help:"Select an organization" hidden:""` - User UserCmd `cmd:"" help:"Invite users to the organization"` - Version VersionCmd `cmd:"" help:"Print the version of the CLI being used"` - Whoami whoami.WhoAmICmd `cmd:"" help:"Print the current user and organization" hidden:""` + Yes bool `help:"Skip all confirmation prompts" short:"y"` + NoInput bool `help:"Disable all interactive prompts" name:"no-input"` + Quiet bool `help:"Suppress progress output" short:"q"` + NoPager bool `help:"Disable pager for text output" name:"no-pager"` + Debug bool `help:"Enable debug output for REST API calls"` + Agent AgentCmd `cmd:"" help:"Manage agents"` + Api ApiCmd `cmd:"" help:"Interact with the Buildkite API"` + Artifacts ArtifactsCmd `cmd:"" help:"Manage pipeline build artifacts"` + Auth AuthCmd `cmd:"" help:"Authenticate with Buildkite"` + Build BuildCmd `cmd:"" help:"Manage pipeline builds"` + Cluster ClusterCmd `cmd:"" help:"Manage organization clusters"` + Maintainer MaintainerCmd `cmd:"" help:"Manage cluster maintainers"` + Queue QueueCmd `cmd:"" help:"Manage cluster queues"` + Secret SecretCmd `cmd:"" help:"Manage cluster secrets"` + Skill SkillCmd `cmd:"" help:"Manage Buildkite skills for AI coding agents"` + Config bkConfig.ConfigCmd `cmd:"" help:"Manage CLI configuration"` + Configure ConfigureCmd `cmd:"" help:"Configure Buildkite API token" hidden:""` + Init bkInit.InitCmd `cmd:"" help:"Initialize a pipeline.yaml file"` + Job JobCmd `cmd:"" help:"Manage jobs within a build"` + Organization OrganizationCmd `cmd:"" help:"Manage organizations" aliases:"org"` + Pipeline PipelineCmd `cmd:"" help:"Manage pipelines"` + Package PackageCmd `cmd:"" help:"Manage packages"` + Preflight PreflightCmd `cmd:"" help:"Run a build against a snapshot of the local working tree (experimental)"` + Use use.UseCmd `cmd:"" help:"Select an organization" hidden:""` + User UserCmd `cmd:"" help:"Invite users to the organization"` + Update updatePkg.UpdateCmd `cmd:"" help:"Update the installed bk CLI or print update instructions"` + Version VersionCmd `cmd:"" help:"Print the version of the CLI being used"` + Whoami whoami.WhoAmICmd `cmd:"" help:"Print the current user and organization" hidden:""` } type (