diff --git a/CLAUDE.md b/CLAUDE.md index 091fe35a..7d973735 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -272,6 +272,7 @@ Tiger CLI is a Go-based command-line interface for managing Tiger, the modern da - `config.go` - Configuration management commands (show, set, unset, reset) - `mcp.go` - MCP server commands (install, start, list, get) - `version.go` - Version command + - `upgrade.go` - Self-update command (download latest release, verify checksum, replace running binary in place) - **Configuration**: `internal/tiger/config/config.go` - Centralized config with Viper integration - **Logging**: `internal/tiger/logging/logging.go` - Structured logging with zap - **API Client**: `internal/tiger/api/` - Generated OpenAPI client with mocks @@ -481,6 +482,7 @@ Tiger CLI uses a pure functional builder pattern with **zero global command stat ``` buildRootCmd() → Complete CLI with all commands and flags ├── buildVersionCmd() +├── buildUpgradeCmd() ├── buildConfigCmd() │ ├── buildConfigShowCmd() │ ├── buildConfigSetCmd() diff --git a/README.md b/README.md index 8d4422e5..5ae75516 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,16 @@ For manual repository installation instructions, see [here](https://packagecloud go install github.com/timescale/tiger-cli/cmd/tiger@latest ``` +## Updating + +To upgrade an existing installation to the latest release: + +```bash +tiger upgrade +``` + +This downloads the latest published binary, verifies its checksum, and replaces the currently running binary in place. If Tiger CLI was installed via a package manager (Homebrew, apt, yum/dnf), `tiger upgrade` will instead point you at the matching package-manager command. + ## Quick Start After installing Tiger CLI, authenticate with your Tiger Cloud account: @@ -115,6 +125,7 @@ Tiger CLI provides the following commands: - `list` - List available MCP tools, prompts, and resources - `get` - Get detailed information about a specific MCP capability (aliases: `describe`, `show`) - `tiger version` - Show version information +- `tiger upgrade` - Upgrade the Tiger CLI to the latest version (alias: `update`) Use `tiger --help` for detailed information about each command. @@ -236,7 +247,7 @@ All configuration options can be set via `tiger config set `: - `password_storage` - Password storage method: `keyring`, `pgpass`, or `none` (default: `keyring`) - `read_only` - When `true`, mutating operations are refused: the `tiger service create`/`fork`/`start`/`stop`/`resize`/`update-password`/`delete` CLI commands and their MCP equivalents return an error, and `tiger db connect`, `tiger db connection-string`, and the `db_execute_query` MCP tool open the database session in Tiger Cloud's immutable read-only mode (writes and DDL are rejected by the server). Read commands/tools are unaffected. Default: `false`. - `service_id` - Default service ID -- `version_check_interval` - How often the CLI will check for new versions, 0 to disable (default: `24h`) +- `version_check` - When `true`, the CLI checks for a newer version on each invocation (in an interactive terminal) and prints a notice if one is available. Set to `false` to disable. Default: `true`. ### Environment Variables @@ -253,7 +264,7 @@ Environment variables override configuration file values. All variables use the - `TIGER_PUBLIC_KEY` - Public key to use for authentication (takes priority over stored credentials) - `TIGER_SECRET_KEY` - Secret key to use for authentication (takes priority over stored credentials) - `TIGER_SERVICE_ID` - Default service ID -- `TIGER_VERSION_CHECK_INTERVAL` - How often the CLI will check for new versions, 0 to disable +- `TIGER_VERSION_CHECK` - When `true`, the CLI checks for a newer version on each invocation (in an interactive terminal) and prints a notice if one is available; `false` to disable ### Global Flags diff --git a/go.mod b/go.mod index 7b70464b..e81d758f 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,6 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/cli/safeexec v1.0.1 github.com/fatih/color v1.18.0 - github.com/go-viper/mapstructure/v2 v2.4.0 github.com/google/jsonschema-go v0.4.2 github.com/jackc/pgx/v5 v5.8.0 github.com/modelcontextprotocol/go-sdk v1.2.0 @@ -91,6 +90,7 @@ require ( github.com/go-openapi/swag/stringutils v0.25.4 // indirect github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/gofrs/flock v0.13.0 // indirect diff --git a/internal/tiger/cmd/config.go b/internal/tiger/cmd/config.go index 97b45ff9..afff70b8 100644 --- a/internal/tiger/cmd/config.go +++ b/internal/tiger/cmd/config.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "io" - "time" "github.com/olekukonko/tablewriter" "github.com/spf13/cobra" @@ -52,6 +51,7 @@ func buildConfigShowCmd() *cobra.Command { if err := config.ReadInConfig(v); err != nil { return err } + config.MigrateVersionCheck(v) cfgOut, err := config.ForOutputFromViper(v) if err != nil { @@ -221,11 +221,8 @@ func outputTable(w io.Writer, cfg *config.ConfigOutput) error { if cfg.ServiceID != nil { table.Append("service_id", *cfg.ServiceID) } - if cfg.VersionCheckInterval != nil { - table.Append("version_check_interval", cfg.VersionCheckInterval.String()) - } - if cfg.VersionCheckLastTime != nil { - table.Append("version_check_last_time", cfg.VersionCheckLastTime.Format(time.RFC1123)) + if cfg.VersionCheck != nil { + table.Append("version_check", fmt.Sprintf("%t", *cfg.VersionCheck)) } return table.Render() } diff --git a/internal/tiger/cmd/config_test.go b/internal/tiger/cmd/config_test.go index b75bd708..b438c57e 100644 --- a/internal/tiger/cmd/config_test.go +++ b/internal/tiger/cmd/config_test.go @@ -8,7 +8,6 @@ import ( "slices" "strings" "testing" - "time" "gopkg.in/yaml.v3" @@ -116,14 +115,12 @@ password_storage: pgpass func TestConfigShow_JSONOutput(t *testing.T) { tmpDir, _ := setupConfigTest(t) - now := time.Now() - // Create config file with JSON output format configContent := `api_url: https://json.api.com/v1 output: json analytics: false password_storage: keyring -version_check_last_time: ` + now.Format(time.RFC3339) + "\n" +` configFile := config.GetConfigFile(tmpDir) if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil { @@ -143,22 +140,21 @@ version_check_last_time: ` + now.Format(time.RFC3339) + "\n" // Verify ALL JSON keys and their expected values expectedValues := map[string]interface{}{ - "api_url": "https://json.api.com/v1", - "console_url": "https://console.cloud.tigerdata.com", - "gateway_url": "https://console.cloud.tigerdata.com/api", - "docs_mcp": true, - "docs_mcp_url": "https://mcp.tigerdata.com/docs", - "service_id": "", - "color": true, - "output": "json", - "analytics": false, - "password_storage": "keyring", - "read_only": false, - "debug": false, - "config_dir": tmpDir, - "releases_url": "https://cli.tigerdata.com", - "version_check_interval": "24h0m0s", - "version_check_last_time": now.Format(time.RFC3339), + "api_url": "https://json.api.com/v1", + "console_url": "https://console.cloud.tigerdata.com", + "gateway_url": "https://console.cloud.tigerdata.com/api", + "docs_mcp": true, + "docs_mcp_url": "https://mcp.tigerdata.com/docs", + "service_id": "", + "color": true, + "output": "json", + "analytics": false, + "password_storage": "keyring", + "read_only": false, + "debug": false, + "config_dir": tmpDir, + "releases_url": "https://cli.tigerdata.com", + "version_check": true, } for key, expectedValue := range expectedValues { @@ -176,14 +172,12 @@ version_check_last_time: ` + now.Format(time.RFC3339) + "\n" func TestConfigShow_YAMLOutput(t *testing.T) { tmpDir, _ := setupConfigTest(t) - now := time.Now() - // Create config file with YAML output format configContent := `api_url: https://yaml.api.com/v1 output: yaml analytics: false password_storage: keyring -version_check_last_time: ` + now.Format(time.RFC3339) + "\n" +` configFile := config.GetConfigFile(tmpDir) if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil { @@ -203,22 +197,21 @@ version_check_last_time: ` + now.Format(time.RFC3339) + "\n" // Verify ALL YAML keys and their expected values expectedValues := map[string]any{ - "api_url": "https://yaml.api.com/v1", - "console_url": "https://console.cloud.tigerdata.com", - "gateway_url": "https://console.cloud.tigerdata.com/api", - "docs_mcp": true, - "docs_mcp_url": "https://mcp.tigerdata.com/docs", - "service_id": "", - "color": true, - "output": "yaml", - "analytics": false, - "password_storage": "keyring", - "read_only": false, - "debug": false, - "config_dir": tmpDir, - "releases_url": "https://cli.tigerdata.com", - "version_check_interval": "24h0m0s", - "version_check_last_time": now.Format(time.RFC3339), + "api_url": "https://yaml.api.com/v1", + "console_url": "https://console.cloud.tigerdata.com", + "gateway_url": "https://console.cloud.tigerdata.com/api", + "docs_mcp": true, + "docs_mcp_url": "https://mcp.tigerdata.com/docs", + "service_id": "", + "color": true, + "output": "yaml", + "analytics": false, + "password_storage": "keyring", + "read_only": false, + "debug": false, + "config_dir": tmpDir, + "releases_url": "https://cli.tigerdata.com", + "version_check": true, } for key, expectedValue := range expectedValues { diff --git a/internal/tiger/cmd/root.go b/internal/tiger/cmd/root.go index c8512b3d..24290201 100644 --- a/internal/tiger/cmd/root.go +++ b/internal/tiger/cmd/root.go @@ -16,6 +16,7 @@ import ( "github.com/timescale/tiger-cli/internal/tiger/common" "github.com/timescale/tiger-cli/internal/tiger/config" "github.com/timescale/tiger-cli/internal/tiger/logging" + "github.com/timescale/tiger-cli/internal/tiger/util" "github.com/timescale/tiger-cli/internal/tiger/version" ) @@ -28,6 +29,11 @@ func buildRootCmd(ctx context.Context) (*cobra.Command, error) { var skipUpdateCheck bool var colorFlag bool + // versionCheckCh receives the result of the background update check started + // in PersistentPreRunE and drained in PersistentPostRunE. nil when no check + // was launched (disabled, non-interactive, CI, or --skip-update-check). + var versionCheckCh chan *version.CheckResult + cmd := &cobra.Command{ Use: "tiger", Short: "Tiger CLI - Tiger Cloud Platform command-line interface", @@ -80,6 +86,27 @@ tiger auth login color.NoColor = true } + // Kick off a background check for a newer release so the network + // fetch overlaps with the command's actual work; the result is + // printed in PersistentPostRunE. Gated to interactive, non-CI + // terminals. `version --check` runs its own synchronous check. + isVersionCheckCmd := cmd.Name() == "version" && cmd.Flag("check") != nil && cmd.Flag("check").Changed + if cfg.VersionCheck && !skipUpdateCheck && !isVersionCheckCmd && + !util.IsCI() && util.IsTerminal(cmd.ErrOrStderr()) { + versionCheckCh = make(chan *version.CheckResult, 1) + go func() { + result, err := version.CheckForUpdate(cfg) + if err != nil { + // A failed check (e.g. offline) shouldn't spam a warning + // on every command; surface it only in debug logs. + logging.Debug("background version check failed", zap.Error(err)) + versionCheckCh <- nil + return + } + versionCheckCh <- result + }() + } + return nil }, PersistentPostRunE: func(cmd *cobra.Command, args []string) error { @@ -88,14 +115,13 @@ tiger auth login return fmt.Errorf("failed to load config: %w", err) } - // Skip update check if: - // 1. --skip-update-check flag was provided - // 2. Running "version --check" (version command handles its own check) - isVersionCheck := cmd.Name() == "version" && cmd.Flag("check").Changed - if !skipUpdateCheck && !isVersionCheck { + // Print the result of the background check started in + // PersistentPreRunE, if one was launched. Re-check cfg.VersionCheck + // in case the command itself toggled it off (e.g. + // `tiger config set version_check false`). + if versionCheckCh != nil && cfg.VersionCheck { output := cmd.ErrOrStderr() - result := version.PerformCheck(cfg, &output, false) - version.PrintUpdateWarning(result, cfg, &output) + version.PrintUpdateWarning(<-versionCheckCh, cfg, &output) } logging.Sync() @@ -114,6 +140,7 @@ tiger auth login // Add all subcommands cmd.AddCommand(buildVersionCmd()) + cmd.AddCommand(buildUpgradeCmd()) cmd.AddCommand(buildConfigCmd()) cmd.AddCommand(buildAuthCmd()) cmd.AddCommand(buildServiceCmd()) diff --git a/internal/tiger/cmd/service_test.go b/internal/tiger/cmd/service_test.go index d1d7e73d..b1cae243 100644 --- a/internal/tiger/cmd/service_test.go +++ b/internal/tiger/cmd/service_test.go @@ -1547,9 +1547,9 @@ func TestServiceList_OutputFlagAffectsCommandOnly(t *testing.T) { // Set up config with output format explicitly set to "table" cfg, err := config.UseTestConfig(tmpDir, map[string]any{ - "api_url": "http://localhost:9999", - "output": "table", - "version_check_interval": 0, + "api_url": "http://localhost:9999", + "output": "table", + "version_check": false, }) if err != nil { t.Fatalf("Failed to setup test config: %v", err) diff --git a/internal/tiger/cmd/upgrade.go b/internal/tiger/cmd/upgrade.go new file mode 100644 index 00000000..7914128c --- /dev/null +++ b/internal/tiger/cmd/upgrade.go @@ -0,0 +1,547 @@ +package cmd + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/spf13/cobra" + + "github.com/timescale/tiger-cli/internal/tiger/config" + "github.com/timescale/tiger-cli/internal/tiger/version" +) + +// upgradeHTTPClient is used to download release archives and checksums. It uses +// a generous timeout because release archives are much larger than the small +// latest.txt fetch performed by the version package's 5s client. +var upgradeHTTPClient = &http.Client{Timeout: 3 * time.Minute} + +// binaryFilename is the name of the tiger executable inside a release archive +// (and on disk), with the platform-appropriate extension. Note that the binary +// is named "tiger" even though the release archive is prefixed "tiger-cli". +func binaryFilename() string { + if runtime.GOOS == "windows" { + return "tiger.exe" + } + return "tiger" +} + +// normalizeTag returns a version string with exactly one leading "v", matching +// the release tag used in CDN download paths (e.g. releases/v1.2.3/). +func normalizeTag(v string) string { + return "v" + strings.TrimPrefix(v, "v") +} + +func buildUpgradeCmd() *cobra.Command { + var force bool + var requestedVersion string + + cmd := &cobra.Command{ + Use: "upgrade", + Aliases: []string{"update"}, + Short: "Upgrade the Tiger CLI to the latest version", + Long: `Download and install the latest published version of the Tiger CLI, replacing the currently running binary. + +If Tiger CLI was installed via a package manager (Homebrew, apt, yum/dnf), the upgrade will be refused with a suggestion to use that package manager instead.`, + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runUpgrade(cmd, requestedVersion, force) + }, + } + + cmd.Flags().StringVar(&requestedVersion, "version", "", "specific version to install (e.g. v1.2.3). Defaults to latest.") + if err := cmd.Flags().MarkHidden("version"); err != nil { + panic(err) + } + cmd.Flags().BoolVar(&force, "force", false, "reinstall even if the current version already matches, or the binary was installed via a package manager") + if err := cmd.Flags().MarkHidden("force"); err != nil { + panic(err) + } + + return cmd +} + +func runUpgrade(cmd *cobra.Command, requestedVersion string, force bool) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + ctx := cmd.Context() + releasesURL := strings.TrimRight(cfg.ReleasesURL, "/") + + // Validate the --version argument up front so we fail fast on bad input + // without doing any network work. + if requestedVersion != "" { + if _, err := semver.NewVersion(requestedVersion); err != nil { + return fmt.Errorf("invalid version %q: must be a valid semver version (e.g. v1.2.3)", requestedVersion) + } + } + + currentBinaryPath, err := resolveCurrentBinaryPath() + if err != nil { + return err + } + + result, err := version.CheckForUpdate(cfg) + if err != nil { + return fmt.Errorf("failed to check for latest version: %w", err) + } + + currentVersion := result.CurrentVersion + targetTag := normalizeTag(result.LatestVersion) + if requestedVersion != "" { + targetTag = normalizeTag(requestedVersion) + } + + // Package-manager-installed binaries should be upgraded via the package manager. + switch result.InstallMethod { + case version.InstallMethodHomebrew, version.InstallMethodDeb, version.InstallMethodRPM: + if !force { + return fmt.Errorf("tiger appears to have been installed via %s; upgrade it with:\n %s", + result.InstallMethod, result.UpdateCommand) + } + cmd.PrintErrf("Warning: tiger appears to have been installed via %s; overwriting from release archive because --force was set\n", result.InstallMethod) + } + + // Dev builds are typically local, unreleased builds; replacing one with a + // release archive is almost always surprising, so require --force. + if (currentVersion == "dev" || currentVersion == "unknown" || result.InstallMethod == version.InstallMethodDevelopment) && !force { + return fmt.Errorf("tiger is a local dev build, not a released version; re-run with --force to replace it with version %s", targetTag) + } + + if !force { + if cur, curErr := semver.NewVersion(currentVersion); curErr == nil { + if tgt, tgtErr := semver.NewVersion(targetTag); tgtErr == nil && cur.Equal(tgt) { + cmd.Printf("tiger is already at version %s\n", currentVersion) + return nil + } + } + } + + // Verify we can write to the install location before downloading anything. + if err := checkCanReplaceBinary(currentBinaryPath); err != nil { + return err + } + + archiveFilename, archiveIsZip, err := buildReleaseArchiveName() + if err != nil { + return err + } + + tmpDir, err := os.MkdirTemp("", "tiger-upgrade-*") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer func() { + if removeErr := os.RemoveAll(tmpDir); removeErr != nil { + cmd.PrintErrf("Warning: failed to clean up temp directory %s: %v\n", tmpDir, removeErr) + } + }() + + archivePath := filepath.Join(tmpDir, archiveFilename) + archiveURL := fmt.Sprintf("%s/releases/%s/%s", releasesURL, targetTag, archiveFilename) + checksumURL := archiveURL + ".sha256" + + cmd.Printf("Upgrading tiger %s → %s\n", currentVersion, targetTag) + cmd.Printf("Downloading %s\n", archiveURL) + if err := downloadFile(ctx, archiveURL, archivePath); err != nil { + return fmt.Errorf("failed to download release archive: %w", err) + } + + cmd.Println("Verifying checksum") + expectedChecksum, err := fetchSHA256Checksum(ctx, checksumURL) + if err != nil { + return fmt.Errorf("failed to fetch checksum: %w", err) + } + if err := verifyFileSHA256(archivePath, expectedChecksum); err != nil { + return err + } + + extractedBinaryPath, err := extractBinaryFromArchive(archivePath, archiveIsZip, binaryFilename()) + if err != nil { + return fmt.Errorf("failed to extract archive: %w", err) + } + + cmd.Printf("Installing new binary to %s\n", currentBinaryPath) + if err := replaceRunningBinary(currentBinaryPath, extractedBinaryPath); err != nil { + return err + } + + cmd.Printf("tiger upgraded successfully to %s\n", targetTag) + return nil +} + +// resolveCurrentBinaryPath returns the absolute path of the running binary, +// resolving any symlinks so that upgrades target the actual file rather than +// replacing a symlink. +func resolveCurrentBinaryPath() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", fmt.Errorf("failed to determine current binary path: %w", err) + } + resolved, err := filepath.EvalSymlinks(exe) + if err != nil { + // Fall back to the un-resolved path; EvalSymlinks can fail in edge + // cases (e.g. on some Windows package paths) and we'd still like to + // attempt the upgrade. + return exe, nil + } + return resolved, nil +} + +// buildReleaseArchiveName computes the filename of the release archive for the +// current platform, matching the naming scheme produced by GoReleaser and +// consumed by scripts/install.sh / install.ps1. +// +// The archive is prefixed with the project name ("tiger-cli"), which differs +// from the binary name inside the archive ("tiger"). +// +// The second return value is true for zip archives (Windows) and false for +// tar.gz archives (Linux/macOS). +func buildReleaseArchiveName() (string, bool, error) { + var osLabel string + switch runtime.GOOS { + case "linux": + osLabel = "Linux" + case "darwin": + osLabel = "Darwin" + case "windows": + osLabel = "Windows" + default: + return "", false, fmt.Errorf("unsupported operating system: %s", runtime.GOOS) + } + + var archLabel string + switch runtime.GOARCH { + case "amd64": + archLabel = "x86_64" + case "arm64": + archLabel = "arm64" + case "386": + archLabel = "i386" + case "arm": + archLabel = "armv7" + default: + return "", false, fmt.Errorf("unsupported architecture: %s", runtime.GOARCH) + } + + if runtime.GOOS == "windows" { + return fmt.Sprintf("tiger-cli_%s_%s.zip", osLabel, archLabel), true, nil + } + return fmt.Sprintf("tiger-cli_%s_%s.tar.gz", osLabel, archLabel), false, nil +} + +// checkCanReplaceBinary verifies that the process can create files in the +// directory containing the currently running binary, so we fail fast rather +// than downloading a release archive only to discover we lack permission. +func checkCanReplaceBinary(currentBinaryPath string) error { + parentDir := filepath.Dir(currentBinaryPath) + probe, err := os.CreateTemp(parentDir, ".tiger-upgrade-writecheck-*") + if err != nil { + return fmt.Errorf("cannot write to %s (where tiger is installed): %w\nConsider re-running with elevated privileges, or upgrading via the install method originally used", parentDir, err) + } + probePath := probe.Name() + defer os.Remove(probePath) + defer probe.Close() + + if err := probe.Close(); err != nil { + return fmt.Errorf("failed to close write-check probe file %s: %w", probePath, err) + } + if err := os.Remove(probePath); err != nil { + return fmt.Errorf("failed to remove write-check probe file %s: %w", probePath, err) + } + return nil +} + +// downloadFile downloads the content at url into outputPath. +func downloadFile(ctx context.Context, url, outputPath string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := upgradeHTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code %d for %s", resp.StatusCode, url) + } + + out, err := os.Create(outputPath) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, resp.Body); err != nil { + return err + } + if err := out.Close(); err != nil { + return fmt.Errorf("failed to close %s: %w", outputPath, err) + } + if err := resp.Body.Close(); err != nil { + return fmt.Errorf("failed to close response body: %w", err) + } + return nil +} + +// fetchSHA256Checksum fetches a .sha256 file and returns the hex digest. +// GoReleaser's per-artifact checksum files contain either just the hex digest +// or " " — we accept either. +func fetchSHA256Checksum(ctx context.Context, url string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + resp, err := upgradeHTTPClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status code %d for %s", resp.StatusCode, url) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + fields := strings.Fields(string(body)) + if len(fields) == 0 { + return "", errors.New("checksum file is empty") + } + if err := resp.Body.Close(); err != nil { + return "", fmt.Errorf("failed to close response body: %w", err) + } + return fields[0], nil +} + +// verifyFileSHA256 computes the SHA-256 of filePath and compares with expectedHex. +func verifyFileSHA256(filePath, expectedHex string) error { + file, err := os.Open(filePath) + if err != nil { + return err + } + defer file.Close() + + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + return err + } + actualHex := hex.EncodeToString(hasher.Sum(nil)) + if !strings.EqualFold(actualHex, expectedHex) { + return fmt.Errorf("checksum mismatch for %s: expected %s, got %s", filepath.Base(filePath), expectedHex, actualHex) + } + return file.Close() +} + +// extractBinaryFromArchive extracts the named binary out of a release archive +// into the archive's parent directory, returning the path to the extracted +// file. +func extractBinaryFromArchive(archivePath string, isZip bool, binaryName string) (string, error) { + destPath := filepath.Join(filepath.Dir(archivePath), binaryName) + if isZip { + return destPath, extractBinaryFromZip(archivePath, binaryName, destPath) + } + return destPath, extractBinaryFromTarGz(archivePath, binaryName, destPath) +} + +func extractBinaryFromTarGz(archivePath, binaryName, destPath string) error { + file, err := os.Open(archivePath) + if err != nil { + return err + } + defer file.Close() + + gzReader, err := gzip.NewReader(file) + if err != nil { + return err + } + defer gzReader.Close() + + tarReader := tar.NewReader(gzReader) + for { + header, err := tarReader.Next() + if errors.Is(err, io.EOF) { + return fmt.Errorf("binary %q not found in archive", binaryName) + } + if err != nil { + return err + } + if header.Typeflag != tar.TypeReg || filepath.Base(header.Name) != binaryName { + continue + } + if err := writeExecutableFile(destPath, tarReader); err != nil { + return err + } + if err := gzReader.Close(); err != nil { + return fmt.Errorf("failed to close gzip reader: %w", err) + } + return file.Close() + } +} + +func extractBinaryFromZip(archivePath, binaryName, destPath string) error { + reader, err := zip.OpenReader(archivePath) + if err != nil { + return err + } + defer reader.Close() + + for _, file := range reader.File { + if filepath.Base(file.Name) != binaryName { + continue + } + if err := copyZipEntryToFile(file, destPath); err != nil { + return err + } + return reader.Close() + } + return fmt.Errorf("binary %q not found in archive", binaryName) +} + +// copyZipEntryToFile copies the contents of a zip entry into a new executable +// file at destPath. +func copyZipEntryToFile(entry *zip.File, destPath string) error { + rc, err := entry.Open() + if err != nil { + return err + } + defer rc.Close() + if err := writeExecutableFile(destPath, rc); err != nil { + return err + } + if err := rc.Close(); err != nil { + return fmt.Errorf("failed to close zip entry: %w", err) + } + return nil +} + +func writeExecutableFile(destPath string, src io.Reader) error { + out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755) + if err != nil { + return err + } + defer out.Close() + if _, err := io.Copy(out, src); err != nil { + return err + } + if err := out.Close(); err != nil { + return fmt.Errorf("failed to close %s: %w", destPath, err) + } + return nil +} + +// replaceRunningBinary replaces the currently executing binary at +// currentBinaryPath with the file at newBinaryPath. +// +// On Linux and macOS the running binary can be overwritten directly because +// the kernel keeps the inode alive while the process runs; an atomic rename +// onto the existing path is safe. +// +// On Windows a running executable cannot be deleted or overwritten, but it +// can be renamed, so we move the existing binary aside (to tiger.exe.old.) +// before installing the new one. Any accumulated .old.* files from previous +// upgrades are cleaned up opportunistically. +func replaceRunningBinary(currentBinaryPath, newBinaryPath string) error { + targetDir := filepath.Dir(currentBinaryPath) + + // Stage the new binary in the same directory so the final rename stays + // on the same filesystem (i.e. is atomic on POSIX). + stagedFile, err := os.CreateTemp(targetDir, ".tiger-upgrade-staged-*") + if err != nil { + return fmt.Errorf("failed to stage new binary in %s: %w", targetDir, err) + } + stagedPath := stagedFile.Name() + // If stagedPath is successfully renamed into place, the deferred Remove + // becomes a no-op (ENOENT), which we ignore in the defer. Close is + // deferred as a best-effort safety net on early-return paths; the explicit + // Close below propagates any real error. + defer os.Remove(stagedPath) + defer stagedFile.Close() + + if err := copyFileContents(stagedFile, newBinaryPath); err != nil { + return err + } + if err := stagedFile.Chmod(0o755); err != nil { + return err + } + if err := stagedFile.Close(); err != nil { + return fmt.Errorf("failed to close staged binary %s: %w", stagedPath, err) + } + + if runtime.GOOS == "windows" { + cleanupStaleOldBinaries(currentBinaryPath) + + oldPath := fmt.Sprintf("%s.old.%d", currentBinaryPath, time.Now().UnixNano()) + if err := os.Rename(currentBinaryPath, oldPath); err != nil { + return fmt.Errorf("failed to move existing binary aside: %w", err) + } + if err := os.Rename(stagedPath, currentBinaryPath); err != nil { + // Try to restore the original so we don't leave the install broken. + if rollbackErr := os.Rename(oldPath, currentBinaryPath); rollbackErr != nil { + return fmt.Errorf("failed to install new binary (%w) and failed to restore original from %s: %v", err, oldPath, rollbackErr) + } + return fmt.Errorf("failed to install new binary: %w", err) + } + // oldPath remains on disk; Windows holds the file open until the + // current process exits, after which the next upgrade invocation can + // clean it up. + return nil + } + + if err := os.Rename(stagedPath, currentBinaryPath); err != nil { + return fmt.Errorf("failed to install new binary: %w", err) + } + return nil +} + +// copyFileContents copies the contents of srcPath into dest (an already-open file). +func copyFileContents(dest *os.File, srcPath string) error { + src, err := os.Open(srcPath) + if err != nil { + return err + } + defer src.Close() + if _, err := io.Copy(dest, src); err != nil { + return err + } + return src.Close() +} + +// cleanupStaleOldBinaries removes leftover tiger.exe.old.* files from previous +// Windows upgrades. Files still held open by another process will silently fail +// to delete; that's fine — they'll be cleaned up on a future invocation, so +// Remove errors are intentionally not propagated. +func cleanupStaleOldBinaries(currentBinaryPath string) { + // filepath.Glob only returns an error for a malformed pattern, which is + // a programmer error — the pattern we build here is always well-formed. + matches, err := filepath.Glob(currentBinaryPath + ".old.*") + if err != nil { + return + } + for _, match := range matches { + // Best-effort: failure (usually because the file is still locked by + // another running tiger process) is expected and harmless. + os.Remove(match) //nolint:errcheck // documented best-effort cleanup + } +} diff --git a/internal/tiger/cmd/upgrade_test.go b/internal/tiger/cmd/upgrade_test.go new file mode 100644 index 00000000..af81d3ec --- /dev/null +++ b/internal/tiger/cmd/upgrade_test.go @@ -0,0 +1,334 @@ +package cmd + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/timescale/tiger-cli/internal/tiger/config" +) + +// setupUpgradeTest creates a temp config dir whose config.yaml points +// releases_url at the given URL, and returns the dir. Mirrors the env handling +// used elsewhere (TIGER_CONFIG_DIR + TIGER_ANALYTICS) so the analytics +// middleware and version-check post-run hook stay inert during tests. +func setupUpgradeTest(t *testing.T, releasesURL string) string { + t.Helper() + config.SetTestServiceName(t) + + tmpDir, err := os.MkdirTemp("", "tiger-test-upgrade-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + configContent := fmt.Sprintf("releases_url: %s\nanalytics: false\n", releasesURL) + if err := os.WriteFile(config.GetConfigFile(tmpDir), []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + t.Setenv("TIGER_ANALYTICS", "false") + t.Cleanup(func() { + os.RemoveAll(tmpDir) + config.ResetGlobalConfig() + }) + + return tmpDir +} + +func executeUpgradeCommand(ctx context.Context, configDir string, args ...string) (string, error) { + testRoot, err := buildRootCmd(ctx) + if err != nil { + return "", err + } + + buf := new(bytes.Buffer) + testRoot.SetOut(buf) + testRoot.SetErr(buf) + testRoot.SetArgs(append([]string{"--config-dir", configDir}, args...)) + + err = testRoot.Execute() + return buf.String(), err +} + +// startFakeReleasesServer mimics the release hosting used by scripts/install.sh +// (latest.txt). Only latest.txt is served; anything else returns 404. +func startFakeReleasesServer(t *testing.T, latestVersion string) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("GET /latest.txt", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + if _, err := w.Write([]byte(latestVersion + "\n")); err != nil { + t.Errorf("failed to write latest.txt response: %v", err) + } + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + return server +} + +func TestUpgradeCmd(t *testing.T) { + releasesServer := startFakeReleasesServer(t, "v99.99.99") + + t.Run("rejects invalid --version", func(t *testing.T) { + configDir := setupUpgradeTest(t, releasesServer.URL) + _, err := executeUpgradeCommand(t.Context(), configDir, "upgrade", "--version", "not-a-version") + wantErr := `invalid version "not-a-version": must be a valid semver version (e.g. v1.2.3)` + if err == nil || err.Error() != wantErr { + t.Errorf("got error %v, want %q", err, wantErr) + } + }) + + t.Run("update alias rejects invalid --version", func(t *testing.T) { + configDir := setupUpgradeTest(t, releasesServer.URL) + _, err := executeUpgradeCommand(t.Context(), configDir, "update", "--version", "nope") + wantErr := `invalid version "nope": must be a valid semver version (e.g. v1.2.3)` + if err == nil || err.Error() != wantErr { + t.Errorf("got error %v, want %q", err, wantErr) + } + }) + + t.Run("refuses dev build without --force", func(t *testing.T) { + // config.Version is "dev" in tests, so every invocation without --force + // exercises the dev-build guard. + configDir := setupUpgradeTest(t, releasesServer.URL) + _, err := executeUpgradeCommand(t.Context(), configDir, "upgrade") + wantErr := "tiger is a local dev build, not a released version; re-run with --force to replace it with version v99.99.99" + if err == nil || err.Error() != wantErr { + t.Errorf("got error %v, want %q", err, wantErr) + } + }) + + t.Run("fails when latest version cannot be fetched", func(t *testing.T) { + // Error from a network failure is non-deterministic (depends on the net + // stack's exact wording), so we assert only the stable wrapping prefix. + configDir := setupUpgradeTest(t, "http://127.0.0.1:1") + _, err := executeUpgradeCommand(t.Context(), configDir, "upgrade") + if err == nil { + t.Fatal("expected error, got nil") + } + const wantPrefix = "failed to check for latest version: " + if !strings.HasPrefix(err.Error(), wantPrefix) { + t.Errorf("unexpected error: %v (want prefix %q)", err, wantPrefix) + } + }) +} + +func TestNormalizeTag(t *testing.T) { + cases := map[string]string{ + "1.2.3": "v1.2.3", + "v1.2.3": "v1.2.3", + "v0.0.1": "v0.0.1", + } + for in, want := range cases { + if got := normalizeTag(in); got != want { + t.Errorf("normalizeTag(%q) = %q, want %q", in, got, want) + } + } +} + +func TestBuildReleaseArchiveName(t *testing.T) { + name, isZip, err := buildReleaseArchiveName() + if err != nil { + // Unsupported platform in CI is acceptable; just ensure it's the + // expected kind of failure rather than a panic. + t.Skipf("unsupported platform for this test: %v", err) + } + + if !strings.HasPrefix(name, "tiger-cli_") { + t.Errorf("archive name %q does not start with project prefix \"tiger-cli_\"", name) + } + if runtime.GOOS == "windows" { + if !isZip || !strings.HasSuffix(name, ".zip") { + t.Errorf("expected zip archive on windows, got %q (isZip=%v)", name, isZip) + } + } else { + if isZip || !strings.HasSuffix(name, ".tar.gz") { + t.Errorf("expected tar.gz archive, got %q (isZip=%v)", name, isZip) + } + } +} + +func TestBinaryFilename(t *testing.T) { + got := binaryFilename() + want := "tiger" + if runtime.GOOS == "windows" { + want = "tiger.exe" + } + if got != want { + t.Errorf("binaryFilename() = %q, want %q", got, want) + } +} + +// makeTarGz writes a gzipped tarball at path containing a single regular file +// named entryName with the given contents. +func makeTarGz(t *testing.T, path, entryName string, contents []byte) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatalf("create archive: %v", err) + } + defer f.Close() + + gw := gzip.NewWriter(f) + tw := tar.NewWriter(gw) + hdr := &tar.Header{ + Name: entryName, + Mode: 0o755, + Size: int64(len(contents)), + Typeflag: tar.TypeReg, + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatalf("write tar header: %v", err) + } + if _, err := tw.Write(contents); err != nil { + t.Fatalf("write tar body: %v", err) + } + if err := tw.Close(); err != nil { + t.Fatalf("close tar: %v", err) + } + if err := gw.Close(); err != nil { + t.Fatalf("close gzip: %v", err) + } +} + +func TestExtractBinaryFromArchive(t *testing.T) { + tmpDir := t.TempDir() + archivePath := filepath.Join(tmpDir, "tiger-cli_Test_x86_64.tar.gz") + want := []byte("#!/bin/sh\necho fake tiger\n") + makeTarGz(t, archivePath, "tiger", want) + + extracted, err := extractBinaryFromArchive(archivePath, false, "tiger") + if err != nil { + t.Fatalf("extractBinaryFromArchive: %v", err) + } + + got, err := os.ReadFile(extracted) + if err != nil { + t.Fatalf("read extracted binary: %v", err) + } + if !bytes.Equal(got, want) { + t.Errorf("extracted contents = %q, want %q", got, want) + } + + t.Run("missing binary errors", func(t *testing.T) { + if _, err := extractBinaryFromArchive(archivePath, false, "nonexistent"); err == nil { + t.Error("expected error for missing binary, got nil") + } + }) +} + +func TestVerifyFileSHA256(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "blob") + contents := []byte("hello tiger") + if err := os.WriteFile(path, contents, 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + sum := sha256.Sum256(contents) + hexSum := hex.EncodeToString(sum[:]) + + if err := verifyFileSHA256(path, hexSum); err != nil { + t.Errorf("verifyFileSHA256 with correct checksum: %v", err) + } + // Case-insensitive match should also succeed. + if err := verifyFileSHA256(path, strings.ToUpper(hexSum)); err != nil { + t.Errorf("verifyFileSHA256 with uppercase checksum: %v", err) + } + if err := verifyFileSHA256(path, "deadbeef"); err == nil { + t.Error("expected checksum mismatch error, got nil") + } +} + +func TestFetchSHA256Checksum(t *testing.T) { + mux := http.NewServeMux() + // Bare digest. + mux.HandleFunc("GET /bare.sha256", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "abc123") + }) + // " " form. + mux.HandleFunc("GET /withname.sha256", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "abc123 tiger-cli_Linux_x86_64.tar.gz") + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + for _, name := range []string{"bare", "withname"} { + got, err := fetchSHA256Checksum(t.Context(), server.URL+"/"+name+".sha256") + if err != nil { + t.Fatalf("fetchSHA256Checksum(%s): %v", name, err) + } + if got != "abc123" { + t.Errorf("fetchSHA256Checksum(%s) = %q, want %q", name, got, "abc123") + } + } + + if _, err := fetchSHA256Checksum(t.Context(), server.URL+"/missing.sha256"); err == nil { + t.Error("expected error for 404 checksum, got nil") + } +} + +func TestReplaceRunningBinary(t *testing.T) { + tmpDir := t.TempDir() + + // A stand-in for the currently running binary (never the real test binary). + currentPath := filepath.Join(tmpDir, "tiger") + if err := os.WriteFile(currentPath, []byte("old binary"), 0o755); err != nil { + t.Fatalf("write current binary: %v", err) + } + + // The freshly extracted replacement. + newPath := filepath.Join(tmpDir, "extracted-tiger") + newContents := []byte("new binary") + if err := os.WriteFile(newPath, newContents, 0o755); err != nil { + t.Fatalf("write new binary: %v", err) + } + + if err := replaceRunningBinary(currentPath, newPath); err != nil { + t.Fatalf("replaceRunningBinary: %v", err) + } + + got, err := os.ReadFile(currentPath) + if err != nil { + t.Fatalf("read replaced binary: %v", err) + } + if !bytes.Equal(got, newContents) { + t.Errorf("replaced contents = %q, want %q", got, newContents) + } + + info, err := os.Stat(currentPath) + if err != nil { + t.Fatalf("stat replaced binary: %v", err) + } + if runtime.GOOS != "windows" && info.Mode().Perm()&0o100 == 0 { + t.Errorf("replaced binary is not executable: mode %v", info.Mode()) + } +} + +func TestCheckCanReplaceBinary(t *testing.T) { + tmpDir := t.TempDir() + binPath := filepath.Join(tmpDir, "tiger") + if err := os.WriteFile(binPath, []byte("x"), 0o755); err != nil { + t.Fatalf("write binary: %v", err) + } + + if err := checkCanReplaceBinary(binPath); err != nil { + t.Errorf("checkCanReplaceBinary on writable dir: %v", err) + } + + // Probe files must not be left behind. + leftovers, _ := filepath.Glob(filepath.Join(tmpDir, ".tiger-upgrade-writecheck-*")) + if len(leftovers) != 0 { + t.Errorf("write-check probe files left behind: %v", leftovers) + } +} diff --git a/internal/tiger/cmd/version.go b/internal/tiger/cmd/version.go index 21ab6b02..1da28632 100644 --- a/internal/tiger/cmd/version.go +++ b/internal/tiger/cmd/version.go @@ -50,7 +50,12 @@ func buildVersionCmd() *cobra.Command { if err != nil { return fmt.Errorf("Error loading config: %w", err) } - if result := version.PerformCheck(cfg, util.Ptr(cmd.ErrOrStderr()), true); result != nil { + result, err := version.CheckForUpdate(cfg) + if err != nil { + // A failed check shouldn't fail the version command; warn and + // continue printing the local version info. + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to check for updates: %v\n", err) + } else if result != nil { versionOutput.LatestVersion = result.LatestVersion versionOutput.UpdateAvailable = &result.UpdateAvailable updateAvailable = result.UpdateAvailable diff --git a/internal/tiger/config/config.go b/internal/tiger/config/config.go index d6a4c29a..9bbcad1f 100644 --- a/internal/tiger/config/config.go +++ b/internal/tiger/config/config.go @@ -9,9 +9,7 @@ import ( "path/filepath" "slices" "strconv" - "time" - "github.com/go-viper/mapstructure/v2" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -19,59 +17,57 @@ import ( ) type Config struct { - APIURL string `mapstructure:"api_url"` - Analytics bool `mapstructure:"analytics"` - Color bool `mapstructure:"color"` - ConfigDir string `mapstructure:"config_dir"` - ConsoleURL string `mapstructure:"console_url"` - Debug bool `mapstructure:"debug"` - DocsMCP bool `mapstructure:"docs_mcp"` - DocsMCPURL string `mapstructure:"docs_mcp_url"` - GatewayURL string `mapstructure:"gateway_url"` - Output string `mapstructure:"output"` - PasswordStorage string `mapstructure:"password_storage"` - ReadOnly bool `mapstructure:"read_only"` - ReleasesURL string `mapstructure:"releases_url"` - ServiceID string `mapstructure:"service_id"` - VersionCheckInterval time.Duration `mapstructure:"version_check_interval"` - VersionCheckLastTime time.Time `mapstructure:"version_check_last_time"` - viper *viper.Viper `mapstructure:"-"` + APIURL string `mapstructure:"api_url"` + Analytics bool `mapstructure:"analytics"` + Color bool `mapstructure:"color"` + ConfigDir string `mapstructure:"config_dir"` + ConsoleURL string `mapstructure:"console_url"` + Debug bool `mapstructure:"debug"` + DocsMCP bool `mapstructure:"docs_mcp"` + DocsMCPURL string `mapstructure:"docs_mcp_url"` + GatewayURL string `mapstructure:"gateway_url"` + Output string `mapstructure:"output"` + PasswordStorage string `mapstructure:"password_storage"` + ReadOnly bool `mapstructure:"read_only"` + ReleasesURL string `mapstructure:"releases_url"` + ServiceID string `mapstructure:"service_id"` + VersionCheck bool `mapstructure:"version_check"` + viper *viper.Viper `mapstructure:"-"` } type ConfigOutput struct { - APIURL *string `mapstructure:"api_url" json:"api_url,omitempty"` - Analytics *bool `mapstructure:"analytics" json:"analytics,omitempty"` - Color *bool `mapstructure:"color" json:"color,omitempty"` - ConfigDir *string `mapstructure:"config_dir" json:"config_dir,omitempty"` - ConsoleURL *string `mapstructure:"console_url" json:"console_url,omitempty"` - Debug *bool `mapstructure:"debug" json:"debug,omitempty"` - DocsMCP *bool `mapstructure:"docs_mcp" json:"docs_mcp,omitempty"` - DocsMCPURL *string `mapstructure:"docs_mcp_url" json:"docs_mcp_url,omitempty"` - GatewayURL *string `mapstructure:"gateway_url" json:"gateway_url,omitempty"` - Output *string `mapstructure:"output" json:"output,omitempty"` - PasswordStorage *string `mapstructure:"password_storage" json:"password_storage,omitempty"` - ReadOnly *bool `mapstructure:"read_only" json:"read_only,omitempty"` - ReleasesURL *string `mapstructure:"releases_url" json:"releases_url,omitempty"` - ServiceID *string `mapstructure:"service_id" json:"service_id,omitempty"` - VersionCheckInterval *util.Duration `mapstructure:"version_check_interval" json:"version_check_interval,omitempty"` // [util.Duration] ensures value is marshaled in [time.Duration.String] format when output - VersionCheckLastTime *time.Time `mapstructure:"version_check_last_time" json:"version_check_last_time,omitempty"` + APIURL *string `mapstructure:"api_url" json:"api_url,omitempty"` + Analytics *bool `mapstructure:"analytics" json:"analytics,omitempty"` + Color *bool `mapstructure:"color" json:"color,omitempty"` + ConfigDir *string `mapstructure:"config_dir" json:"config_dir,omitempty"` + ConsoleURL *string `mapstructure:"console_url" json:"console_url,omitempty"` + Debug *bool `mapstructure:"debug" json:"debug,omitempty"` + DocsMCP *bool `mapstructure:"docs_mcp" json:"docs_mcp,omitempty"` + DocsMCPURL *string `mapstructure:"docs_mcp_url" json:"docs_mcp_url,omitempty"` + GatewayURL *string `mapstructure:"gateway_url" json:"gateway_url,omitempty"` + Output *string `mapstructure:"output" json:"output,omitempty"` + PasswordStorage *string `mapstructure:"password_storage" json:"password_storage,omitempty"` + ReadOnly *bool `mapstructure:"read_only" json:"read_only,omitempty"` + ReleasesURL *string `mapstructure:"releases_url" json:"releases_url,omitempty"` + ServiceID *string `mapstructure:"service_id" json:"service_id,omitempty"` + VersionCheck *bool `mapstructure:"version_check" json:"version_check,omitempty"` } const ( - ConfigFileName = "config.yaml" - DefaultAPIURL = "https://console.cloud.tigerdata.com/public/api/v1" - DefaultAnalytics = true - DefaultColor = true - DefaultConsoleURL = "https://console.cloud.tigerdata.com" - DefaultDebug = false - DefaultDocsMCP = true - DefaultDocsMCPURL = "https://mcp.tigerdata.com/docs" - DefaultGatewayURL = "https://console.cloud.tigerdata.com/api" - DefaultOutput = "table" - DefaultPasswordStorage = "keyring" - DefaultReadOnly = false - DefaultReleasesURL = "https://cli.tigerdata.com" - DefaultVersionCheckInterval = 24 * time.Hour + ConfigFileName = "config.yaml" + DefaultAPIURL = "https://console.cloud.tigerdata.com/public/api/v1" + DefaultAnalytics = true + DefaultColor = true + DefaultConsoleURL = "https://console.cloud.tigerdata.com" + DefaultDebug = false + DefaultDocsMCP = true + DefaultDocsMCPURL = "https://mcp.tigerdata.com/docs" + DefaultGatewayURL = "https://console.cloud.tigerdata.com/api" + DefaultOutput = "table" + DefaultPasswordStorage = "keyring" + DefaultReadOnly = false + DefaultReleasesURL = "https://cli.tigerdata.com" + DefaultVersionCheck = true // TigerCLIClientID is the OAuth client identifier registered with // savannah-gateway's /idp/external/cli/token endpoint. Used for both the @@ -80,21 +76,20 @@ const ( ) var defaultValues = map[string]any{ - "analytics": DefaultAnalytics, - "api_url": DefaultAPIURL, - "color": DefaultColor, - "console_url": DefaultConsoleURL, - "debug": DefaultDebug, - "docs_mcp": DefaultDocsMCP, - "docs_mcp_url": DefaultDocsMCPURL, - "gateway_url": DefaultGatewayURL, - "output": DefaultOutput, - "password_storage": DefaultPasswordStorage, - "read_only": DefaultReadOnly, - "releases_url": DefaultReleasesURL, - "service_id": "", - "version_check_interval": DefaultVersionCheckInterval.String(), // String can be interpreted as either [time.Duration] (for [Config]) or [util.Duration] (for [ConfigOutput]) - "version_check_last_time": time.Time{}, + "analytics": DefaultAnalytics, + "api_url": DefaultAPIURL, + "color": DefaultColor, + "console_url": DefaultConsoleURL, + "debug": DefaultDebug, + "docs_mcp": DefaultDocsMCP, + "docs_mcp_url": DefaultDocsMCPURL, + "gateway_url": DefaultGatewayURL, + "output": DefaultOutput, + "password_storage": DefaultPasswordStorage, + "read_only": DefaultReadOnly, + "releases_url": DefaultReleasesURL, + "service_id": "", + "version_check": DefaultVersionCheck, } func ValidConfigOptions() []string { @@ -137,7 +132,31 @@ func SetupViper(configDir string) error { // Set defaults for all config values ApplyDefaults(v) - return ReadInConfig(v) + if err := ReadInConfig(v); err != nil { + return err + } + + MigrateVersionCheck(v) + return nil +} + +// MigrateVersionCheck preserves backward compatibility with configs written by +// older CLI versions, which used a `version_check_interval` duration (0 to +// disable) instead of the current `version_check` bool. If a pre-existing +// config file set the old key and not the new one, we derive the new value +// from it (0 → false, any non-zero interval → true) so a user who had disabled +// update checks doesn't have them silently re-enabled on upgrade. +// +// The derived value is applied via SetDefault, so an explicit `version_check` +// from the config file or a TIGER_VERSION_CHECK env var still takes precedence. +// It must be called after ApplyDefaults so the derived value overrides the +// generic default, and after ReadInConfig so InConfig can see the file keys. +// This is an in-memory shim only; the old key remains in the file until it is +// rewritten (e.g. via `tiger config set`/`unset`). +func MigrateVersionCheck(v *viper.Viper) { + if v.InConfig("version_check_interval") && !v.InConfig("version_check") { + v.SetDefault("version_check", v.GetDuration("version_check_interval") != 0) + } } func FromViper(v *viper.Viper) (*Config, error) { @@ -159,10 +178,7 @@ func ForOutputFromViper(v *viper.Viper) (*ConfigOutput, error) { ConfigDir: &configDir, } - if err := v.Unmarshal(cfg, - // Decode hook allows us to unmarshal a string into a [util.Duration] for the sake of VersionCheckInterval - viper.DecodeHook(mapstructure.TextUnmarshallerHookFunc()), - ); err != nil { + if err := v.Unmarshal(cfg); err != nil { return nil, fmt.Errorf("error unmarshaling config for output: %w", err) } @@ -423,56 +439,20 @@ func (c *Config) UpdateField(key string, value any) (any, error) { c.ReleasesURL = s validated = s - case "version_check_interval": - switch v := value.(type) { - case time.Duration: - if v < 0 { - return nil, fmt.Errorf("version_check_interval must be non-negative (0 to disable)") - } - c.VersionCheckInterval = v - validated = v - case string: - // Parse duration string like "1h", "30m", "24h" - d, err := time.ParseDuration(v) - if err != nil { - return nil, fmt.Errorf("invalid version_check_interval value: %s (must be a duration like '1h', '30m', etc.)", v) - } - if d < 0 { - return nil, fmt.Errorf("version_check_interval must be non-negative (0 to disable)") - } - c.VersionCheckInterval = d - validated = d - default: - return nil, fmt.Errorf("version_check_interval must be string or duration, got %T", value) - } - - case "version_check_last_time": - nowish := time.Now().Add(time.Hour) + case "version_check": switch v := value.(type) { - case time.Time: - if v.After(nowish) { - return nil, fmt.Errorf("version_check_last_time cannot be in the future") - } - c.VersionCheckLastTime = v + case bool: + c.VersionCheck = v validated = v case string: - // Try parsing as RFC3339 first, then as unix timestamp - t, err := time.Parse(time.RFC3339, v) + b, err := setBool("version_check", v) if err != nil { - // Try parsing as unix timestamp - i, err := strconv.ParseInt(v, 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid version_check_last_time value: %s (must be RFC3339 timestamp or unix timestamp)", v) - } - t = time.Unix(i, 0) - } - if t.After(nowish) { - return nil, fmt.Errorf("version_check_last_time cannot be in the future") + return nil, err } - c.VersionCheckLastTime = t - validated = t + c.VersionCheck = b + validated = b default: - return nil, fmt.Errorf("version_check_last_time must be string or time, got %T", value) + return nil, fmt.Errorf("version_check must be string or bool, got %T", value) } default: diff --git a/internal/tiger/config/config_test.go b/internal/tiger/config/config_test.go index b7fc26ba..594a6a5b 100644 --- a/internal/tiger/config/config_test.go +++ b/internal/tiger/config/config_test.go @@ -122,6 +122,60 @@ read_only: true } } +func TestLoad_MigrateVersionCheck(t *testing.T) { + tests := []struct { + name string + fileBody string + env map[string]string + want bool + }{ + { + name: "legacy interval 0 keeps checks disabled", + fileBody: "version_check_interval: 0\n", + want: false, + }, + { + name: "explicit version_check overrides legacy interval", + fileBody: "version_check_interval: 0\nversion_check: true\n", + want: true, + }, + { + name: "env var overrides legacy interval", + fileBody: "version_check_interval: 24h\n", + env: map[string]string{"TIGER_VERSION_CHECK": "false"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := setupTestConfig(t) + + configFile := GetConfigFile(tmpDir) + if err := os.WriteFile(configFile, []byte(tt.fileBody), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + os.Setenv("TIGER_CONFIG_DIR", tmpDir) + t.Cleanup(func() { os.Unsetenv("TIGER_CONFIG_DIR") }) + for k, val := range tt.env { + os.Setenv(k, val) + t.Cleanup(func() { os.Unsetenv(k) }) + } + + setupViper(t, tmpDir) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + if cfg.VersionCheck != tt.want { + t.Errorf("VersionCheck = %t, want %t", cfg.VersionCheck, tt.want) + } + }) + } +} + func TestLoad_FromEnvironmentVariables(t *testing.T) { tmpDir := setupTestConfig(t) diff --git a/internal/tiger/version/check.go b/internal/tiger/version/check.go index 464ec3c0..31a0d593 100644 --- a/internal/tiger/version/check.go +++ b/internal/tiger/version/check.go @@ -39,20 +39,6 @@ type CheckResult struct { UpdateCommand string } -// shouldCheck returns true if enough time has passed since the last check -func shouldCheck(lastCheckTime time.Time, interval time.Duration) bool { - if interval == 0 { - return false // Version check disabled - } - - if lastCheckTime.IsZero() { - return true // Never checked before - } - - nextCheck := lastCheckTime.Add(interval) - return time.Now().After(nextCheck) -} - // FetchLatestVersion downloads the latest version string from the given URL func fetchLatestVersion(checkURL string) (string, error) { client := &http.Client{ @@ -172,7 +158,7 @@ func detectInstallMethod(binaryPath string) InstallMethod { } // GetUpdateCommand returns the command to update Tiger CLI based on the install method -func getUpdateCommand(method InstallMethod, cfg *config.Config) string { +func getUpdateCommand(method InstallMethod) string { switch method { case InstallMethodHomebrew: return "brew update && brew upgrade tiger-cli" @@ -184,16 +170,18 @@ func getUpdateCommand(method InstallMethod, cfg *config.Config) string { return "sudo dnf update tiger-cli" } return "sudo yum update tiger-cli" - case InstallMethodInstallSh: - return "curl -fsSL " + cfg.ReleasesURL + " | sh" case InstallMethodDevelopment: return "rebuild from source or install via package manager" default: - return "visit https://github.com/timescale/tiger-cli/releases" + // InstallMethodInstallSh and InstallMethodUnknown: `tiger upgrade` + // replaces the binary in place; if it can't (e.g. wrong permissions or + // an unrecognized package manager), it reports a clear error directing + // the user back to their original install method. + return "tiger upgrade" } } -func checkVersionForUpdate(version string, cfg *config.Config, output *io.Writer) (*CheckResult, error) { +func checkVersionForUpdate(version string, cfg *config.Config) (*CheckResult, error) { latestVersion, err := fetchLatestVersion(cfg.ReleasesURL + "/latest.txt") if err != nil { return nil, err @@ -201,17 +189,12 @@ func checkVersionForUpdate(version string, cfg *config.Config, output *io.Writer updateAvailable := compareVersions(version, latestVersion) - // Detect installation method - binaryPath, err := os.Executable() - if err != nil { - if cfg.Debug && output != nil { - fmt.Fprintf(*output, "Warning: failed to get executable path: %v\n", err) - } - binaryPath = "" - } + // Detect installation method. On failure, fall back to an empty path, which + // detectInstallMethod reports as "unknown". + binaryPath, _ := os.Executable() installMethod := detectInstallMethod(binaryPath) - updateCommand := getUpdateCommand(installMethod, cfg) + updateCommand := getUpdateCommand(installMethod) return &CheckResult{ UpdateAvailable: updateAvailable, @@ -222,9 +205,17 @@ func checkVersionForUpdate(version string, cfg *config.Config, output *io.Writer }, nil } -// CheckForUpdate checks if a new version is available and returns the result -func checkForUpdate(cfg *config.Config, output *io.Writer) (*CheckResult, error) { - return checkVersionForUpdate(config.Version, cfg, output) +// CheckForUpdate fetches the latest released version and returns the result +// with no side effects (no throttling, CI/terminal gating, or persisted +// state). Gating (whether to check at all) is the caller's responsibility: +// the startup notifier gates on an interactive, non-CI terminal in root.go, +// while the `tiger upgrade` command always wants a fresh result on demand. +// +// Note: CheckResult.LatestVersion has its leading "v" trimmed (for display and +// comparison). Callers that need the release tag used in download paths (e.g. +// releases/v1.2.3/) must re-add the "v" prefix. +func CheckForUpdate(cfg *config.Config) (*CheckResult, error) { + return checkVersionForUpdate(config.Version, cfg) } // PrintUpdateWarning prints a warning message to stderr if an update is available @@ -252,69 +243,3 @@ func PrintUpdateWarning(result *CheckResult, cfg *config.Config, output *io.Writ result.UpdateCommand, ) } - -// PerformCheck performs the full version check flow: -// 1. Check if enough time has passed -// 2. Fetch and compare versions -// 3. Update the last check timestamp -// 4. Print warning if update available -func PerformCheck(cfg *config.Config, output *io.Writer, force bool) *CheckResult { - // Skip if version check is disabled - if !force && cfg.VersionCheckInterval == 0 { - if cfg.Debug && output != nil { - fmt.Fprintln(*output, "Version check is disabled") - } - return nil - } - - if !force && util.IsCI() { - if cfg.Debug && output != nil { - fmt.Fprintln(*output, "Skipping version check (CI environment detected)") - } - return nil - } - - if !force && !(util.IsTerminal(os.Stderr) && util.IsTerminal(os.Stdout)) { - if cfg.Debug && output != nil { - fmt.Fprintln(*output, "Skipping version check (non-interactive terminal detected)") - } - return nil - } - - // Skip if not enough time has passed - if !force && !shouldCheck(cfg.VersionCheckLastTime, cfg.VersionCheckInterval) { - if cfg.Debug && output != nil { - fmt.Fprintf( - *output, - "Skipping version check (too soon)\n Last check: %s, Interval: %s\n", - cfg.VersionCheckLastTime.Format(time.RFC3339), - cfg.VersionCheckInterval, - ) - } - return nil - } - - // Perform the check - result, err := checkForUpdate(cfg, output) - if err != nil { - if cfg.Debug && output != nil { - fmt.Fprintf(*output, "Warning: version check failed: %v\n", err) - } else if output != nil { - fmt.Fprintf(*output, "Warning: failed to check the latest CLI version.\n") - } - // Don't update last check time on error - we'll retry next time - return nil - } - - // Update last check time only after successful check - // This ensures we retry quickly if the check failed - now := time.Now() - if err := cfg.Set("version_check_last_time", now.Format(time.RFC3339)); err != nil { - if cfg.Debug && output != nil { - fmt.Fprintf(*output, "Warning: failed to update last check time: %v\n", err) - } - // Don't fail if we can't update the timestamp - } - - return result -} diff --git a/internal/tiger/version/check_test.go b/internal/tiger/version/check_test.go index 0910cd3c..5ebf5f0f 100644 --- a/internal/tiger/version/check_test.go +++ b/internal/tiger/version/check_test.go @@ -6,54 +6,10 @@ import ( "os" "path/filepath" "testing" - "time" "github.com/timescale/tiger-cli/internal/tiger/config" ) -func TestShouldCheck(t *testing.T) { - tests := []struct { - name string - lastCheckTime time.Time - interval time.Duration - want bool - }{ - { - name: "disabled when interval is 0", - lastCheckTime: time.Now(), - interval: 0, - want: false, - }, - { - name: "never checked before", - lastCheckTime: time.Time{}, - interval: time.Hour, - want: true, - }, - { - name: "checked recently", - lastCheckTime: time.Now().Add(-30 * time.Minute), // 30 minutes ago - interval: time.Hour, // 1 hour interval - want: false, - }, - { - name: "checked long ago", - lastCheckTime: time.Now().Add(-2 * time.Hour), // 2 hours ago - interval: time.Hour, // 1 hour interval - want: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := shouldCheck(tt.lastCheckTime, tt.interval) - if got != tt.want { - t.Errorf("shouldCheck() = %v, want %v", got, tt.want) - } - }) - } -} - func TestCompareVersions(t *testing.T) { tests := []struct { name string @@ -171,7 +127,6 @@ func TestDetectInstallMethod(t *testing.T) { } func TestGetUpdateCommand(t *testing.T) { - testUrl := "https://cli.example.com" tests := []struct { name string method InstallMethod @@ -195,7 +150,7 @@ func TestGetUpdateCommand(t *testing.T) { { name: "install.sh", method: InstallMethodInstallSh, - want: "curl -fsSL " + testUrl + " | sh", + want: "tiger upgrade", }, { name: "development", @@ -205,17 +160,13 @@ func TestGetUpdateCommand(t *testing.T) { { name: "unknown", method: InstallMethodUnknown, - want: "visit https://github.com/timescale/tiger-cli/releases", + want: "tiger upgrade", }, } - cfg := &config.Config{ - ReleasesURL: testUrl, - } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := getUpdateCommand(tt.method, cfg) + got := getUpdateCommand(tt.method) // For RPM, accept either yum or dnf if tt.method == InstallMethodRPM { if got != "sudo yum update tiger-cli" && got != "sudo dnf update tiger-cli" { @@ -334,7 +285,7 @@ func TestCheckForUpdate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := checkVersionForUpdate(tt.currentVersion, cfg, nil) + result, err := checkVersionForUpdate(tt.currentVersion, cfg) if err != nil { t.Errorf("checkVersionForUpdate() error = %v", err) return @@ -351,32 +302,3 @@ func TestCheckForUpdate(t *testing.T) { }) } } - -func TestPerformCheck_Disabled(t *testing.T) { - // Create config with version check disabled - cfg := &config.Config{ - VersionCheckInterval: 0, // Disabled - ReleasesURL: "http://example.com", - } - - // Should return immediately without error - result := PerformCheck(cfg, nil, false) - if result != nil { - t.Errorf("PerformCheck() with disabled check should return nil") - } -} - -func TestPerformCheck_TooSoon(t *testing.T) { - // Create config with recent check - cfg := &config.Config{ - VersionCheckInterval: time.Hour, // 1 hour - VersionCheckLastTime: time.Now(), // Just checked - ReleasesURL: "http://example.com", - } - - // Should return immediately without error (and without making HTTP request) - result := PerformCheck(cfg, nil, false) - if result != nil { - t.Errorf("PerformCheck() with recent check should return nil") - } -} diff --git a/specs/spec.md b/specs/spec.md index bd5008d0..95892c85 100644 --- a/specs/spec.md +++ b/specs/spec.md @@ -34,7 +34,7 @@ Environment variables override configuration file values. All variables use the - `TIGER_PASSWORD_STORAGE` - Password storage method: keyring, pgpass, or none - `TIGER_READ_ONLY` - When `true`, write/destructive CLI commands and Tiger MCP tools refuse to run - `TIGER_SERVICE_ID` - Default service ID -- `TIGER_VERSION_CHECK_INTERVAL` - How often the CLI will check for new versions, 0 to disable +- `TIGER_VERSION_CHECK` - When true, the CLI checks for a newer version on each invocation (in an interactive terminal) and prints a notice if one is available; false to disable ### Configuration File @@ -50,7 +50,7 @@ All configuration options can be set via `tiger config set `: - `password_storage` - Password storage method: keyring, pgpass, or none (default: keyring) - `read_only` - When `true`, mutating operations are refused: `tiger service create`/`fork`/`start`/`stop`/`resize`/`update-password`/`delete` and their MCP equivalents return an error, and `tiger db connect`/`connection-string`/`db_execute_query` open against an immutable read-only database connection regardless of `--read-only` (default: false). See `specs/spec_mcp.md` for details. - `service_id` - Default service ID -- `version_check_interval` - How often the CLI will check for new versions, 0 to disable (default: 24h) +- `version_check` - When true, the CLI checks for a newer version on each invocation (in an interactive terminal) and prints a notice if one is available; false to disable (default: true) #### Example @@ -777,6 +777,22 @@ tiger config unset service_id tiger config reset ``` +#### `tiger upgrade` +Upgrade the Tiger CLI to the latest published version, replacing the currently running binary in place. Alias: `update`. + +The command downloads the release archive for the current platform from the releases URL (`releases_url`, default `https://cli.tigerdata.com`), verifies its SHA-256 checksum, extracts the `tiger` binary, and atomically replaces the running binary. + +If the CLI was installed via a package manager (Homebrew, apt, yum/dnf), the upgrade is refused with a suggestion to use that package manager instead. Local development builds are likewise refused unless `--force` is passed. + +**Examples:** +```bash +# Upgrade to the latest release +tiger upgrade + +# Equivalent alias +tiger update +``` + ## Exit Codes - `0`: Success