diff --git a/cmd/spawn/local-interchain.go b/cmd/spawn/local-interchain.go index 472bb8e5..38e8d88a 100644 --- a/cmd/spawn/local-interchain.go +++ b/cmd/spawn/local-interchain.go @@ -2,8 +2,6 @@ package main import ( "os" - "os/exec" - "path" "github.com/spf13/cobra" @@ -16,6 +14,7 @@ const ( func init() { LocalICCmd.Flags().Bool(FlagLocationPath, false, "print the location of local-ic binary") + } // --- @@ -34,12 +33,7 @@ var LocalICCmd = &cobra.Command{ logger := GetLogger() - loc := whereIsLocalICInstalled() - if loc == "" { - logger.Error("local-ic not found. Please run `make get-localic`") - return - } - + loc := spawn.WhereIsBinInstalled("local-ic") if debugBinaryLoc { logger.Debug("local-ic binary", "location", loc) return @@ -61,17 +55,3 @@ var LocalICCmd = &cobra.Command{ } }, } - -func whereIsLocalICInstalled() string { - for _, path := range []string{"local-ic", path.Join("bin", "local-ic"), path.Join("local-interchain", "localic")} { - if _, err := os.Stat(path); err == nil { - return path - } - } - - if path, err := exec.LookPath("local-ic"); err == nil { - return path - } - - return "" -} diff --git a/cmd/spawn/main.go b/cmd/spawn/main.go index cdfa62bf..b78effc7 100644 --- a/cmd/spawn/main.go +++ b/cmd/spawn/main.go @@ -2,34 +2,51 @@ package main import ( "fmt" - "io/fs" "log" "log/slog" "os" - "os/exec" - "path" "strings" "time" "github.com/lmittmann/tint" "github.com/mattn/go-isatty" + "github.com/rollchains/spawn/spawn" "github.com/spf13/cobra" ) -// Set in the makefile ld_flags on compile -var SpawnVersion = "" - -var LogLevelFlag = "log-level" +var ( + // Set in the makefile ld_flags on compile + SpawnVersion = "" + LogLevelFlag = "log-level" + rootCmd = &cobra.Command{ + Use: "spawn", + Short: "Entry into the Interchain | Contact us: support@rollchains.com", + CompletionOptions: cobra.CompletionOptions{ + HiddenDefaultCmd: false, + }, + Run: func(cmd *cobra.Command, args []string) { + if err := cmd.Help(); err != nil { + log.Fatal(err) + } + }, + } +) func main() { + outOfDateChecker() rootCmd.AddCommand(newChain) rootCmd.AddCommand(LocalICCmd) - rootCmd.AddCommand(versionCmd) + rootCmd.AddCommand(&cobra.Command{ + Use: "version", + Short: "Print the version number of spawn", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println(SpawnVersion) + }, + }) rootCmd.AddCommand(ModuleCmd()) rootCmd.AddCommand(ProtoServiceGenerate()) rootCmd.AddCommand(DocsCmd) - rootCmd.AddCommand(contactCmd) applyPluginCmds() @@ -59,123 +76,41 @@ func GetLogger() *slog.Logger { return slog.Default() } -var PluginsCmd = &cobra.Command{ - Use: "plugins", - Short: "Spawn Plugins", - Aliases: []string{"plugin", "plug", "pl"}, - Run: func(cmd *cobra.Command, args []string) { - if err := cmd.Help(); err != nil { - log.Fatal(err) - } - }, -} +// outOfDateChecker checks if binaries are up to date and logs if they are not. +// if not, it will prompt the user every command they run with spawn until they update. +// else, it will wait 24h+ before checking again. +func outOfDateChecker() { + logger := GetLogger() -func applyPluginCmds() { - for name, abspath := range loadPlugins() { - name := name - abspath := abspath + if !spawn.DoOutdatedNotificationRunCheck(logger) { + return + } - info, err := ParseCobraCLICmd(abspath) + for _, program := range []string{"local-ic", "spawn"} { + releases, err := spawn.GetLatestGithubReleases(spawn.BinaryToGithubAPI[program]) if err != nil { - GetLogger().Warn("error parsing the CLI commands from the plugin", "name", name, "error", err) - continue + logger.Error("Error getting latest local-ic releases", "err", err) + return } + latest := releases[0].TagName - execCmd := &cobra.Command{ - Use: name, - Short: info.Description, - Run: func(cmd *cobra.Command, args []string) { - output, err := exec.Command(abspath, args...).CombinedOutput() - if err != nil { - fmt.Println(err.Error()) - } - fmt.Println(string(output)) - }, - } - PluginsCmd.AddCommand(execCmd) - } - - rootCmd.AddCommand(PluginsCmd) -} - -// returns name and path -func loadPlugins() map[string]string { - p := make(map[string]string) - - logger := GetLogger() - - homeDir, err := os.UserHomeDir() - if err != nil { - panic(err) - } - - pluginsDir := path.Join(homeDir, ".spawn", "plugins") + current := spawn.GetLocalVersion(logger, program, latest) + if spawn.OutOfDateCheckLog(logger, program, current, latest) { + // write check to -24h from now to spam the user until it's resolved. - d := os.DirFS(pluginsDir) - if _, err := d.Open("."); err != nil { - if os.IsNotExist(err) { - if err := os.MkdirAll(pluginsDir, 0755); err != nil { - panic(err) + file, err := spawn.GetLatestVersionCheckFile(logger) + if err != nil { + return } - } else { - panic(err) - } - } - err = fs.WalkDir(d, ".", func(relPath string, d fs.DirEntry, e error) error { - if d.IsDir() { - return nil - } - - // /home/username/.spawn/plugins/myplugin - absPath := path.Join(pluginsDir, relPath) + if err := spawn.WriteLastTimeToFile(logger, file, time.Now().Add(-spawn.RunCheckInterval)); err != nil { + logger.Error("Error writing last check file", "err", err) + return + } - // ensure path exist - if _, err := os.Stat(absPath); os.IsNotExist(err) { - logger.Error(fmt.Sprintf("Plugin %s does not exist. Skipping", absPath)) - return nil + return } - - name := path.Base(absPath) - p[name] = absPath - return nil - }) - if err != nil { - logger.Error(fmt.Sprintf("Error walking the path %s: %v", pluginsDir, err)) - panic(err) } - - return p -} - -var rootCmd = &cobra.Command{ - Use: "spawn", - Short: "Entry into the Interchain", - CompletionOptions: cobra.CompletionOptions{ - HiddenDefaultCmd: false, - }, - Run: func(cmd *cobra.Command, args []string) { - if err := cmd.Help(); err != nil { - log.Fatal(err) - } - }, -} - -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Print the version number of spawn", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println(SpawnVersion) - }, -} - -var contactCmd = &cobra.Command{ - Use: "email", - Aliases: []string{"contact"}, - Short: "Reach out and connect with us!", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("Email us! support@rollchains.com") - }, } func parseLogLevelFromFlags() slog.Level { diff --git a/cmd/spawn/new_chain_test.go b/cmd/spawn/new_chain_test.go index 1411e99a..457cae07 100644 --- a/cmd/spawn/new_chain_test.go +++ b/cmd/spawn/new_chain_test.go @@ -36,6 +36,7 @@ func TestDisabledGeneration(t *testing.T) { } // custom cases + // NOTE: block-explorer is disabled for all cases. disabledCases := []disabledCase{ { // by default ICS is used @@ -70,14 +71,14 @@ func TestDisabledGeneration(t *testing.T) { disabledCases = append(disabledCases, disabledCase{ Name: normalizedName, - Disabled: []string{f}, + Disabled: []string{f, spawn.BlockExplorer}, }) } for _, c := range disabledCases { c := c name := "spawnunittest" + c.Name - dc := c.Disabled + dc := append(c.Disabled, spawn.BlockExplorer) fmt.Println("=====\ndisabled cases", name, dc) diff --git a/cmd/spawn/plugins.go b/cmd/spawn/plugins.go new file mode 100644 index 00000000..f48cce92 --- /dev/null +++ b/cmd/spawn/plugins.go @@ -0,0 +1,101 @@ +package main + +import ( + "fmt" + "io/fs" + "log" + "os" + "os/exec" + "path" + + "github.com/spf13/cobra" +) + +var PluginsCmd = &cobra.Command{ + Use: "plugins", + Short: "Spawn Plugins", + Aliases: []string{"plugin", "plug", "pl"}, + Run: func(cmd *cobra.Command, args []string) { + if err := cmd.Help(); err != nil { + log.Fatal(err) + } + }, +} + +func applyPluginCmds() { + for name, abspath := range loadPlugins() { + name := name + abspath := abspath + + info, err := ParseCobraCLICmd(abspath) + if err != nil { + GetLogger().Warn("error parsing the CLI commands from the plugin", "name", name, "error", err) + continue + } + + execCmd := &cobra.Command{ + Use: name, + Short: info.Description, + Run: func(cmd *cobra.Command, args []string) { + output, err := exec.Command(abspath, args...).CombinedOutput() + if err != nil { + fmt.Println(err.Error()) + } + fmt.Println(string(output)) + }, + } + PluginsCmd.AddCommand(execCmd) + } + + rootCmd.AddCommand(PluginsCmd) +} + +// returns name and path +func loadPlugins() map[string]string { + p := make(map[string]string) + + logger := GetLogger() + + homeDir, err := os.UserHomeDir() + if err != nil { + panic(err) + } + + pluginsDir := path.Join(homeDir, ".spawn", "plugins") + + d := os.DirFS(pluginsDir) + if _, err := d.Open("."); err != nil { + if os.IsNotExist(err) { + if err := os.MkdirAll(pluginsDir, 0755); err != nil { + panic(err) + } + } else { + panic(err) + } + } + + err = fs.WalkDir(d, ".", func(relPath string, d fs.DirEntry, e error) error { + if d.IsDir() { + return nil + } + + // /home/username/.spawn/plugins/myplugin + absPath := path.Join(pluginsDir, relPath) + + // ensure path exist + if _, err := os.Stat(absPath); os.IsNotExist(err) { + logger.Error(fmt.Sprintf("Plugin %s does not exist. Skipping", absPath)) + return nil + } + + name := path.Base(absPath) + p[name] = absPath + return nil + }) + if err != nil { + logger.Error(fmt.Sprintf("Error walking the path %s: %v", pluginsDir, err)) + panic(err) + } + + return p +} diff --git a/spawn/command.go b/spawn/command.go index 0a5094a6..e5d4e894 100644 --- a/spawn/command.go +++ b/spawn/command.go @@ -12,6 +12,11 @@ func ExecCommand(command string, args ...string) error { return cmd.Run() } +func ExecCommandWithOutput(command string, args ...string) ([]byte, error) { + cmd := exec.Command(command, args...) + return cmd.CombinedOutput() +} + func (cfg *NewChainConfig) GitInitNewProjectRepo() { if err := ExecCommand("git", "init", cfg.ProjectName, "--quiet"); err != nil { cfg.Logger.Error("Error initializing git", "err", err) diff --git a/spawn/version_check.go b/spawn/version_check.go new file mode 100644 index 00000000..17607a14 --- /dev/null +++ b/spawn/version_check.go @@ -0,0 +1,237 @@ +package spawn + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "os" + "os/exec" + "path" + "strings" + "time" + + "golang.org/x/mod/semver" +) + +var ( + RunCheckInterval = 24 * time.Hour + howToInstallBinary = map[string]string{ + "local-ic": "git clone https://github.com/strangelove-ventures/interchaintest.git && cd interchaintest/local-interchain && git checkout __VERSION__ && make install", + "spawn": "git clone https://github.com/rollchains/spawn.git && cd spawn && git checkout __VERSION__ && make install", + } + BinaryToGithubAPI = map[string]string{ + "local-ic": "https://api.github.com/repos/strangelove-ventures/interchaintest/releases", + "spawn": "https://api.github.com/repos/rollchains/spawn/releases", + } +) + +type ( + Release struct { + Id int64 `json:"id"` + Name string `json:"name"` + TagName string `json:"tag_name"` + PublishedAt string `json:"published_at"` + Assets []Asset `json:"assets"` + + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` + } + Asset struct { + URL string `json:"url"` + ID int `json:"id"` + NodeID string `json:"node_id"` + Name string `json:"name"` + Label string `json:"label"` + Uploader struct { + Login string `json:"login"` + ID int `json:"id"` + NodeID string `json:"node_id"` + AvatarURL string `json:"avatar_url"` + GravatarID string `json:"gravatar_id"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + FollowersURL string `json:"followers_url"` + FollowingURL string `json:"following_url"` + GistsURL string `json:"gists_url"` + StarredURL string `json:"starred_url"` + SubscriptionsURL string `json:"subscriptions_url"` + OrganizationsURL string `json:"organizations_url"` + ReposURL string `json:"repos_url"` + EventsURL string `json:"events_url"` + ReceivedEventsURL string `json:"received_events_url"` + Type string `json:"type"` + SiteAdmin bool `json:"site_admin"` + } `json:"uploader"` + ContentType string `json:"content_type"` + State string `json:"state"` + Size int `json:"size"` + DownloadCount int `json:"download_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + BrowserDownloadURL string `json:"browser_download_url"` + } +) + +func GetLatestGithubReleases(apiRepoURL string) ([]Release, error) { + client := http.Client{ + Timeout: 5 * time.Second, + } + req, err := http.NewRequest(http.MethodGet, apiRepoURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + // parse response + var releases []Release + if err := json.Unmarshal(body, &releases); err != nil { + return nil, err + } + + return releases, nil +} + +func GetLocalVersion(logger *slog.Logger, binName, latestVer string) string { + loc := WhereIsBinInstalled(binName) + if loc == "" { + // WhereIsBinInstalled loggers error already + return "" + } + + output, err := ExecCommandWithOutput(loc, "version") + if err != nil { + // typically old spawn / local-ic versions + logger.Error("Error calling version command", "bin", binName+" version", "err", err) + return "v0.0.0" + } + + out := string(output) + + if i := strings.Index(out, "-"); i != -1 { + out = semver.Canonical(out[:i]) + } + + if out == "" { + logger.Debug("Could not parse version", "output", out, "setting to", "v0.0.0") + out = "v0.0.0" + } + + return out +} + +func WhereIsBinInstalled(binName string) string { + // looks for relative paths as well as the global $PATH for binaries + for _, path := range []string{binName, path.Join("bin", binName), path.Join("local-interchain", binName)} { + if _, err := os.Stat(path); err == nil { + return path + } + } + + if path, err := exec.LookPath(binName); err == nil { + return path + } + + return "" +} + +// OutOfDateCheckLog logs & returns true if it is out of date. +func OutOfDateCheckLog(logger *slog.Logger, binName, current, latest string) bool { + isOutOfDate := semver.Compare(current, latest) < 0 + if isOutOfDate { + logger.Error( + "New "+binName+" version available", + "current", current, + "latest", latest, + "install", GetInstallMsg(howToInstallBinary[binName], latest), + ) + } + return isOutOfDate +} + +func GetInstallMsg(msg, latestVer string) string { + return strings.ReplaceAll(msg, "__VERSION__", latestVer) +} + +// DoOutdatedNotificationRunCheck returns true if it is time to run the check again. +// It saves the last run time to a file for future runs. +func DoOutdatedNotificationRunCheck(logger *slog.Logger) bool { + now := time.Now() + + lastCheckFile, err := GetLatestVersionCheckFile(logger) + if err != nil { + return false + } + + lastCheck, err := os.ReadFile(lastCheckFile) + if err != nil { + logger.Error("Error reading last check file", "err", err) + return false + } + + lastCheckTime, err := time.Parse(time.RFC3339, string(lastCheck)) + if err != nil { + // i.e. empty file + lastCheckTime = time.Unix(0, 0) + } + + diff := now.Sub(lastCheckTime) + isTime := diff > RunCheckInterval + + if isTime { + if err := WriteLastTimeToFile(logger, lastCheckFile, now); err != nil { + logger.Error("Error writing last check file", "err", err) + return false + } + } + + return isTime +} + +func WriteLastTimeToFile(logger *slog.Logger, lastCheckFile string, t time.Time) error { + return os.WriteFile(lastCheckFile, []byte(t.Format(time.RFC3339)), 0644) +} + +// GetLatestVersionCheckFile grabs the check file used to determine when to run the version check. +func GetLatestVersionCheckFile(logger *slog.Logger) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + logger.Error("Error getting user home dir", "err", err) + return "", err + } + + spawnDir := path.Join(home, ".spawn") + if _, err := os.Stat(spawnDir); os.IsNotExist(err) { + if err := os.Mkdir(spawnDir, 0755); err != nil { + logger.Error("Error creating home spawn dir", "err", err) + return "", err + } + } + + lastCheckFile := path.Join(spawnDir, "last_ver_check.txt") + if _, err := os.Stat(lastCheckFile); os.IsNotExist(err) { + if _, err := os.Create(lastCheckFile); err != nil { + logger.Error("Error creating last check file", "err", err) + return "", err + } + + epoch := time.Unix(0, 0) + if err := os.WriteFile(lastCheckFile, []byte(epoch.Format(time.RFC3339)), 0644); err != nil { + logger.Error("Error writing last check file", "err", err) + return "", err + } + } + + return lastCheckFile, nil +}