diff --git a/CHANGELOG.md b/CHANGELOG.md index 87aecf0..8e3e69c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable user-facing changes to `sonacli` are documented in this file. ## [Unreleased] - Nothing yet. +## [v0.1.0] - 2026-03-27 +- Added a `sonacli update` command that downloads a release archive, verifies `checksums.txt`, and replaces the current binary in place. +- Added a `sonacli star` command that opens the project GitHub repository in the default browser. + ## [v0.1.0-rc.3] - 2026-03-27 - Added a root `install.sh` and simplified `README.md` so users can install `sonacli` directly from `curl` or `wget`. @@ -34,7 +38,8 @@ All notable user-facing changes to `sonacli` are documented in this file. macOS. - Security policy for vulnerability reporting and supported version guidance. -[Unreleased]: https://github.com/mshddev/sonacli/compare/v0.1.0-rc.3...HEAD +[Unreleased]: https://github.com/mshddev/sonacli/compare/v0.1.0...HEAD +[v0.1.0]: https://github.com/mshddev/sonacli/releases/tag/v0.1.0 [v0.1.0-rc.3]: https://github.com/mshddev/sonacli/releases/tag/v0.1.0-rc.3 [v0.1.0-rc.2]: https://github.com/mshddev/sonacli/releases/tag/v0.1.0-rc.2 [v0.1.0-rc.1]: https://github.com/mshddev/sonacli/releases/tag/v0.1.0-rc.1 diff --git a/README.md b/README.md index d07f26b..6e63e1b 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,13 @@ curl -fsSL https://raw.githubusercontent.com/mshddev/sonacli/main/install.sh | s The installer downloads the matching GitHub release archive for your OS and CPU, verifies `checksums.txt`, installs `sonacli`, and prints a `PATH` hint when needed. +After installing from a release, update the binary in place with: + +```sh +sonacli update +sonacli update --version v0.1.0-rc.3 +``` + ### Build from source ```sh diff --git a/docs/cli.md b/docs/cli.md index 0f64ac3..08b615c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -118,6 +118,35 @@ sonacli skill uninstall --codex | `--claude` | Remove from Claude Code | | `--codex` | Remove from Codex | +## Repository + +### `sonacli star` + +Open the `sonacli` GitHub repository in your default browser so you can star it manually. + +```sh +sonacli star +``` + +On macOS this uses `open`. On Linux this uses `xdg-open`. + +## Updating + +### `sonacli update` + +Download the matching GitHub release archive for the current OS and CPU, verify `checksums.txt`, and replace the running `sonacli` executable in place. + +```sh +sonacli update +sonacli update --version v0.1.0-rc.3 +``` + +If the requested tag matches the current build version, `sonacli` reports that it is already installed and exits without replacing the binary. + +| Flag | Description | +|------|-------------| +| `--version` | Install a specific release tag instead of the latest release | + ## Versioning ### `sonacli version` diff --git a/internal/cli/root.go b/internal/cli/root.go index 2c4ffa9..9413951 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -54,7 +54,7 @@ func NewRootCmd(stdout, stderr io.Writer) *cobra.Command { }) cmd.CompletionOptions.DisableDefaultCmd = true cmd.InitDefaultHelpFlag() - cmd.AddCommand(NewAuthCmd(), NewIssueCmd(), NewProjectCmd(), NewSkillCmd(), NewVersionCmd()) + cmd.AddCommand(NewAuthCmd(), NewIssueCmd(), NewProjectCmd(), NewSkillCmd(), NewStarCmd(), NewUpdateCmd(), NewVersionCmd()) return cmd } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 31b7cc8..6ede242 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -94,6 +94,14 @@ func assertRootHelp(t *testing.T, output string) { t.Fatalf("expected skill command to be listed, got %q", output) } + if !strings.Contains(output, "star") || !strings.Contains(output, "Star the sonacli GitHub repository") { + t.Fatalf("expected star command to be listed, got %q", output) + } + + if !strings.Contains(output, "update") || !strings.Contains(output, "Update sonacli to the latest version") { + t.Fatalf("expected update command to be listed, got %q", output) + } + if !strings.Contains(output, "version") { t.Fatalf("expected version command to be listed, got %q", output) } diff --git a/internal/cli/star.go b/internal/cli/star.go new file mode 100644 index 0000000..84a25c2 --- /dev/null +++ b/internal/cli/star.go @@ -0,0 +1,77 @@ +package cli + +import ( + "context" + "fmt" + "os/exec" + "runtime" + "strings" + + "github.com/spf13/cobra" +) + +const repoURL = "https://github.com/mshddev/sonacli" + +type browserOpener interface { + Open(ctx context.Context, url string) error +} + +type systemBrowserOpener struct { + command string +} + +var newStarOpener = func() (browserOpener, error) { + return newSystemBrowserOpener(runtime.GOOS) +} + +func NewStarCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "star", + Short: "Star the sonacli GitHub repository", + Long: "Open the sonacli GitHub repository in your default browser so you can star it manually.", + Example: ` sonacli star`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + opener, err := newStarOpener() + if err != nil { + return err + } + + if err := opener.Open(cmd.Context(), repoURL); err != nil { + return err + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Opened %s in your browser.\n", repoURL) + return err + }, + } + + applyCommandTemplates(cmd) + + return cmd +} + +func newSystemBrowserOpener(goos string) (*systemBrowserOpener, error) { + switch goos { + case "darwin": + return &systemBrowserOpener{command: "open"}, nil + case "linux": + return &systemBrowserOpener{command: "xdg-open"}, nil + default: + return nil, fmt.Errorf("unsupported operating system: %s", goos) + } +} + +func (o *systemBrowserOpener) Open(ctx context.Context, url string) error { + output, err := exec.CommandContext(ctx, o.command, url).CombinedOutput() + if err == nil { + return nil + } + + message := strings.TrimSpace(string(output)) + if message == "" { + return fmt.Errorf("open %s with %s: %w", url, o.command, err) + } + + return fmt.Errorf("open %s with %s: %w: %s", url, o.command, err, message) +} diff --git a/internal/cli/star_test.go b/internal/cli/star_test.go new file mode 100644 index 0000000..0a1f950 --- /dev/null +++ b/internal/cli/star_test.go @@ -0,0 +1,120 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "testing" +) + +func TestRunStarCommandOpensRepositoryURL(t *testing.T) { + originalFactory := newStarOpener + t.Cleanup(func() { + newStarOpener = originalFactory + }) + + opener := &stubBrowserOpener{} + newStarOpener = func() (browserOpener, error) { + return opener, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + + exitCode := Run([]string{"star"}, &stdout, &stderr) + + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d", exitCode) + } + + if opener.url != repoURL { + t.Fatalf("opened URL = %q, want %q", opener.url, repoURL) + } + + if got := stdout.String(); got != "Opened "+repoURL+" in your browser.\n" { + t.Fatalf("stdout = %q", got) + } + + if stderr.Len() != 0 { + t.Fatalf("expected no stderr output, got %q", stderr.String()) + } +} + +func TestRunStarCommandShowsHelpWhenOpenFails(t *testing.T) { + originalFactory := newStarOpener + t.Cleanup(func() { + newStarOpener = originalFactory + }) + + newStarOpener = func() (browserOpener, error) { + return &stubBrowserOpener{err: errors.New("boom")}, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + + exitCode := Run([]string{"star"}, &stdout, &stderr) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + + if stdout.Len() != 0 { + t.Fatalf("expected no stdout output, got %q", stdout.String()) + } + + output := stderr.String() + if !bytes.Contains(stderr.Bytes(), []byte("Error: boom")) { + t.Fatalf("stderr = %q", output) + } + + if !bytes.Contains(stderr.Bytes(), []byte("sonacli star")) { + t.Fatalf("stderr = %q", output) + } +} + +func TestNewSystemBrowserOpenerSelectsCommandByOS(t *testing.T) { + t.Parallel() + + testCases := []struct { + goos string + command string + }{ + {goos: "darwin", command: "open"}, + {goos: "linux", command: "xdg-open"}, + } + + for _, tc := range testCases { + t.Run(tc.goos, func(t *testing.T) { + t.Parallel() + + opener, err := newSystemBrowserOpener(tc.goos) + if err != nil { + t.Fatalf("newSystemBrowserOpener(%q) returned error: %v", tc.goos, err) + } + + if opener.command != tc.command { + t.Fatalf("command = %q, want %q", opener.command, tc.command) + } + }) + } +} + +func TestNewSystemBrowserOpenerRejectsUnsupportedOS(t *testing.T) { + t.Parallel() + + opener, err := newSystemBrowserOpener("windows") + if err == nil { + t.Fatalf("expected error, got opener %+v", opener) + } +} + +type stubBrowserOpener struct { + url string + err error +} + +func (s *stubBrowserOpener) Open(_ context.Context, url string) error { + s.url = url + return s.err +} diff --git a/internal/cli/update.go b/internal/cli/update.go new file mode 100644 index 0000000..b90d773 --- /dev/null +++ b/internal/cli/update.go @@ -0,0 +1,54 @@ +package cli + +import ( + "context" + "fmt" + + "github.com/mshddev/sonacli/internal/selfupdate" + "github.com/spf13/cobra" +) + +type updateRunner interface { + Update(ctx context.Context, requestedVersion string) (selfupdate.Result, error) +} + +var newUpdateRunner = func() (updateRunner, error) { + return selfupdate.NewFromEnvironment(Version) +} + +func NewUpdateCmd() *cobra.Command { + var version string + + cmd := &cobra.Command{ + Use: "update", + Short: "Update sonacli to the latest version", + Long: "Download a sonacli release archive for the current operating system and CPU, verify checksums.txt, and replace the current executable in place. By default the latest GitHub release is installed.", + Example: ` sonacli update + sonacli update --version v0.1.0-rc.3`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + updater, err := newUpdateRunner() + if err != nil { + return err + } + + result, err := updater.Update(cmd.Context(), version) + if err != nil { + return err + } + + if !result.Updated { + _, err = fmt.Fprintf(cmd.OutOrStdout(), "sonacli %s is already installed.\nPath: %s\n", result.Version, result.ExecutablePath) + return err + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Updated sonacli from %s to %s.\nPath: %s\n", result.PreviousVersion, result.Version, result.ExecutablePath) + return err + }, + } + + applyCommandTemplates(cmd) + cmd.Flags().StringVar(&version, "version", "", "Install a specific release tag instead of the latest release") + + return cmd +} diff --git a/internal/cli/update_test.go b/internal/cli/update_test.go new file mode 100644 index 0000000..871109f --- /dev/null +++ b/internal/cli/update_test.go @@ -0,0 +1,138 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "testing" + + "github.com/mshddev/sonacli/internal/selfupdate" +) + +func TestRunUpdateCommandPrintsSuccessMessage(t *testing.T) { + originalFactory := newUpdateRunner + t.Cleanup(func() { + newUpdateRunner = originalFactory + }) + + runner := &stubUpdateRunner{ + result: selfupdate.Result{ + Version: "v1.2.3", + PreviousVersion: "v1.0.0", + ExecutablePath: "/tmp/sonacli", + Updated: true, + }, + } + + newUpdateRunner = func() (updateRunner, error) { + return runner, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + + exitCode := Run([]string{"update", "--version", "v1.2.3"}, &stdout, &stderr) + + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d", exitCode) + } + + if runner.requestedVersion != "v1.2.3" { + t.Fatalf("requested version = %q, want %q", runner.requestedVersion, "v1.2.3") + } + + if got := stdout.String(); got != "Updated sonacli from v1.0.0 to v1.2.3.\nPath: /tmp/sonacli\n" { + t.Fatalf("stdout = %q", got) + } + + if stderr.Len() != 0 { + t.Fatalf("expected no stderr output, got %q", stderr.String()) + } +} + +func TestRunUpdateCommandPrintsAlreadyInstalledMessage(t *testing.T) { + originalFactory := newUpdateRunner + t.Cleanup(func() { + newUpdateRunner = originalFactory + }) + + newUpdateRunner = func() (updateRunner, error) { + return &stubUpdateRunner{ + result: selfupdate.Result{ + Version: "v1.2.3", + ExecutablePath: "/tmp/sonacli", + }, + }, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + + exitCode := Run([]string{"update"}, &stdout, &stderr) + + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d", exitCode) + } + + if got := stdout.String(); got != "sonacli v1.2.3 is already installed.\nPath: /tmp/sonacli\n" { + t.Fatalf("stdout = %q", got) + } + + if stderr.Len() != 0 { + t.Fatalf("expected no stderr output, got %q", stderr.String()) + } +} + +func TestRunUpdateCommandShowsHelpWhenUpdaterFails(t *testing.T) { + originalFactory := newUpdateRunner + t.Cleanup(func() { + newUpdateRunner = originalFactory + }) + + newUpdateRunner = func() (updateRunner, error) { + return &stubUpdateRunner{ + err: errors.New("boom"), + }, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + + exitCode := Run([]string{"update"}, &stdout, &stderr) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + + if stdout.Len() != 0 { + t.Fatalf("expected no stdout output, got %q", stdout.String()) + } + + output := stderr.String() + if output == "" { + t.Fatalf("expected stderr output") + } + + if !bytes.Contains(stderr.Bytes(), []byte("Error: boom")) { + t.Fatalf("stderr = %q", output) + } + + if !bytes.Contains(stderr.Bytes(), []byte("sonacli update [flags]")) { + t.Fatalf("stderr = %q", output) + } +} + +type stubUpdateRunner struct { + requestedVersion string + result selfupdate.Result + err error +} + +func (s *stubUpdateRunner) Update(_ context.Context, requestedVersion string) (selfupdate.Result, error) { + s.requestedVersion = requestedVersion + if s.err != nil { + return selfupdate.Result{}, s.err + } + + return s.result, nil +} diff --git a/internal/selfupdate/updater.go b/internal/selfupdate/updater.go new file mode 100644 index 0000000..fd36825 --- /dev/null +++ b/internal/selfupdate/updater.go @@ -0,0 +1,424 @@ +package selfupdate + +import ( + "archive/tar" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path" + "path/filepath" + "runtime" + "strings" + "time" +) + +const ( + defaultRepo = "mshddev/sonacli" + defaultAPIBase = "https://api.github.com" +) + +type Updater struct { + Client *http.Client + Repo string + APIBase string + DownloadBase string + ExecutablePath string + CurrentVersion string + GOOS string + GOARCH string +} + +type Result struct { + Version string + PreviousVersion string + ExecutablePath string + Updated bool +} + +type githubRelease struct { + TagName string `json:"tag_name"` +} + +func NewFromEnvironment(currentVersion string) (*Updater, error) { + executablePath, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("resolve current executable path: %w", err) + } + + if resolvedPath, err := filepath.EvalSymlinks(executablePath); err == nil { + executablePath = resolvedPath + } + + repo := valueOrDefault(os.Getenv("SONACLI_INSTALL_REPO"), defaultRepo) + downloadBase := os.Getenv("SONACLI_INSTALL_DOWNLOAD_BASE") + if downloadBase == "" { + downloadBase = fmt.Sprintf("https://github.com/%s/releases/download", repo) + } + + return &Updater{ + Client: &http.Client{ + Timeout: 30 * time.Second, + }, + Repo: repo, + APIBase: valueOrDefault(os.Getenv("SONACLI_INSTALL_API_BASE"), defaultAPIBase), + DownloadBase: downloadBase, + ExecutablePath: executablePath, + CurrentVersion: currentVersion, + GOOS: runtime.GOOS, + GOARCH: runtime.GOARCH, + }, nil +} + +func (u *Updater) Update(ctx context.Context, requestedVersion string) (Result, error) { + targetVersion, err := u.resolveVersion(ctx, requestedVersion) + if err != nil { + return Result{}, err + } + + result := Result{ + Version: targetVersion, + PreviousVersion: u.CurrentVersion, + ExecutablePath: u.ExecutablePath, + } + + if targetVersion == u.CurrentVersion { + return result, nil + } + + assetBase, err := u.assetBaseName(targetVersion) + if err != nil { + return Result{}, err + } + + tmpDir, err := os.MkdirTemp("", "sonacli-update-*") + if err != nil { + return Result{}, fmt.Errorf("create temporary directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + archiveName := assetBase + ".tar.gz" + archivePath := filepath.Join(tmpDir, archiveName) + checksumsPath := filepath.Join(tmpDir, "checksums.txt") + extractedBinaryPath := filepath.Join(tmpDir, "sonacli") + + if err := u.downloadToFile(ctx, u.releaseAssetURL(targetVersion, archiveName), archivePath); err != nil { + return Result{}, err + } + + if err := u.downloadToFile(ctx, u.releaseAssetURL(targetVersion, "checksums.txt"), checksumsPath); err != nil { + return Result{}, err + } + + if err := verifyChecksum(archiveName, archivePath, checksumsPath); err != nil { + return Result{}, err + } + + if err := extractBinaryFromArchive(archivePath, assetBase+"/sonacli", extractedBinaryPath); err != nil { + return Result{}, err + } + + if err := replaceExecutable(extractedBinaryPath, u.ExecutablePath); err != nil { + return Result{}, err + } + + result.Updated = true + + return result, nil +} + +func (u *Updater) resolveVersion(ctx context.Context, requestedVersion string) (string, error) { + if requestedVersion != "" { + return requestedVersion, nil + } + + latestURL := fmt.Sprintf("%s/repos/%s/releases/latest", strings.TrimRight(u.APIBase, "/"), u.Repo) + if tag, err := u.fetchLatestReleaseTag(ctx, latestURL); err == nil && tag != "" { + return tag, nil + } + + releasesURL := fmt.Sprintf("%s/repos/%s/releases?per_page=1", strings.TrimRight(u.APIBase, "/"), u.Repo) + if tag, err := u.fetchReleasesTag(ctx, releasesURL); err == nil && tag != "" { + return tag, nil + } + + return "", fmt.Errorf("could not determine a release tag from %s", u.Repo) +} + +func (u *Updater) fetchLatestReleaseTag(ctx context.Context, url string) (string, error) { + var release githubRelease + if err := u.getJSON(ctx, url, &release); err != nil { + return "", err + } + + return strings.TrimSpace(release.TagName), nil +} + +func (u *Updater) fetchReleasesTag(ctx context.Context, url string) (string, error) { + var releases []githubRelease + if err := u.getJSON(ctx, url, &releases); err != nil { + return "", err + } + + if len(releases) == 0 { + return "", errors.New("no releases found") + } + + return strings.TrimSpace(releases[0].TagName), nil +} + +func (u *Updater) getJSON(ctx context.Context, url string, target any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("create request for %s: %w", url, err) + } + + resp, err := u.Client.Do(req) + if err != nil { + return fmt.Errorf("download %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download %s: unexpected status %s", url, resp.Status) + } + + if err := json.NewDecoder(resp.Body).Decode(target); err != nil { + return fmt.Errorf("decode %s: %w", url, err) + } + + return nil +} + +func (u *Updater) downloadToFile(ctx context.Context, url, destination string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("create request for %s: %w", url, err) + } + + resp, err := u.Client.Do(req) + if err != nil { + return fmt.Errorf("download %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download %s: unexpected status %s", url, resp.Status) + } + + file, err := os.Create(destination) + if err != nil { + return fmt.Errorf("create %s: %w", destination, err) + } + + if _, err := io.Copy(file, resp.Body); err != nil { + file.Close() + return fmt.Errorf("write %s: %w", destination, err) + } + + if err := file.Close(); err != nil { + return fmt.Errorf("close %s: %w", destination, err) + } + + return nil +} + +func (u *Updater) assetBaseName(version string) (string, error) { + goos, err := releaseGOOS(u.GOOS) + if err != nil { + return "", err + } + + goarch, err := releaseGOARCH(u.GOARCH) + if err != nil { + return "", err + } + + return fmt.Sprintf("sonacli_%s_%s_%s", strings.TrimPrefix(version, "v"), goos, goarch), nil +} + +func (u *Updater) releaseAssetURL(version, filename string) string { + return fmt.Sprintf("%s/%s/%s", strings.TrimRight(u.DownloadBase, "/"), version, filename) +} + +func releaseGOOS(goos string) (string, error) { + switch goos { + case "linux", "darwin": + return goos, nil + default: + return "", fmt.Errorf("unsupported operating system: %s", goos) + } +} + +func releaseGOARCH(goarch string) (string, error) { + switch goarch { + case "amd64", "arm64": + return goarch, nil + default: + return "", fmt.Errorf("unsupported architecture: %s", goarch) + } +} + +func verifyChecksum(assetName, archivePath, checksumsPath string) error { + expected, err := checksumForAsset(assetName, checksumsPath) + if err != nil { + return err + } + + archiveBytes, err := os.ReadFile(archivePath) + if err != nil { + return fmt.Errorf("read %s: %w", archivePath, err) + } + + actual := sha256.Sum256(archiveBytes) + actualHex := hex.EncodeToString(actual[:]) + if expected != actualHex { + return fmt.Errorf("checksum verification failed for %s", assetName) + } + + return nil +} + +func checksumForAsset(assetName, checksumsPath string) (string, error) { + contents, err := os.ReadFile(checksumsPath) + if err != nil { + return "", fmt.Errorf("read %s: %w", checksumsPath, err) + } + + for _, line := range strings.Split(string(contents), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + + name := strings.TrimPrefix(fields[1], "*") + if name == assetName { + return fields[0], nil + } + } + + return "", fmt.Errorf("could not find %s in checksums.txt", assetName) +} + +func extractBinaryFromArchive(archivePath, entryName, destination string) error { + file, err := os.Open(archivePath) + if err != nil { + return fmt.Errorf("open %s: %w", archivePath, err) + } + defer file.Close() + + gzipReader, err := gzip.NewReader(file) + if err != nil { + return fmt.Errorf("open gzip stream for %s: %w", archivePath, err) + } + defer gzipReader.Close() + + tarReader := tar.NewReader(gzipReader) + cleanEntryName := path.Clean(entryName) + + for { + header, err := tarReader.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return fmt.Errorf("read archive %s: %w", archivePath, err) + } + + if path.Clean(header.Name) != cleanEntryName { + continue + } + + mode := header.FileInfo().Mode().Perm() + if mode&0o111 == 0 { + mode = 0o755 + } + + out, err := os.OpenFile(destination, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return fmt.Errorf("create %s: %w", destination, err) + } + + if _, err := io.Copy(out, tarReader); err != nil { + out.Close() + return fmt.Errorf("extract %s: %w", destination, err) + } + + if err := out.Close(); err != nil { + return fmt.Errorf("close %s: %w", destination, err) + } + + return nil + } + + return fmt.Errorf("release archive did not contain %s", entryName) +} + +func replaceExecutable(sourcePath, targetPath string) error { + sourceInfo, err := os.Stat(sourcePath) + if err != nil { + return fmt.Errorf("stat %s: %w", sourcePath, err) + } + + targetDir := filepath.Dir(targetPath) + replacement, err := os.CreateTemp(targetDir, ".sonacli-update-*") + if err != nil { + return fmt.Errorf("create replacement binary in %s: %w", targetDir, err) + } + + replacementPath := replacement.Name() + cleanupReplacement := true + defer func() { + if cleanupReplacement { + _ = os.Remove(replacementPath) + } + }() + + source, err := os.Open(sourcePath) + if err != nil { + replacement.Close() + return fmt.Errorf("open %s: %w", sourcePath, err) + } + + if _, err := io.Copy(replacement, source); err != nil { + source.Close() + replacement.Close() + return fmt.Errorf("stage replacement binary: %w", err) + } + + if err := source.Close(); err != nil { + replacement.Close() + return fmt.Errorf("close %s: %w", sourcePath, err) + } + + if err := replacement.Chmod(sourceInfo.Mode().Perm()); err != nil { + replacement.Close() + return fmt.Errorf("chmod %s: %w", replacementPath, err) + } + + if err := replacement.Close(); err != nil { + return fmt.Errorf("close %s: %w", replacementPath, err) + } + + if err := os.Rename(replacementPath, targetPath); err != nil { + return fmt.Errorf("replace %s: %w", targetPath, err) + } + + cleanupReplacement = false + + return nil +} + +func valueOrDefault(value, fallback string) string { + if value != "" { + return value + } + + return fallback +} diff --git a/internal/selfupdate/updater_test.go b/internal/selfupdate/updater_test.go new file mode 100644 index 0000000..658faa2 --- /dev/null +++ b/internal/selfupdate/updater_test.go @@ -0,0 +1,324 @@ +package selfupdate + +import ( + "archive/tar" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" +) + +func TestUpdaterInstallsLatestRelease(t *testing.T) { + t.Parallel() + + server := newTestServer(t, updateServerConfig{ + tag: "v1.2.3", + latestTag: "v1.2.3", + releasesTag: "v1.2.3", + latestStatus: http.StatusOK, + releasesStatus: http.StatusOK, + }) + + executablePath := createExecutable(t, "sonacli current version") + updater := &Updater{ + Client: server.client(), + Repo: "mshddev/sonacli", + APIBase: server.apiBase, + DownloadBase: server.downloadBase, + ExecutablePath: executablePath, + CurrentVersion: "v1.0.0", + GOOS: server.goos, + GOARCH: server.goarch, + } + + result, err := updater.Update(context.Background(), "") + if err != nil { + t.Fatalf("update failed: %v", err) + } + + if !result.Updated { + t.Fatalf("expected updater to replace the executable") + } + + if result.Version != "v1.2.3" { + t.Fatalf("updated version = %q, want %q", result.Version, "v1.2.3") + } + + verifyExecutableOutput(t, executablePath, "fake sonacli v1.2.3\n") +} + +func TestUpdaterFallsBackToFirstReleaseWhenLatestEndpointFails(t *testing.T) { + t.Parallel() + + server := newTestServer(t, updateServerConfig{ + tag: "v2.0.0-rc.1", + releasesTag: "v2.0.0-rc.1", + latestStatus: http.StatusNotFound, + releasesStatus: http.StatusOK, + }) + + executablePath := createExecutable(t, "sonacli current version") + updater := &Updater{ + Client: server.client(), + Repo: "mshddev/sonacli", + APIBase: server.apiBase, + DownloadBase: server.downloadBase, + ExecutablePath: executablePath, + CurrentVersion: "v1.0.0", + GOOS: server.goos, + GOARCH: server.goarch, + } + + result, err := updater.Update(context.Background(), "") + if err != nil { + t.Fatalf("update failed: %v", err) + } + + if result.Version != "v2.0.0-rc.1" { + t.Fatalf("updated version = %q, want %q", result.Version, "v2.0.0-rc.1") + } + + verifyExecutableOutput(t, executablePath, "fake sonacli v2.0.0-rc.1\n") +} + +func TestUpdaterSkipsReplacementWhenVersionMatches(t *testing.T) { + t.Parallel() + + server := newTestServer(t, updateServerConfig{ + tag: "v1.2.3", + latestTag: "v1.2.3", + latestStatus: http.StatusOK, + releasesStatus: http.StatusInternalServerError, + }) + + executablePath := createExecutable(t, "sonacli current version") + updater := &Updater{ + Client: server.client(), + Repo: "mshddev/sonacli", + APIBase: server.apiBase, + DownloadBase: server.downloadBase, + ExecutablePath: executablePath, + CurrentVersion: "v1.2.3", + GOOS: server.goos, + GOARCH: server.goarch, + } + + result, err := updater.Update(context.Background(), "") + if err != nil { + t.Fatalf("update failed: %v", err) + } + + if result.Updated { + t.Fatalf("expected updater to skip replacing the executable") + } + + verifyExecutableOutput(t, executablePath, "sonacli current version\n") + if server.downloadCount() != 1 { + t.Fatalf("download request count = %d, want %d", server.downloadCount(), 1) + } +} + +type updateServerConfig struct { + tag string + latestTag string + releasesTag string + latestStatus int + releasesStatus int +} + +type updateTestServer struct { + apiBase string + downloadBase string + goos string + goarch string + server *httptest.Server + requestCounter *requestCounter +} + +type requestCounter struct { + count atomic.Int64 +} + +func (c *requestCounter) add() { + c.count.Add(1) +} + +func (c *requestCounter) value() int { + return int(c.count.Load()) +} + +func newTestServer(t *testing.T, cfg updateServerConfig) updateTestServer { + t.Helper() + + goos, goarch := releaseTargetForTests(t) + assetBase := fmt.Sprintf("sonacli_%s_%s_%s", strings.TrimPrefix(cfg.tag, "v"), goos, goarch) + archiveName := assetBase + ".tar.gz" + archiveBytes := makeArchive(t, assetBase, "#!/bin/sh\nprintf '%s\\n' \"fake sonacli "+cfg.tag+"\"\n") + checksum := sha256.Sum256(archiveBytes) + checksums := fmt.Sprintf("%s %s\n", hex.EncodeToString(checksum[:]), archiveName) + counter := &requestCounter{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + counter.add() + + switch { + case r.URL.Path == "/repos/mshddev/sonacli/releases/latest": + if cfg.latestStatus != http.StatusOK { + http.Error(w, http.StatusText(cfg.latestStatus), cfg.latestStatus) + return + } + fmt.Fprintf(w, `{"tag_name":%q}`, cfg.latestTag) + case r.URL.Path == "/repos/mshddev/sonacli/releases": + if cfg.releasesStatus != http.StatusOK { + http.Error(w, http.StatusText(cfg.releasesStatus), cfg.releasesStatus) + return + } + fmt.Fprintf(w, `[{"tag_name":%q}]`, cfg.releasesTag) + case r.URL.Path == "/download/"+cfg.tag+"/"+archiveName: + w.Header().Set("Content-Type", "application/gzip") + _, _ = w.Write(archiveBytes) + case r.URL.Path == "/download/"+cfg.tag+"/checksums.txt": + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = io.WriteString(w, checksums) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + return updateTestServer{ + apiBase: server.URL, + downloadBase: server.URL + "/download", + goos: goos, + goarch: goarch, + server: server, + requestCounter: counter, + } +} + +func (s updateTestServer) client() *http.Client { + return s.server.Client() +} + +func (s updateTestServer) downloadCount() int { + return s.requestCounter.value() +} + +func createExecutable(t *testing.T, contents string) string { + t.Helper() + + binaryPath := filepath.Join(t.TempDir(), "sonacli") + script := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' %q\n", contents) + if err := os.WriteFile(binaryPath, []byte(script), 0o755); err != nil { + t.Fatalf("write fake executable: %v", err) + } + + return binaryPath +} + +func verifyExecutableOutput(t *testing.T, binaryPath, want string) { + t.Helper() + + output, err := runExecutable(binaryPath) + if err != nil { + t.Fatalf("run executable: %v", err) + } + + if output != want { + t.Fatalf("executable output = %q, want %q", output, want) + } +} + +func runExecutable(binaryPath string) (string, error) { + cmd := exec.Command(binaryPath) + output, err := cmd.CombinedOutput() + return string(output), err +} + +func makeArchive(t *testing.T, assetBase, binaryContents string) []byte { + t.Helper() + + archivePath := filepath.Join(t.TempDir(), assetBase+".tar.gz") + file, err := os.Create(archivePath) + if err != nil { + t.Fatalf("create archive: %v", err) + } + + gzipWriter := gzip.NewWriter(file) + tarWriter := tar.NewWriter(gzipWriter) + + writeTarEntry(t, tarWriter, assetBase+"/", 0o755, nil, tar.TypeDir) + writeTarEntry(t, tarWriter, assetBase+"/sonacli", 0o755, []byte(binaryContents), tar.TypeReg) + + if err := tarWriter.Close(); err != nil { + t.Fatalf("close tar writer: %v", err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatalf("close gzip writer: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("close archive file: %v", err) + } + + data, err := os.ReadFile(archivePath) + if err != nil { + t.Fatalf("read archive: %v", err) + } + + return data +} + +func writeTarEntry(t *testing.T, tw *tar.Writer, name string, mode int64, content []byte, typeflag byte) { + t.Helper() + + header := &tar.Header{ + Name: name, + Mode: mode, + Size: int64(len(content)), + Typeflag: typeflag, + } + if typeflag == tar.TypeDir { + header.Size = 0 + } + + if err := tw.WriteHeader(header); err != nil { + t.Fatalf("write tar header for %s: %v", name, err) + } + + if len(content) == 0 { + return + } + + if _, err := tw.Write(content); err != nil { + t.Fatalf("write tar contents for %s: %v", name, err) + } +} + +func releaseTargetForTests(t *testing.T) (string, string) { + t.Helper() + + switch runtime.GOOS { + case "linux", "darwin": + default: + t.Skipf("unsupported GOOS %q", runtime.GOOS) + } + + switch runtime.GOARCH { + case "amd64", "arm64": + default: + t.Skipf("unsupported GOARCH %q", runtime.GOARCH) + } + + return runtime.GOOS, runtime.GOARCH +} diff --git a/tests/01-root.md b/tests/01-root.md index dad67ef..16b6a8a 100644 --- a/tests/01-root.md +++ b/tests/01-root.md @@ -35,6 +35,8 @@ Available Commands: issue Read SonarQube issues project Read SonarQube projects skill Manage agent skills for sonacli + star Star the sonacli GitHub repository + update Update sonacli to the latest version version Print the sonacli version Flags: -h, --help help for sonacli @@ -97,6 +99,8 @@ Available Commands: issue Read SonarQube issues project Read SonarQube projects skill Manage agent skills for sonacli + star Star the sonacli GitHub repository + update Update sonacli to the latest version version Print the sonacli version Flags: -h, --help help for sonacli @@ -157,6 +161,8 @@ Available Commands: issue Read SonarQube issues project Read SonarQube projects skill Manage agent skills for sonacli + star Star the sonacli GitHub repository + update Update sonacli to the latest version version Print the sonacli version Flags: -h, --help help for sonacli diff --git a/tests/12-update.md b/tests/12-update.md new file mode 100644 index 0000000..2ef2d9f --- /dev/null +++ b/tests/12-update.md @@ -0,0 +1,166 @@ +# 12 Update Command + +This case file defines end-to-end coverage for the `sonacli update` command. + +## Additional Requirements + +- Use the shared setup from [README.md](README.md). +- Use fresh capture files for each case. +- Use a copied binary instead of mutating `./tests/bin/sonacli` directly. +- Verify that the update command does not create any files or directories inside the isolated `HOME`. +- Write the final Markdown report to `tests/results/12-update-testresults.md`. +- Do not remove `./tests/bin/sonacli` in this case file. The shared cleanup step in [README.md](README.md) removes the built binary after the selected run finishes. + +## Cases + +### UPDATE-01 Replace The Current Binary From A Local Fixture Server + +Run the update command against a copied binary and a local release fixture server. + +#### Execute + +```sh +STDOUT_FILE="$(mktemp)" +STDERR_FILE="$(mktemp)" +VERIFY_STDOUT_FILE="$(mktemp)" +VERIFY_STDERR_FILE="$(mktemp)" +EXPECTED_STDOUT_FILE="$(mktemp)" +EXPECTED_STDERR_FILE="$(mktemp)" +EXPECTED_VERIFY_STDOUT_FILE="$(mktemp)" +ACTUAL_FILE_LIST="$(mktemp)" +EXPECTED_FILE_LIST="$(mktemp)" +UPDATE_FIXTURE_ROOT="$(mktemp -d)" +UPDATE_BIN_DIR="$(mktemp -d)" +UPDATE_BINARY="$UPDATE_BIN_DIR/sonacli" +UPDATE_BINARY_REAL="$(cd "$UPDATE_BIN_DIR" && pwd -P)/sonacli" +TEST_VERSION="v9.8.7" + +cp ./tests/bin/sonacli "$UPDATE_BINARY" +chmod 755 "$UPDATE_BINARY" + +case "$(uname -s)" in + Linux) TEST_GOOS="linux" ;; + Darwin) TEST_GOOS="darwin" ;; + *) echo "unsupported OS" >&2; exit 1 ;; +esac + +case "$(uname -m)" in + x86_64|amd64) TEST_GOARCH="amd64" ;; + arm64|aarch64) TEST_GOARCH="arm64" ;; + *) echo "unsupported architecture" >&2; exit 1 ;; +esac + +TEST_ASSET_BASENAME="sonacli_${TEST_VERSION#v}_${TEST_GOOS}_${TEST_GOARCH}" + +mkdir -p "$UPDATE_FIXTURE_ROOT/repos/mshddev/sonacli/releases" +mkdir -p "$UPDATE_FIXTURE_ROOT/download/$TEST_VERSION" +mkdir -p "$UPDATE_FIXTURE_ROOT/$TEST_ASSET_BASENAME" + +cat >"$UPDATE_FIXTURE_ROOT/repos/mshddev/sonacli/releases/latest" <"$UPDATE_FIXTURE_ROOT/$TEST_ASSET_BASENAME/sonacli" </dev/null 2>&1; then + ( + cd "$UPDATE_FIXTURE_ROOT/download/$TEST_VERSION" && + shasum -a 256 "$TEST_ASSET_BASENAME.tar.gz" > checksums.txt + ) +else + ( + cd "$UPDATE_FIXTURE_ROOT/download/$TEST_VERSION" && + sha256sum "$TEST_ASSET_BASENAME.tar.gz" > checksums.txt + ) +fi + +cat >"$EXPECTED_STDOUT_FILE" <"$EXPECTED_STDERR_FILE" +printf '%s\n' "fake sonacli $TEST_VERSION" >"$EXPECTED_VERIFY_STDOUT_FILE" +printf '%s\n' "$HOME" >"$EXPECTED_FILE_LIST" + +UPDATE_SERVER_PORT_FILE="$UPDATE_FIXTURE_ROOT/server.port" +UPDATE_SERVER_LOG="$UPDATE_FIXTURE_ROOT/server.log" + +python3 - <<'PY' "$UPDATE_FIXTURE_ROOT" "$UPDATE_SERVER_PORT_FILE" >"$UPDATE_SERVER_LOG" 2>&1 & +import functools +import http.server +import pathlib +import socketserver +import sys + +root = pathlib.Path(sys.argv[1]) +port_file = pathlib.Path(sys.argv[2]) +handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(root)) + +with socketserver.TCPServer(("127.0.0.1", 0), handler) as httpd: + port_file.write_text(str(httpd.server_address[1]), encoding="utf-8") + httpd.serve_forever() +PY + +UPDATE_SERVER_PID=$! + +while [ ! -s "$UPDATE_SERVER_PORT_FILE" ]; do + sleep 1 +done + +UPDATE_SERVER_PORT="$(cat "$UPDATE_SERVER_PORT_FILE")" + +SONACLI_INSTALL_API_BASE="http://127.0.0.1:$UPDATE_SERVER_PORT" \ +SONACLI_INSTALL_DOWNLOAD_BASE="http://127.0.0.1:$UPDATE_SERVER_PORT/download" \ +"$UPDATE_BINARY" update >"$STDOUT_FILE" 2>"$STDERR_FILE" +EXIT_CODE=$? + +"$UPDATE_BINARY" >"$VERIFY_STDOUT_FILE" 2>"$VERIFY_STDERR_FILE" +VERIFY_EXIT_CODE=$? + +find "$HOME" -print | LC_ALL=C sort >"$ACTUAL_FILE_LIST" +``` + +#### Verify + +```sh +test "$EXIT_CODE" -eq 0 +test "$VERIFY_EXIT_CODE" -eq 0 +cmp -s "$STDOUT_FILE" "$EXPECTED_STDOUT_FILE" +cmp -s "$STDERR_FILE" "$EXPECTED_STDERR_FILE" +cmp -s "$VERIFY_STDOUT_FILE" "$EXPECTED_VERIFY_STDOUT_FILE" +test ! -s "$VERIFY_STDERR_FILE" +cmp -s "$ACTUAL_FILE_LIST" "$EXPECTED_FILE_LIST" +``` + +If any assertion fails, capture the mismatch: + +```sh +printf 'update_exit=%s\n' "$EXIT_CODE" +printf 'verify_exit=%s\n' "$VERIFY_EXIT_CODE" +diff -u "$EXPECTED_STDOUT_FILE" "$STDOUT_FILE" +diff -u "$EXPECTED_STDERR_FILE" "$STDERR_FILE" +diff -u "$EXPECTED_VERIFY_STDOUT_FILE" "$VERIFY_STDOUT_FILE" +cat "$VERIFY_STDERR_FILE" +diff -u "$EXPECTED_FILE_LIST" "$ACTUAL_FILE_LIST" +``` + +Remove case-specific files after verification: + +```sh +kill "$UPDATE_SERVER_PID" +wait "$UPDATE_SERVER_PID" 2>/dev/null || true +rm -rf "$UPDATE_FIXTURE_ROOT" "$UPDATE_BIN_DIR" +rm -f "$STDOUT_FILE" "$STDERR_FILE" "$VERIFY_STDOUT_FILE" "$VERIFY_STDERR_FILE" +rm -f "$EXPECTED_STDOUT_FILE" "$EXPECTED_STDERR_FILE" "$EXPECTED_VERIFY_STDOUT_FILE" +rm -f "$ACTUAL_FILE_LIST" "$EXPECTED_FILE_LIST" +``` diff --git a/tests/13-star.md b/tests/13-star.md new file mode 100644 index 0000000..2348ddb --- /dev/null +++ b/tests/13-star.md @@ -0,0 +1,84 @@ +# 13 Star Command + +This case file defines end-to-end coverage for the `sonacli star` command. + +## Additional Requirements + +- Use the shared setup from [README.md](README.md). +- Use fresh capture files for each case. +- Use a fake browser opener executable earlier on `PATH` so the test does not open a real browser. +- Verify that the star command does not create any files or directories inside the isolated `HOME`. +- Write the final Markdown report to `tests/results/13-star-testresults.md`. +- Do not remove `./tests/bin/sonacli` in this case file. The shared cleanup step in [README.md](README.md) removes the built binary after the selected run finishes. + +## Cases + +### STAR-01 Open The Repository URL With The Platform Browser Command + +Run the star command with a fake `open` or `xdg-open` executable on `PATH`. + +#### Execute + +```sh +STDOUT_FILE="$(mktemp)" +STDERR_FILE="$(mktemp)" +EXPECTED_STDOUT_FILE="$(mktemp)" +EXPECTED_STDERR_FILE="$(mktemp)" +EXPECTED_URL_FILE="$(mktemp)" +ACTUAL_URL_FILE="$(mktemp)" +FAKE_BIN_DIR="$(mktemp -d)" +OPEN_LOG_FILE="$(mktemp)" + +case "$(uname -s)" in + Linux) OPENER_NAME="xdg-open" ;; + Darwin) OPENER_NAME="open" ;; + *) echo "unsupported OS" >&2; exit 1 ;; +esac + +cat >"$FAKE_BIN_DIR/$OPENER_NAME" <"$OPEN_LOG_FILE" +exit 0 +EOF + +chmod +x "$FAKE_BIN_DIR/$OPENER_NAME" + +cat >"$EXPECTED_STDOUT_FILE" <<'EOF' +Opened https://github.com/mshddev/sonacli in your browser. +EOF + +: >"$EXPECTED_STDERR_FILE" +printf '%s\n' 'https://github.com/mshddev/sonacli' >"$EXPECTED_URL_FILE" + +PATH="$FAKE_BIN_DIR:/usr/bin:/bin:/usr/sbin:/sbin" ./tests/bin/sonacli star >"$STDOUT_FILE" 2>"$STDERR_FILE" +EXIT_CODE=$? +cp "$OPEN_LOG_FILE" "$ACTUAL_URL_FILE" +``` + +#### Verify + +```sh +test "$EXIT_CODE" -eq 0 +cmp -s "$STDOUT_FILE" "$EXPECTED_STDOUT_FILE" +cmp -s "$STDERR_FILE" "$EXPECTED_STDERR_FILE" +cmp -s "$ACTUAL_URL_FILE" "$EXPECTED_URL_FILE" +test -z "$(find "$HOME" -mindepth 1 -print -quit)" +``` + +If any assertion fails, capture the mismatch: + +```sh +printf 'exit=%s\n' "$EXIT_CODE" +diff -u "$EXPECTED_STDOUT_FILE" "$STDOUT_FILE" +diff -u "$EXPECTED_STDERR_FILE" "$STDERR_FILE" +diff -u "$EXPECTED_URL_FILE" "$ACTUAL_URL_FILE" +find "$HOME" -mindepth 1 -maxdepth 3 -print +``` + +Remove case-specific files after verification: + +```sh +rm -rf "$FAKE_BIN_DIR" +rm -f "$OPEN_LOG_FILE" "$ACTUAL_URL_FILE" "$EXPECTED_URL_FILE" +rm -f "$STDOUT_FILE" "$STDERR_FILE" "$EXPECTED_STDOUT_FILE" "$EXPECTED_STDERR_FILE" +``` diff --git a/tests/README.md b/tests/README.md index 2e4aaf7..564d7eb 100644 --- a/tests/README.md +++ b/tests/README.md @@ -104,6 +104,8 @@ Case files that generate temporary tokens are responsible for revoking them duri | [09-project-list.md](09-project-list.md) | Yes | Requires saved auth config and the sample project seeded during shared setup | | [10-group-commands.md](10-group-commands.md) | No | CLI-only coverage for grouped commands rejecting unknown subcommands | | [11-install-script.md](11-install-script.md) | No | Uses a local HTTP fixture to validate the release installer script | +| [12-update.md](12-update.md) | No | Uses a local HTTP fixture and a copied binary to validate self-update behavior | +| [13-star.md](13-star.md) | No | Uses a fake browser opener on `PATH` to validate repository opening behavior | Each case file may define additional requirements beyond the shared setup, read the case file for details.