diff --git a/CHANGELOG.md b/CHANGELOG.md index 024fb15..7f9e926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to APIMux CLI are documented here. +## [1.1.5] - 2026-05-12 + +### Added + +- Implemented `apimux upgrade` for direct binary installs. The command now + checks the release manifest, downloads the matching platform archive, verifies + the checksum, extracts the `apimux` binary, and atomically replaces the + current executable. + +### Fixed + +- Replaced the previous `cli_upgrade_not_implemented` response with either a + successful upgrade result or a clear package-manager/install-script guidance + error when the current executable cannot be safely replaced. +- Made schema-bound source command help degrade gracefully when the service + schema cannot be reached, instead of returning a transport error for + `--help`. + ## [1.1.4] - 2026-05-12 ### Fixed diff --git a/README.md b/README.md index ee005fc..05554fa 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,15 @@ apimux schema show amazon.get_product apimux schema list ``` +Check for updates or upgrade direct binary installs: + +```bash +apimux upgrade --check +apimux upgrade +``` + +If your `apimux` executable is managed by a package manager or is installed as a symlink, use that package manager or rerun the install script instead. + Useful flags: - `--debug`: print the sanitized response envelope diff --git a/internal/command/root.go b/internal/command/root.go index 6b7e6f1..3969a2f 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "strings" + "time" "github.com/reorc/apimux-cli/internal/buildinfo" "github.com/reorc/apimux-cli/internal/client" @@ -326,20 +327,23 @@ func (r *Root) newUpgradeCommand() *cobra.Command { Use: "upgrade", Short: "Inspect or run CLI updates", RunE: func(cmd *cobra.Command, args []string) error { - if !check { - return &cliError{ - exitCode: 2, - code: "cli_upgrade_not_implemented", - message: "upgrade currently supports --check only", - } - } - manifestURL := releaseManifestURL() - result, err := update.Check(cmd.Context(), &http.Client{}, buildinfo.Current().Version, manifestURL) + httpClient := &http.Client{Timeout: 60 * time.Second} + var result any + var err error + if check { + result, err = update.Check(cmd.Context(), httpClient, buildinfo.Current().Version, manifestURL) + } else { + result, err = update.Upgrade(cmd.Context(), httpClient, buildinfo.Current().Version, manifestURL, "") + } if err != nil { + code := "cli_upgrade_failed" + if check { + code = "cli_upgrade_check_failed" + } return &cliError{ exitCode: 1, - code: "cli_upgrade_check_failed", + code: code, message: err.Error(), } } diff --git a/internal/command/root_lifecycle_test.go b/internal/command/root_lifecycle_test.go index 5820088..4ba0ea1 100644 --- a/internal/command/root_lifecycle_test.go +++ b/internal/command/root_lifecycle_test.go @@ -106,6 +106,43 @@ func TestUpgradeCheckReportsVersionStatus(t *testing.T) { } } +func TestUpgradeReportsUpToDate(t *testing.T) { + originalVersion := buildinfo.Version + t.Cleanup(func() { + buildinfo.Version = originalVersion + }) + buildinfo.Version = "1.2.0" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"latest_version":"1.2.0"}`)) + })) + defer server.Close() + + t.Setenv("APIMUX_CLI_RELEASE_MANIFEST_URL", server.URL) + + var stdout bytes.Buffer + var stderr bytes.Buffer + + root := NewRoot(&stdout, &stderr) + exitCode, err := root.Execute(context.Background(), []string{"upgrade"}) + if err != nil { + t.Fatalf("execute root: %v", err) + } + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d, stderr=%s", exitCode, stderr.String()) + } + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if payload["status"] != "up_to_date" { + t.Fatalf("unexpected status: %#v", payload) + } + if payload["message"] != "apimux is already up to date" { + t.Fatalf("unexpected message: %#v", payload) + } +} + func TestConfigInitFollowedByGoogleTrendsUsesSavedConfig(t *testing.T) { tempDir := t.TempDir() t.Setenv("APIMUX_CONFIG_DIR", tempDir) diff --git a/internal/command/root_sources_test.go b/internal/command/root_sources_test.go index ad573de..d6a718a 100644 --- a/internal/command/root_sources_test.go +++ b/internal/command/root_sources_test.go @@ -985,6 +985,31 @@ func TestRedditSearchHelpPrintsSchemaFlags(t *testing.T) { } } +func TestSchemaBoundHelpFallsBackWhenSchemaUnavailable(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + root := NewRoot(&stdout, &stderr) + exitCode, err := root.Execute(context.Background(), []string{ + "--base-url", "http://127.0.0.1:1", + "amazon", "get_product", + "--help", + }) + if err != nil { + t.Fatalf("execute root: %v", err) + } + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d, stderr=%s, stdout=%s", exitCode, stderr.String(), stdout.String()) + } + output := stdout.String() + if !strings.Contains(output, "Fetch one Amazon product") || !strings.Contains(output, "Schema lookup failed") { + t.Fatalf("expected fallback help with schema lookup warning, got %s", output) + } + if strings.Contains(output, "cli_transport_error") { + t.Fatalf("expected help fallback, got transport error: %s", output) + } +} + func TestRedditGetPostDetailCallsService(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if maybeServeSchema(w, r) { diff --git a/internal/command/schema_binding.go b/internal/command/schema_binding.go index 0f4351c..f9e7c08 100644 --- a/internal/command/schema_binding.go +++ b/internal/command/schema_binding.go @@ -40,11 +40,16 @@ func newSchemaBoundCapabilityCommand(runCtx *runContext, capability, use, short, DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { args = stripPersistentArgs(args) + wantsHelp := wantsSchemaBoundHelp(args) spec, err := fetchCapabilitySchema(cmd.Context(), runCtx, capability) if err != nil { + if wantsHelp { + cmd.Long = fmt.Sprintf("%s\n\nDetailed flags are loaded from the APIMux service schema. Schema lookup failed: %s", short, err.Error()) + return cmd.Help() + } return err } - if wantsSchemaBoundHelp(args) { + if wantsHelp { return writeSchemaBoundHelp(cmd, spec) } if len(spec.Parameters) == 0 { diff --git a/internal/update/update.go b/internal/update/update.go index 939640f..d125ee6 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -1,10 +1,18 @@ package update import ( + "archive/tar" + "compress/gzip" "context" + "crypto/sha256" "encoding/json" "fmt" + "io" "net/http" + "net/url" + "os" + "path/filepath" + "runtime" "strconv" "strings" "time" @@ -22,6 +30,13 @@ type CheckResult struct { Status string `json:"status"` } +type UpgradeResult struct { + CheckResult + ExecutablePath string `json:"executable_path,omitempty"` + ArchiveURL string `json:"archive_url,omitempty"` + StatusMessage string `json:"message,omitempty"` +} + func Check(ctx context.Context, client *http.Client, currentVersion string, manifestURL string) (CheckResult, error) { result := CheckResult{ CurrentVersion: strings.TrimSpace(currentVersion), @@ -78,6 +93,83 @@ func Check(ctx context.Context, client *http.Client, currentVersion string, mani return result, nil } +func Upgrade(ctx context.Context, client *http.Client, currentVersion string, manifestURL string, executablePath string) (UpgradeResult, error) { + check, err := Check(ctx, client, currentVersion, manifestURL) + result := UpgradeResult{CheckResult: check} + if err != nil { + return result, err + } + if !check.UpdateAvailable { + result.StatusMessage = "apimux is already up to date" + return result, nil + } + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + + releaseBaseURL, err := releaseBaseURLFromManifest(manifestURL) + if err != nil { + return result, err + } + releaseVersion, releaseTag, err := normalizeReleaseVersion(check.LatestVersion) + if err != nil { + return result, err + } + archiveName, err := archiveName(releaseVersion) + if err != nil { + return result, err + } + archiveURL := strings.TrimRight(releaseBaseURL, "/") + "/" + releaseTag + "/" + archiveName + checksumURL := strings.TrimRight(releaseBaseURL, "/") + "/" + releaseTag + "/apimux_" + releaseVersion + "_checksums.txt" + result.ArchiveURL = archiveURL + + targetPath := strings.TrimSpace(executablePath) + if targetPath == "" { + targetPath, err = os.Executable() + if err != nil { + return result, err + } + } + targetPath, err = filepath.Abs(targetPath) + if err != nil { + return result, err + } + result.ExecutablePath = targetPath + if err := validateReplaceTarget(targetPath); err != nil { + return result, err + } + + tmpDir, err := os.MkdirTemp("", "apimux-upgrade-*") + if err != nil { + return result, err + } + defer os.RemoveAll(tmpDir) + + archivePath := filepath.Join(tmpDir, archiveName) + checksumPath := filepath.Join(tmpDir, "checksums.txt") + if err := downloadFile(ctx, client, archiveURL, archivePath); err != nil { + return result, err + } + if err := downloadFile(ctx, client, checksumURL, checksumPath); err != nil { + return result, err + } + if err := verifyChecksum(archivePath, checksumPath, archiveName); err != nil { + return result, err + } + extractedPath, err := extractBinary(archivePath, tmpDir) + if err != nil { + return result, err + } + if err := replaceExecutable(targetPath, extractedPath); err != nil { + return result, err + } + + result.Status = "upgraded" + result.UpdateAvailable = false + result.StatusMessage = "apimux upgraded to " + check.LatestVersion + return result, nil +} + func compareVersions(current string, latest string) int { currentParts := parseVersion(current) latestParts := parseVersion(latest) @@ -107,6 +199,185 @@ func compareVersions(current string, latest string) int { return 0 } +func normalizeReleaseVersion(value string) (string, string, error) { + version := strings.TrimSpace(value) + if version == "" { + return "", "", fmt.Errorf("release version is empty") + } + version = strings.TrimPrefix(version, "v") + return version, "v" + version, nil +} + +func releaseBaseURLFromManifest(manifestURL string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(manifestURL)) + if err != nil { + return "", err + } + const suffix = "/latest/download/latest.json" + if !strings.HasSuffix(parsed.Path, suffix) { + return "", fmt.Errorf("cannot derive release download URL from manifest URL %s", manifestURL) + } + parsed.Path = strings.TrimSuffix(parsed.Path, suffix) + "/download" + parsed.RawQuery = "" + parsed.Fragment = "" + return parsed.String(), nil +} + +func archiveName(version string) (string, error) { + goos := runtime.GOOS + goarch := runtime.GOARCH + switch goos { + case "darwin", "linux": + default: + return "", fmt.Errorf("unsupported platform: %s/%s", goos, goarch) + } + switch goarch { + case "amd64", "arm64": + default: + return "", fmt.Errorf("unsupported platform: %s/%s", goos, goarch) + } + return fmt.Sprintf("apimux_%s_%s_%s.tar.gz", version, goos, goarch), nil +} + +func validateReplaceTarget(path string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("current executable is a symlink; upgrade with your package manager or rerun the install script") + } + if strings.Contains(path, "/Cellar/") || strings.Contains(path, "/Homebrew/") { + return fmt.Errorf("current executable appears to be package-manager managed; upgrade with your package manager or rerun the install script") + } + dir := filepath.Dir(path) + probe, err := os.CreateTemp(dir, ".apimux-upgrade-probe-*") + if err != nil { + return fmt.Errorf("cannot write to install directory %s; upgrade with your package manager or rerun the install script: %w", dir, err) + } + probePath := probe.Name() + _ = probe.Close() + _ = os.Remove(probePath) + return nil +} + +func downloadFile(ctx context.Context, client *http.Client, fileURL string, dst string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download %s returned status %d", fileURL, resp.StatusCode) + } + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, resp.Body) + return err +} + +func verifyChecksum(archivePath string, checksumPath string, archiveName string) error { + body, err := os.ReadFile(checksumPath) + if err != nil { + return err + } + var expected string + for _, line := range strings.Split(string(body), "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 && fields[1] == archiveName { + expected = fields[0] + break + } + } + if expected == "" { + return fmt.Errorf("checksum entry not found for %s", archiveName) + } + archiveBody, err := os.ReadFile(archivePath) + if err != nil { + return err + } + actual := fmt.Sprintf("%x", sha256.Sum256(archiveBody)) + if !strings.EqualFold(expected, actual) { + return fmt.Errorf("checksum mismatch for %s", archiveName) + } + return nil +} + +func extractBinary(archivePath string, tmpDir string) (string, error) { + file, err := os.Open(archivePath) + if err != nil { + return "", err + } + defer file.Close() + gzipReader, err := gzip.NewReader(file) + if err != nil { + return "", err + } + defer gzipReader.Close() + tarReader := tar.NewReader(gzipReader) + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return "", err + } + if filepath.Base(header.Name) != "apimux" || header.FileInfo().IsDir() { + continue + } + outPath := filepath.Join(tmpDir, "apimux") + out, err := os.OpenFile(outPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755) + if err != nil { + return "", err + } + if _, err := io.Copy(out, tarReader); err != nil { + _ = out.Close() + return "", err + } + if err := out.Close(); err != nil { + return "", err + } + return outPath, nil + } + return "", fmt.Errorf("archive did not contain apimux binary") +} + +func replaceExecutable(targetPath string, newBinaryPath string) error { + dir := filepath.Dir(targetPath) + tmp, err := os.CreateTemp(dir, ".apimux-upgrade-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + in, err := os.Open(newBinaryPath) + if err != nil { + _ = tmp.Close() + return err + } + defer in.Close() + if _, err := io.Copy(tmp, in); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmpPath, 0o755); err != nil { + return err + } + return os.Rename(tmpPath, targetPath) +} + func parseVersion(value string) []int { value = strings.TrimSpace(strings.TrimPrefix(value, "v")) if value == "" { diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..8df45b1 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,149 @@ +package update + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestUpgradeDownloadsVerifiesAndReplacesBinary(t *testing.T) { + archiveName, err := archiveName("1.2.0") + if err != nil { + t.Fatalf("archive name: %v", err) + } + newBinary := []byte("#!/bin/sh\necho upgraded\n") + archiveBody := buildTarGz(t, "apimux", newBinary) + archiveSum := fmt.Sprintf("%x", sha256.Sum256(archiveBody)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/releases/latest/download/latest.json": + _, _ = w.Write([]byte(`{"latest_version":"1.2.0"}`)) + case "/releases/download/v1.2.0/" + archiveName: + _, _ = w.Write(archiveBody) + case "/releases/download/v1.2.0/apimux_1.2.0_checksums.txt": + _, _ = fmt.Fprintf(w, "%s %s\n", archiveSum, archiveName) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + targetPath := filepath.Join(t.TempDir(), "apimux") + if err := os.WriteFile(targetPath, []byte("old binary"), 0o755); err != nil { + t.Fatalf("write target: %v", err) + } + + result, err := Upgrade(context.Background(), server.Client(), "1.1.0", server.URL+"/releases/latest/download/latest.json", targetPath) + if err != nil { + t.Fatalf("upgrade: %v", err) + } + if result.Status != "upgraded" || result.LatestVersion != "1.2.0" || result.UpdateAvailable { + t.Fatalf("unexpected result: %#v", result) + } + got, err := os.ReadFile(targetPath) + if err != nil { + t.Fatalf("read target: %v", err) + } + if string(got) != string(newBinary) { + t.Fatalf("binary was not replaced: %q", string(got)) + } + info, err := os.Stat(targetPath) + if err != nil { + t.Fatalf("stat target: %v", err) + } + if info.Mode().Perm()&0o111 == 0 { + t.Fatalf("expected executable permissions, got %s", info.Mode().Perm()) + } +} + +func TestUpgradeRejectsChecksumMismatch(t *testing.T) { + archiveName, err := archiveName("1.2.0") + if err != nil { + t.Fatalf("archive name: %v", err) + } + archiveBody := buildTarGz(t, "apimux", []byte("new binary")) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/releases/latest/download/latest.json": + _, _ = w.Write([]byte(`{"latest_version":"1.2.0"}`)) + case "/releases/download/v1.2.0/" + archiveName: + _, _ = w.Write(archiveBody) + case "/releases/download/v1.2.0/apimux_1.2.0_checksums.txt": + _, _ = fmt.Fprintf(w, "%064x %s\n", 0, archiveName) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + targetPath := filepath.Join(t.TempDir(), "apimux") + if err := os.WriteFile(targetPath, []byte("old binary"), 0o755); err != nil { + t.Fatalf("write target: %v", err) + } + + _, err = Upgrade(context.Background(), server.Client(), "1.1.0", server.URL+"/releases/latest/download/latest.json", targetPath) + if err == nil || !strings.Contains(err.Error(), "checksum mismatch") { + t.Fatalf("expected checksum mismatch, got %v", err) + } + got, err := os.ReadFile(targetPath) + if err != nil { + t.Fatalf("read target: %v", err) + } + if string(got) != "old binary" { + t.Fatalf("target changed after failed upgrade: %q", string(got)) + } +} + +func TestUpgradeRejectsSymlinkTarget(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink permissions vary on Windows") + } + dir := t.TempDir() + realPath := filepath.Join(dir, "apimux-real") + linkPath := filepath.Join(dir, "apimux") + if err := os.WriteFile(realPath, []byte("old binary"), 0o755); err != nil { + t.Fatalf("write target: %v", err) + } + if err := os.Symlink(realPath, linkPath); err != nil { + t.Fatalf("symlink target: %v", err) + } + err := validateReplaceTarget(linkPath) + if err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("expected symlink error, got %v", err) + } +} + +func buildTarGz(t *testing.T, name string, body []byte) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + if err := tw.WriteHeader(&tar.Header{ + Name: name, + Mode: 0o755, + Size: int64(len(body)), + }); err != nil { + t.Fatalf("write tar header: %v", err) + } + if _, err := tw.Write(body); err != nil { + t.Fatalf("write tar body: %v", err) + } + if err := tw.Close(); err != nil { + t.Fatalf("close tar: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("close gzip: %v", err) + } + return buf.Bytes() +}