diff --git a/.gitignore b/.gitignore index f231eff31..7eeb05d79 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ # Nix .envrc .direnv/ + +.atmos/cache.yaml diff --git a/atmos.yaml b/atmos.yaml index adfd5176b..e9793b62f 100644 --- a/atmos.yaml +++ b/atmos.yaml @@ -315,3 +315,9 @@ settings: # If the source and destination lists have the same length, all items in the destination lists are # deep-merged with all items in the source list. list_merge_strategy: replace + +version: + check: + enabled: true + timeout: 1000 # ms + frequency: 1h diff --git a/cmd/cmd_utils.go b/cmd/cmd_utils.go index 3d55bf630..149969cf6 100644 --- a/cmd/cmd_utils.go +++ b/cmd/cmd_utils.go @@ -7,6 +7,7 @@ import ( "os" "path" "strings" + "time" "github.com/fatih/color" "github.com/spf13/cobra" @@ -428,18 +429,60 @@ func printMessageForMissingAtmosConfig(cliConfig schema.CliConfiguration) { u.PrintMessage("https://atmos.tools/quick-start\n") } -// customHelpMessageToUpgradeToAtmosLatestRelease adds Atmos version info at the end of each help commnad -func customHelpMessageToUpgradeToAtmosLatestRelease(cmd *cobra.Command, args []string) { - originalHelpFunc(cmd, args) - // Check for the latest Atmos release on GitHub +// CheckForAtmosUpdateAndPrintMessage checks if a version update is needed and prints a message if a newer version is found. +// It loads the cache, decides if it's time to check for updates, compares the current version to the latest available release, +// and if newer, prints the update message. It also updates the cache's timestamp after printing. +func CheckForAtmosUpdateAndPrintMessage(cliConfig schema.CliConfiguration) { + // If version checking is disabled in the configuration, do nothing + if !cliConfig.Version.Check.Enabled { + return + } + + // Load the cache + cacheCfg, err := cfg.LoadCache() + if err != nil { + u.LogWarning(cliConfig, fmt.Sprintf("Could not load cache: %s", err)) + return + } + + // Determine if it's time to check for updates based on frequency and last_checked + if !cfg.ShouldCheckForUpdates(cacheCfg.LastChecked, cliConfig.Version.Check.Frequency) { + // Not due for another check yet, so return without printing anything + return + } + + // Get the latest Atmos release from GitHub latestReleaseTag, err := u.GetLatestGitHubRepoRelease("cloudposse", "atmos") - if err == nil && latestReleaseTag != "" { - latestRelease := strings.TrimPrefix(latestReleaseTag, "v") - currentRelease := strings.TrimPrefix(version.Version, "v") - if latestRelease != currentRelease { - u.PrintMessageToUpgradeToAtmosLatestRelease(latestRelease) - } + if err != nil { + u.LogWarning(cliConfig, fmt.Sprintf("Failed to retrieve latest Atmos release info: %s", err)) + return } + + if latestReleaseTag == "" { + u.LogWarning(cliConfig, "No release information available") + return + } + + // Trim "v" prefix to compare versions + latestVersion := strings.TrimPrefix(latestReleaseTag, "v") + currentVersion := strings.TrimPrefix(version.Version, "v") + + // If the versions differ, print the update message + if latestVersion != currentVersion { + u.PrintMessageToUpgradeToAtmosLatestRelease(latestVersion) + } + + // Update the cache to mark the current timestamp + cacheCfg.LastChecked = time.Now().Unix() + if saveErr := cfg.SaveCache(cacheCfg); saveErr != nil { + u.LogWarning(cliConfig, fmt.Sprintf("Unable to save cache: %s", saveErr)) + + } +} + +func customHelpMessageToUpgradeToAtmosLatestRelease(cmd *cobra.Command, args []string) { + originalHelpFunc(cmd, args) + CheckForAtmosUpdateAndPrintMessage(cliConfig) } // Check Atmos is version command diff --git a/cmd/docs.go b/cmd/docs.go index c9cad03a2..93a46b77d 100644 --- a/cmd/docs.go +++ b/cmd/docs.go @@ -97,7 +97,10 @@ var docsCmd = &cobra.Command{ u.LogErrorAndExit(schema.CliConfiguration{}, err) } - fmt.Println(componentDocs) + if err := u.DisplayDocs(componentDocs, cliConfig.Settings.Docs.Pagination); err != nil { + u.LogErrorAndExit(schema.CliConfiguration{}, fmt.Errorf("failed to display documentation: %w", err)) + } + return } diff --git a/cmd/helmfile.go b/cmd/helmfile.go index e4fb07c84..eb5ee9663 100644 --- a/cmd/helmfile.go +++ b/cmd/helmfile.go @@ -1,15 +1,12 @@ package cmd import ( - "strings" - "github.com/samber/lo" "github.com/spf13/cobra" e "github.com/cloudposse/atmos/internal/exec" "github.com/cloudposse/atmos/pkg/schema" u "github.com/cloudposse/atmos/pkg/utils" - "github.com/cloudposse/atmos/pkg/version" ) // helmfileCmd represents the base command for all helmfile sub-commands @@ -34,18 +31,10 @@ var helmfileCmd = &cobra.Command{ if err != nil { u.LogErrorAndExit(schema.CliConfiguration{}, err) } - - // Check for the latest Atmos release on GitHub and print update message - latestReleaseTag, err := u.GetLatestGitHubRepoRelease("cloudposse", "atmos") - if err == nil && latestReleaseTag != "" { - latestRelease := strings.TrimPrefix(latestReleaseTag, "v") - currentRelease := strings.TrimPrefix(version.Version, "v") - if latestRelease != currentRelease { - u.PrintMessageToUpgradeToAtmosLatestRelease(latestRelease) - } - } // Exit on help if info.NeedHelp { + // Check for the latest Atmos release on GitHub and print update message + CheckForAtmosUpdateAndPrintMessage(cliConfig) return } // Check Atmos configuration diff --git a/cmd/root.go b/cmd/root.go index 0027776c1..c7b043a3f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -18,6 +18,8 @@ import ( u "github.com/cloudposse/atmos/pkg/utils" ) +var cliConfig schema.CliConfiguration + // originalHelpFunc holds Cobra's original help function to avoid recursion. var originalHelpFunc func(*cobra.Command, []string) @@ -72,14 +74,6 @@ func Execute() error { Flags: cc.Bold, }) - // Save the original help function to prevent infinite recursion when overriding it. - // This allows us to call the original help functionality within our custom help function. - originalHelpFunc = RootCmd.HelpFunc() - - // Override the help function with a custom one that adds an upgrade message after displaying help. - // This custom help function will call the original help function and then display the bordered message. - RootCmd.SetHelpFunc(customHelpMessageToUpgradeToAtmosLatestRelease) - // Check if the `help` flag is passed and print a styled Atmos logo to the terminal before printing the help err := RootCmd.ParseFlags(os.Args) if err != nil && errors.Is(err, pflag.ErrHelp) { @@ -89,21 +83,29 @@ func Execute() error { u.LogErrorAndExit(schema.CliConfiguration{}, err) } } - // InitCliConfig finds and merges CLI configurations in the following order: // system dir, home dir, current dir, ENV vars, command-line arguments // Here we need the custom commands from the config - cliConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, false) - if err != nil && !errors.Is(err, cfg.NotFound) { + var initErr error + cliConfig, initErr = cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, false) + if initErr != nil && !errors.Is(initErr, cfg.NotFound) { if isVersionCommand() { - u.LogTrace(schema.CliConfiguration{}, fmt.Sprintf("warning: CLI configuration 'atmos.yaml' file not found. Error: %s", err)) + u.LogTrace(schema.CliConfiguration{}, fmt.Sprintf("warning: CLI configuration 'atmos.yaml' file not found. Error: %s", initErr)) } else { - u.LogErrorAndExit(schema.CliConfiguration{}, err) + u.LogErrorAndExit(schema.CliConfiguration{}, initErr) } } + // Save the original help function to prevent infinite recursion when overriding it. + // This allows us to call the original help functionality within our custom help function. + originalHelpFunc = RootCmd.HelpFunc() + + // Override the help function with a custom one that adds an upgrade message after displaying help. + // This custom help function will call the original help function and then display the bordered message. + RootCmd.SetHelpFunc(customHelpMessageToUpgradeToAtmosLatestRelease) + // If CLI configuration was found, process its custom commands and command aliases - if err == nil { + if initErr == nil { err = processCustomCommands(cliConfig, cliConfig.Commands, RootCmd, true) if err != nil { u.LogErrorAndExit(schema.CliConfiguration{}, err) diff --git a/cmd/terraform.go b/cmd/terraform.go index 5d8832c1b..cc9837bb4 100644 --- a/cmd/terraform.go +++ b/cmd/terraform.go @@ -1,15 +1,12 @@ package cmd import ( - "strings" - "github.com/samber/lo" "github.com/spf13/cobra" e "github.com/cloudposse/atmos/internal/exec" "github.com/cloudposse/atmos/pkg/schema" u "github.com/cloudposse/atmos/pkg/utils" - "github.com/cloudposse/atmos/pkg/version" ) // terraformCmd represents the base command for all terraform sub-commands @@ -35,17 +32,11 @@ var terraformCmd = &cobra.Command{ if err != nil { u.LogErrorAndExit(schema.CliConfiguration{}, err) } - // Check for the latest Atmos release on GitHub and print update message - latestReleaseTag, err := u.GetLatestGitHubRepoRelease("cloudposse", "atmos") - if err == nil && latestReleaseTag != "" { - latestRelease := strings.TrimPrefix(latestReleaseTag, "v") - currentRelease := strings.TrimPrefix(version.Version, "v") - if latestRelease != currentRelease { - u.PrintMessageToUpgradeToAtmosLatestRelease(latestRelease) - } - } + // Exit on help if info.NeedHelp { + // Check for the latest Atmos release on GitHub and print update message + CheckForAtmosUpdateAndPrintMessage(cliConfig) return } // Check Atmos configuration diff --git a/cmd/version.go b/cmd/version.go index 3665077c6..1e13eadee 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -13,6 +13,8 @@ import ( "github.com/cloudposse/atmos/pkg/version" ) +var checkFlag bool + var versionCmd = &cobra.Command{ Use: "version", Short: "Show the installed Atmos CLI version and check for updates", @@ -29,18 +31,33 @@ var versionCmd = &cobra.Command{ u.PrintMessage(fmt.Sprintf("\U0001F47D Atmos %s on %s/%s", version.Version, runtime.GOOS, runtime.GOARCH)) fmt.Println() - // Check for the latest Atmos release on GitHub - latestReleaseTag, err := u.GetLatestGitHubRepoRelease("cloudposse", "atmos") - if err == nil && latestReleaseTag != "" { - latestRelease := strings.TrimPrefix(latestReleaseTag, "v") - currentRelease := strings.TrimPrefix(version.Version, "v") - if latestRelease != currentRelease { - u.PrintMessageToUpgradeToAtmosLatestRelease(latestRelease) + if checkFlag { + // Check for the latest Atmos release on GitHub + latestReleaseTag, err := u.GetLatestGitHubRepoRelease("cloudposse", "atmos") + if err == nil && latestReleaseTag != "" { + if err != nil { + u.LogWarning(schema.CliConfiguration{}, fmt.Sprintf("Failed to check for updates: %v", err)) + return + } + if latestReleaseTag == "" { + u.LogWarning(schema.CliConfiguration{}, "No release information available") + return + } + latestRelease := strings.TrimPrefix(latestReleaseTag, "v") + currentRelease := strings.TrimPrefix(version.Version, "v") + if latestRelease != currentRelease { + u.PrintMessageToUpgradeToAtmosLatestRelease(latestRelease) + } } + return } + + // Check for the cache and print update message + CheckForAtmosUpdateAndPrintMessage(cliConfig) }, } func init() { + versionCmd.Flags().BoolVarP(&checkFlag, "check", "c", false, "Run additional checks after displaying version info") RootCmd.AddCommand(versionCmd) } diff --git a/examples/quick-start-advanced/Dockerfile b/examples/quick-start-advanced/Dockerfile index 8ad376f7e..895f09c56 100644 --- a/examples/quick-start-advanced/Dockerfile +++ b/examples/quick-start-advanced/Dockerfile @@ -6,7 +6,7 @@ ARG GEODESIC_OS=debian # https://atmos.tools/ # https://github.com/cloudposse/atmos # https://github.com/cloudposse/atmos/releases -ARG ATMOS_VERSION=1.122.0 +ARG ATMOS_VERSION=1.127.0 # Terraform: https://github.com/hashicorp/terraform/releases ARG TF_VERSION=1.5.7 diff --git a/go.mod b/go.mod index ef51bcbdf..1c01ef148 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/elewis787/boa v0.1.2 github.com/fatih/color v1.18.0 github.com/go-git/go-git/v5 v5.12.0 + github.com/gofrs/flock v0.12.1 github.com/google/go-containerregistry v0.20.2 github.com/google/go-github/v59 v59.0.0 github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum index f557e9bc6..90b091ecb 100644 --- a/go.sum +++ b/go.sum @@ -584,6 +584,8 @@ github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22 github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= diff --git a/internal/exec/atmos.go b/internal/exec/atmos.go index 446c42ca1..a7f5f6f7d 100644 --- a/internal/exec/atmos.go +++ b/internal/exec/atmos.go @@ -51,7 +51,7 @@ func ExecuteAtmosCmd() error { if v2, ok := v.(map[string]any); ok { if v3, ok := v2["components"].(map[string]any); ok { if v4, ok := v3["terraform"].(map[string]any); ok { - return k, lo.Keys(v4) + return k, FilterAbstractComponents(v4) } // TODO: process 'helmfile' components and stacks. // This will require checking the list of commands and filtering the stacks and components depending on the selected command. diff --git a/internal/exec/describe_component.go b/internal/exec/describe_component.go index 2fabc2611..5afea0dd7 100644 --- a/internal/exec/describe_component.go +++ b/internal/exec/describe_component.go @@ -2,6 +2,7 @@ package exec import ( "github.com/pkg/errors" + "github.com/samber/lo" "github.com/spf13/cobra" cfg "github.com/cloudposse/atmos/pkg/config" @@ -83,3 +84,32 @@ func ExecuteDescribeComponent( return configAndStacksInfo.ComponentSection, nil } + +// FilterAbstractComponents This function removes abstract components and returns the list of components +func FilterAbstractComponents(componentsMap map[string]any) []string { + if componentsMap == nil { + return []string{} + } + components := make([]string, 0) + for _, k := range lo.Keys(componentsMap) { + componentMap, ok := componentsMap[k].(map[string]any) + if !ok { + components = append(components, k) + continue + } + + metadata, ok := componentMap["metadata"].(map[string]any) + if !ok { + components = append(components, k) + continue + } + if componentType, ok := metadata["type"].(string); ok && componentType == "abstract" { + continue + } + if componentEnabled, ok := metadata["enabled"].(bool); ok && !componentEnabled { + continue + } + components = append(components, k) + } + return components +} diff --git a/internal/exec/helmfile.go b/internal/exec/helmfile.go index bfcfde41a..fe36f7e9f 100644 --- a/internal/exec/helmfile.go +++ b/internal/exec/helmfile.go @@ -54,6 +54,10 @@ func ExecuteHelmfile(info schema.ConfigAndStacksInfo) error { return nil } + if info.SubCommand == "version" { + return ExecuteShellCommand(cliConfig, "helmfile", []string{info.SubCommand}, "", nil, false, info.RedirectStdErr) + } + info, err = ProcessStacks(cliConfig, info, true, true) if err != nil { return err diff --git a/internal/exec/terraform.go b/internal/exec/terraform.go index c0d53700d..88fd45b04 100644 --- a/internal/exec/terraform.go +++ b/internal/exec/terraform.go @@ -61,6 +61,15 @@ func ExecuteTerraform(info schema.ConfigAndStacksInfo) error { fmt.Println() return nil } + if info.SubCommand == "version" { + return ExecuteShellCommand(cliConfig, + "terraform", + []string{info.SubCommand}, + "", + nil, + false, + info.RedirectStdErr) + } shouldProcessStacks := true shouldCheckStack := true @@ -478,7 +487,7 @@ func ExecuteTerraform(info schema.ConfigAndStacksInfo) error { } // Clean up - if info.SubCommand != "plan" && info.PlanFile == "" { + if info.SubCommand != "plan" && info.SubCommand != "show" && info.PlanFile == "" { planFilePath := constructTerraformComponentPlanfilePath(cliConfig, info) _ = os.Remove(planFilePath) } diff --git a/internal/exec/utils.go b/internal/exec/utils.go index 6fefa53ba..46693a4ba 100644 --- a/internal/exec/utils.go +++ b/internal/exec/utils.go @@ -661,22 +661,19 @@ func processArgsAndFlags(componentType string, inputArgsAndFlags []string) (sche var indexesToRemove []int // For commands like `atmos terraform clean` and `atmos terraform plan`, show the command help - if len(inputArgsAndFlags) == 1 { + if len(inputArgsAndFlags) == 1 && inputArgsAndFlags[0] != "version" { info.SubCommand = inputArgsAndFlags[0] info.NeedHelp = true return info, nil } - - // https://github.com/roboll/helmfile#cli-reference - var globalOptionsFlagIndex int - - // For commands like `atmos terraform clean` and `atmos terraform plan`, show the command help - if len(inputArgsAndFlags) == 1 { + if len(inputArgsAndFlags) == 1 && inputArgsAndFlags[0] == "version" { info.SubCommand = inputArgsAndFlags[0] - info.NeedHelp = true return info, nil } + // https://github.com/roboll/helmfile#cli-reference + var globalOptionsFlagIndex int + for i, arg := range inputArgsAndFlags { if arg == cfg.GlobalOptionsFlag { globalOptionsFlagIndex = i + 1 diff --git a/pkg/config/cache.go b/pkg/config/cache.go new file mode 100644 index 000000000..6833e67b5 --- /dev/null +++ b/pkg/config/cache.go @@ -0,0 +1,164 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/gofrs/flock" + "github.com/pkg/errors" + "github.com/spf13/viper" + + "github.com/cloudposse/atmos/pkg/schema" + u "github.com/cloudposse/atmos/pkg/utils" +) + +type CacheConfig struct { + LastChecked int64 `mapstructure:"last_checked"` +} + +func GetCacheFilePath() (string, error) { + xdgCacheHome := os.Getenv("XDG_CACHE_HOME") + var cacheDir string + if xdgCacheHome == "" { + cacheDir = filepath.Join(".", ".atmos") + } else { + cacheDir = filepath.Join(xdgCacheHome, "atmos") + } + + if err := os.MkdirAll(cacheDir, 0755); err != nil { + return "", errors.Wrap(err, "error creating cache directory") + } + + return filepath.Join(cacheDir, "cache.yaml"), nil +} + +func withCacheFileLock(cacheFile string, fn func() error) error { + lock := flock.New(cacheFile) + err := lock.Lock() + if err != nil { + return errors.Wrap(err, "error acquiring file lock") + } + defer lock.Unlock() + return fn() +} + +func LoadCache() (CacheConfig, error) { + cacheFile, err := GetCacheFilePath() + if err != nil { + return CacheConfig{}, err + } + + var cfg CacheConfig + if _, err := os.Stat(cacheFile); os.IsNotExist(err) { + // No file yet, return default + return cfg, nil + } + + v := viper.New() + v.SetConfigFile(cacheFile) + if err := v.ReadInConfig(); err != nil { + return cfg, errors.Wrap(err, "failed to read cache file") + } + if err := v.Unmarshal(&cfg); err != nil { + return cfg, errors.Wrap(err, "failed to unmarshal cache file") + } + return cfg, nil +} + +func SaveCache2(cfg CacheConfig) error { + cacheFile, err := GetCacheFilePath() + if err != nil { + return err + } + + return withCacheFileLock(cacheFile, func() error { + v := viper.New() + v.Set("last_checked", cfg.LastChecked) + if err := v.WriteConfigAs(cacheFile); err != nil { + return errors.Wrap(err, "failed to write cache file") + } + return nil + }) +} + +func SaveCache(cfg CacheConfig) error { + cacheFile, err := GetCacheFilePath() + if err != nil { + return err + } + + v := viper.New() + v.Set("last_checked", cfg.LastChecked) + if err := v.WriteConfigAs(cacheFile); err != nil { + return errors.Wrap(err, "failed to write cache file") + } + return nil +} + +func ShouldCheckForUpdates(lastChecked int64, frequency string) bool { + now := time.Now().Unix() + + interval, err := parseFrequency(frequency) + if err != nil { + // Log warning and default to daily if we can’t parse + u.LogWarning(schema.CliConfiguration{}, fmt.Sprintf("Unsupported frequency '%s' encountered. Defaulting to daily.", frequency)) + interval = 86400 // daily + } + return now-lastChecked >= interval +} + +// parseFrequency attempts to parse the frequency string in three ways: +// 1. As an integer (seconds) +// 2. As a duration with a suffix (e.g., "1h", "5m", "30s") +// 3. As one of the predefined keywords (daily, hourly, etc.) +func parseFrequency(frequency string) (int64, error) { + freq := strings.TrimSpace(frequency) + + if intVal, err := strconv.ParseInt(freq, 10, 64); err == nil { + if intVal > 0 { + return intVal, nil + } + } + + // Parse duration with suffix + if len(freq) > 1 { + unit := freq[len(freq)-1] + valPart := freq[:len(freq)-1] + if valInt, err := strconv.ParseInt(valPart, 10, 64); err == nil && valInt > 0 { + switch unit { + case 's': + return valInt, nil + case 'm': + return valInt * 60, nil + case 'h': + return valInt * 3600, nil + case 'd': + return valInt * 86400, nil + default: + return 0, fmt.Errorf("unrecognized duration unit: %s", string(unit)) + } + } + } + + // Handle predefined keywords + switch freq { + case "minute": + return 60, nil + case "hourly": + return 3600, nil + case "daily": + return 86400, nil + case "weekly": + return 604800, nil + case "monthly": + return 2592000, nil + case "yearly": + return 31536000, nil + default: + return 0, fmt.Errorf("unrecognized frequency: %s", freq) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 8cbfb8c36..a0a1f0167 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -82,6 +82,13 @@ var ( }, }, Initialized: true, + Version: schema.Version{ + Check: schema.VersionCheck{ + Enabled: true, + Timeout: 1000, + Frequency: "daily", + }, + }, } ) diff --git a/pkg/config/utils.go b/pkg/config/utils.go index 929243c3f..060a1cf2a 100644 --- a/pkg/config/utils.go +++ b/pkg/config/utils.go @@ -371,6 +371,17 @@ func processEnvVars(cliConfig *schema.CliConfiguration) error { cliConfig.Settings.ListMergeStrategy = listMergeStrategy } + versionEnabled := os.Getenv("ATMOS_VERSION_CHECK_ENABLED") + if len(versionEnabled) > 0 { + u.LogTrace(*cliConfig, fmt.Sprintf("Found ENV var ATMOS_VERSION_CHECK_ENABLED=%s", versionEnabled)) + enabled, err := strconv.ParseBool(versionEnabled) + if err != nil { + u.LogWarning(*cliConfig, fmt.Sprintf("Invalid boolean value '%s' for ATMOS_VERSION_CHECK_ENABLED; using default.", versionEnabled)) + } else { + cliConfig.Version.Check.Enabled = enabled + } + } + return nil } diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go index 7eacc76ff..e44396a38 100644 --- a/pkg/schema/schema.go +++ b/pkg/schema/schema.go @@ -26,6 +26,7 @@ type CliConfiguration struct { StackConfigFilesAbsolutePaths []string `yaml:"stackConfigFilesAbsolutePaths,omitempty" json:"stackConfigFilesAbsolutePaths,omitempty" mapstructure:"stackConfigFilesAbsolutePaths"` StackType string `yaml:"stackType,omitempty" json:"StackType,omitempty" mapstructure:"stackType"` Default bool `yaml:"default" json:"default" mapstructure:"default"` + Version Version `yaml:"version,omitempty" json:"version,omitempty" mapstructure:"version"` } type CliSettings struct { @@ -34,7 +35,8 @@ type CliSettings struct { } type Docs struct { - MaxWidth int `yaml:"max-width" json:"max_width" mapstructure:"max-width"` + MaxWidth int `yaml:"max-width" json:"max_width" mapstructure:"max-width"` + Pagination bool `yaml:"pagination" json:"pagination" mapstructure:"pagination"` } type Templates struct { @@ -127,6 +129,16 @@ type Context struct { TerraformWorkspace string `yaml:"terraform_workspace" json:"terraform_workspace" mapstructure:"terraform_workspace"` } +type VersionCheck struct { + Enabled bool `yaml:"enabled,omitempty" mapstructure:"enabled"` + Timeout int `yaml:"timeout,omitempty" mapstructure:"timeout"` + Frequency string `yaml:"frequency,omitempty" mapstructure:"frequency"` +} + +type Version struct { + Check VersionCheck `yaml:"check,omitempty" mapstructure:"check"` +} + type ArgsAndFlagsInfo struct { AdditionalArgsAndFlags []string SubCommand string diff --git a/pkg/utils/doc_utils.go b/pkg/utils/doc_utils.go new file mode 100644 index 000000000..a8d302acc --- /dev/null +++ b/pkg/utils/doc_utils.go @@ -0,0 +1,39 @@ +package utils + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// DisplayDocs displays component documentation directly through the terminal or +// through a pager (like less). The use of a pager is determined by the pagination value +// set in the CLI Settings for Atmos +func DisplayDocs(componentDocs string, usePager bool) error { + if !usePager { + fmt.Println(componentDocs) + return nil + } + + pagerCmd := os.Getenv("PAGER") + if pagerCmd == "" { + pagerCmd = "less -r" + } + + args := strings.Fields(pagerCmd) + if len(args) == 0 { + return fmt.Errorf("invalid pager command") + } + + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdin = strings.NewReader(componentDocs) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to execute pager: %w", err) + } + + return nil +} diff --git a/pkg/utils/json_utils.go b/pkg/utils/json_utils.go index aeb8ad4b6..d9e9c53fc 100644 --- a/pkg/utils/json_utils.go +++ b/pkg/utils/json_utils.go @@ -51,6 +51,13 @@ func WriteToFileAsJSON(filePath string, data any, fileMode os.FileMode) error { return err } + const newlineByte = '\n' + + // Ensure that the JSON content ends with a newline + if len(indentedJSON) == 0 || indentedJSON[len(indentedJSON)-1] != newlineByte { + indentedJSON = append(indentedJSON, newlineByte) + } + err = os.WriteFile(filePath, indentedJSON, fileMode) if err != nil { return err diff --git a/website/docs/cli/commands/help.mdx b/website/docs/cli/commands/help.mdx index fcbc1c2e3..1df2ef3a1 100644 --- a/website/docs/cli/commands/help.mdx +++ b/website/docs/cli/commands/help.mdx @@ -13,6 +13,10 @@ import Terminal from '@site/src/components/Terminal' The `atmos --help` and `atmos -h` commands show help for all Atmos CLI commands. +From time to time, Atmos will check for a newer release and let you know if one is available. +Please see the [`atmos version`](/cli/commands/version) documentation to configure this behavior. + + ```shell atmos help atmos --help diff --git a/website/docs/cli/commands/version.mdx b/website/docs/cli/commands/version.mdx index a8aabaf56..2deb20a4f 100644 --- a/website/docs/cli/commands/version.mdx +++ b/website/docs/cli/commands/version.mdx @@ -20,7 +20,27 @@ Execute the `atmos version` command like this: atmos version ``` -This will show the CLI version. +This will show the CLI version. + +From time to time, Atmos will check for updates. The frequency of these checks is configured in the `atmos.yaml` file. + +Atmos supports three ways to specify the update check frequency: + +1. As an integer: Specify the number of seconds between checks (for example, 3600 for hourly checks). +2. As a duration with a suffix: Use a time suffix to indicate the interval (for example, `1m` for one minute, `5h` for five hours, or `2d` for two days). +3. As one of the predefined keywords: Choose from the following options: minute, hourly, daily, weekly, monthly, and yearly. The default is daily. +The default is to check `daily`, and if any unsupported values are passed this default will be used. + +It is also possible to turn off version checks in `atmos.yaml` by setting `version.check.enabled` to `false`, +or by setting the `ATMOS_VERSION_CHECK_ENABLED` environment variable to `false`, which overrides +the `version.check.enabled` settings in `atmos.yaml`. + +```shell +atmos version --check +``` + +The version command supports a `--check` flag. +This will force Atmos to check for a new version, irrespective of the configuration settings. :::tip To find the latest version of Atmos, go to the [releases](https://github.com/cloudposse/atmos/releases) page on GitHub. diff --git a/website/docs/cli/configuration/configuration.mdx b/website/docs/cli/configuration/configuration.mdx index bcc4761e7..9268c0cb3 100644 --- a/website/docs/cli/configuration/configuration.mdx +++ b/website/docs/cli/configuration/configuration.mdx @@ -151,6 +151,13 @@ The `settings` section configures Atmos global settings. # If the source and destination lists have the same length, all items in the destination lists are # deep-merged with all items in the source list. list_merge_strategy: replace + # `docs` specifies how component documentation is displayed in the terminal. + # The following documentation display settings are supported: + # `max-width`: The maximum width for displaying component documentation in the terminal. + # 'pagination`: When enabled, displays component documentation in a pager instead of directly in the terminal. + docs: + max-width: 80 + pagination: true ``` @@ -171,9 +178,21 @@ The `settings` section configures Atmos global settings.