From 1c1d96cb1535e5abeac93f60940827dccfb18048 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Mon, 30 Oct 2023 16:33:57 -0400 Subject: [PATCH] feat: add cli --- Taskfile.yml | 6 +- cmd/sonrd/cmd/plugin.go | 583 +++++++++++++++++++ cmd/sonrd/cmd/plugin_default.go | 108 ++++ cmd/sonrd/cmd/root.go | 6 +- config/config.go | 8 + config/plugins/config.go | 152 +++++ config/plugins/config_test.go | 246 ++++++++ config/plugins/parse.go | 75 +++ sonr.yml => config/sonr.yml | 0 docker/Taskfile.yml | 11 +- docker/start/Dockerfile.dev | 49 +- docker/start/docker-compose.yml | 17 +- docker/start/sonr.yml | 51 ++ go.mod | 61 +- go.sum | 144 ++++- pkg/cache/cache.go | 149 +++++ pkg/cache/cache_test.go | 170 ++++++ pkg/clictx/clictx.go | 34 ++ pkg/cliui/cliquiz/question.go | 210 +++++++ pkg/cliui/clispinner/clispinner.go | 118 ++++ pkg/cliui/cliui.go | 299 ++++++++++ pkg/cliui/colors/colors.go | 71 +++ pkg/cliui/entrywriter/entrywriter.go | 66 +++ pkg/cliui/entrywriter/entrywriter_test.go | 40 ++ pkg/cliui/icons/icon.go | 22 + pkg/cliui/lineprefixer/lineprefixer.go | 48 ++ pkg/cliui/lineprefixer/lineprefixer_test.go | 27 + pkg/cliui/log/output.go | 146 +++++ pkg/cliui/model/events.go | 255 +++++++++ pkg/cliui/model/events_test.go | 88 +++ pkg/cliui/model/model.go | 29 + pkg/cliui/model/spinner.go | 25 + pkg/cliui/prefixgen/prefixgen.go | 88 +++ pkg/cliui/prefixgen/prefixgen_test.go | 23 + pkg/cliui/view/accountview/account.go | 82 +++ pkg/cliui/view/errorview/error.go | 28 + pkg/cmdrunner/cmdrunner.go | 262 +++++++++ pkg/cmdrunner/exec/exec.go | 87 +++ pkg/cmdrunner/step/step.go | 118 ++++ pkg/env/env.go | 37 ++ pkg/events/bus.go | 95 ++++ pkg/events/events.go | 116 ++++ pkg/events/events_test.go | 54 ++ pkg/gocmd/gocmd.go | 230 ++++++++ pkg/goenv/goenv.go | 57 ++ pkg/gomodule/gomodule.go | 136 +++++ pkg/randstr/randstr.go | 16 + pkg/xfilepath/xfilepath.go | 88 +++ pkg/xfilepath/xfilepath_test.go | 101 ++++ pkg/xgit/xgit.go | 156 ++++++ pkg/xgit/xgit_test.go | 434 ++++++++++++++ pkg/xio/xio.go | 16 + pkg/xnet/xnet.go | 55 ++ pkg/xnet/xnet_test.go | 55 ++ pkg/xos/cp.go | 63 +++ pkg/xos/cp_test.go | 168 ++++++ pkg/xos/files.go | 37 ++ pkg/xos/files_test.go | 132 +++++ pkg/xos/mv.go | 33 ++ pkg/xos/mv_test.go | 32 ++ pkg/xos/rm.go | 14 + pkg/xstrings/xstrings.go | 72 +++ pkg/xstrings/xstrings_test.go | 20 + pkg/xtime/clock.go | 49 ++ pkg/xtime/clock_test.go | 24 + pkg/xtime/unix.go | 26 + pkg/xtime/unix_test.go | 68 +++ pkg/xurl/xurl.go | 138 +++++ pkg/xurl/xurl_test.go | 378 +++++++++++++ pkg/yaml/map.go | 53 ++ pkg/yaml/map_test.go | 44 ++ pkg/yaml/yaml.go | 43 ++ services/plugin/cache.go | 88 +++ services/plugin/plugin.go | 592 ++++++++++++-------- 74 files changed, 7409 insertions(+), 313 deletions(-) create mode 100644 cmd/sonrd/cmd/plugin.go create mode 100644 cmd/sonrd/cmd/plugin_default.go create mode 100644 config/plugins/config.go create mode 100644 config/plugins/config_test.go create mode 100644 config/plugins/parse.go rename sonr.yml => config/sonr.yml (100%) create mode 100644 docker/start/sonr.yml create mode 100644 pkg/cache/cache.go create mode 100644 pkg/cache/cache_test.go create mode 100755 pkg/clictx/clictx.go create mode 100755 pkg/cliui/cliquiz/question.go create mode 100755 pkg/cliui/clispinner/clispinner.go create mode 100755 pkg/cliui/cliui.go create mode 100755 pkg/cliui/colors/colors.go create mode 100755 pkg/cliui/entrywriter/entrywriter.go create mode 100755 pkg/cliui/entrywriter/entrywriter_test.go create mode 100755 pkg/cliui/icons/icon.go create mode 100755 pkg/cliui/lineprefixer/lineprefixer.go create mode 100755 pkg/cliui/lineprefixer/lineprefixer_test.go create mode 100755 pkg/cliui/log/output.go create mode 100755 pkg/cliui/model/events.go create mode 100755 pkg/cliui/model/events_test.go create mode 100755 pkg/cliui/model/model.go create mode 100755 pkg/cliui/model/spinner.go create mode 100755 pkg/cliui/prefixgen/prefixgen.go create mode 100755 pkg/cliui/prefixgen/prefixgen_test.go create mode 100755 pkg/cliui/view/accountview/account.go create mode 100755 pkg/cliui/view/errorview/error.go create mode 100644 pkg/cmdrunner/cmdrunner.go create mode 100644 pkg/cmdrunner/exec/exec.go create mode 100644 pkg/cmdrunner/step/step.go create mode 100644 pkg/env/env.go create mode 100644 pkg/events/bus.go create mode 100644 pkg/events/events.go create mode 100644 pkg/events/events_test.go create mode 100644 pkg/gocmd/gocmd.go create mode 100644 pkg/goenv/goenv.go create mode 100644 pkg/gomodule/gomodule.go create mode 100644 pkg/randstr/randstr.go create mode 100644 pkg/xfilepath/xfilepath.go create mode 100644 pkg/xfilepath/xfilepath_test.go create mode 100644 pkg/xgit/xgit.go create mode 100644 pkg/xgit/xgit_test.go create mode 100755 pkg/xio/xio.go create mode 100755 pkg/xnet/xnet.go create mode 100755 pkg/xnet/xnet_test.go create mode 100755 pkg/xos/cp.go create mode 100755 pkg/xos/cp_test.go create mode 100755 pkg/xos/files.go create mode 100755 pkg/xos/files_test.go create mode 100755 pkg/xos/mv.go create mode 100755 pkg/xos/mv_test.go create mode 100755 pkg/xos/rm.go create mode 100755 pkg/xstrings/xstrings.go create mode 100755 pkg/xstrings/xstrings_test.go create mode 100755 pkg/xtime/clock.go create mode 100755 pkg/xtime/clock_test.go create mode 100755 pkg/xtime/unix.go create mode 100755 pkg/xtime/unix_test.go create mode 100644 pkg/xurl/xurl.go create mode 100644 pkg/xurl/xurl_test.go create mode 100755 pkg/yaml/map.go create mode 100755 pkg/yaml/map_test.go create mode 100755 pkg/yaml/yaml.go create mode 100644 services/plugin/cache.go diff --git a/Taskfile.yml b/Taskfile.yml index 37930072c..ac192ed25 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -40,11 +40,15 @@ tasks: - task -l build: - desc: Build the binary for all platforms + aliases: + - b + desc: Build the binary for current platform cmds: - make build build-all: + aliases: + - ba desc: Build the binary for all platforms and docker cmds: - make build diff --git a/cmd/sonrd/cmd/plugin.go b/cmd/sonrd/cmd/plugin.go new file mode 100644 index 000000000..3862cf6f7 --- /dev/null +++ b/cmd/sonrd/cmd/plugin.go @@ -0,0 +1,583 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + flag "github.com/spf13/pflag" + + pluginsconfig "github.com/sonr-io/core/config/plugins" + "github.com/sonr-io/core/pkg/clictx" + "github.com/sonr-io/core/pkg/cliui" + "github.com/sonr-io/core/pkg/cliui/icons" + "github.com/sonr-io/core/services/plugin" +) + +const ( + flagPluginsGlobal = "global" +) + +// plugins hold the list of plugin declared in the config. +// A global variable is used so the list is accessible to the plugin commands. +var plugins []*plugin.Plugin + +// LoadPlugins tries to load all the plugins found in configurations. +// If no configurations found, it returns w/o error. +func LoadPlugins(ctx context.Context, cmd *cobra.Command) error { + var ( + rootCmd = cmd.Root() + pluginsConfigs []pluginsconfig.Plugin + ) + localCfg, err := parseLocalPlugins(rootCmd) + if err != nil { + return err + } else if err == nil { + pluginsConfigs = append(pluginsConfigs, localCfg.Plugins...) + } + + globalCfg, err := parseGlobalPlugins() + if err == nil { + pluginsConfigs = append(pluginsConfigs, globalCfg.Plugins...) + } + ensureDefaultPlugins(cmd, globalCfg) + + if len(pluginsConfigs) == 0 { + return nil + } + + session := cliui.New(cliui.WithStdout(os.Stdout)) + defer session.End() + + uniquePlugins := pluginsconfig.RemoveDuplicates(pluginsConfigs) + plugins, err = plugin.Load(ctx, uniquePlugins, plugin.CollectEvents(session.EventBus())) + if err != nil { + return err + } + if len(plugins) == 0 { + return nil + } + + return linkPlugins(rootCmd, plugins) +} + +func parseLocalPlugins(cmd *cobra.Command) (*pluginsconfig.Config, error) { + // FIXME(tb): like other commands that works on a chain directory, + // parseLocalPlugins should rely on `-p` flag to guess that chain directory. + // Unfortunately parseLocalPlugins is invoked before flags are parsed, so + // we cannot rely on `-p` flag. As a workaround, we use the working dir. + // The drawback is we cannot load chain's plugin when using `-p`. + _ = cmd + wd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("parse local plugins: %w", err) + } + return pluginsconfig.ParseDir(wd) +} + +func parseGlobalPlugins() (cfg *pluginsconfig.Config, err error) { + globalDir, err := plugin.PluginsPath() + if err != nil { + return cfg, err + } + + cfg, err = pluginsconfig.ParseDir(globalDir) + // if there is error parsing, return empty config and continue execution to load + // local plugins if they exist. + if err != nil { + return &pluginsconfig.Config{}, nil + } + + for i := range cfg.Plugins { + cfg.Plugins[i].Global = true + } + return +} + +func linkPlugins(rootCmd *cobra.Command, plugins []*plugin.Plugin) error { + // Link plugins to related commands + var linkErrors []*plugin.Plugin + for _, p := range plugins { + if p.Error != nil { + linkErrors = append(linkErrors, p) + continue + } + manifest, err := p.Interface.Manifest() + if err != nil { + p.Error = fmt.Errorf("Manifest() error: %w", err) + continue + } + linkPluginHooks(rootCmd, p, manifest.Hooks) + if p.Error != nil { + linkErrors = append(linkErrors, p) + continue + } + linkPluginCmds(rootCmd, p, manifest.Commands) + if p.Error != nil { + linkErrors = append(linkErrors, p) + continue + } + } + if len(linkErrors) > 0 { + // unload any plugin that could have been loaded + defer UnloadPlugins() + if err := printPlugins(cliui.New(cliui.WithStdout(os.Stdout))); err != nil { + // content of loadErrors is more important than a print error, so we don't + // return here, just print the error. + fmt.Printf("fail to print: %v\n", err) + } + var s strings.Builder + for _, p := range linkErrors { + fmt.Fprintf(&s, "%s: %v", p.Path, p.Error) + } + return errors.Errorf("fail to link: %v", s.String()) + } + return nil +} + +// UnloadPlugins releases any loaded plugins, which is basically killing the +// plugin server instance. +func UnloadPlugins() { + for _, p := range plugins { + p.KillClient() + } +} + +func linkPluginHooks(rootCmd *cobra.Command, p *plugin.Plugin, hooks []plugin.Hook) { + if p.Error != nil { + return + } + for _, hook := range hooks { + linkPluginHook(rootCmd, p, hook) + } +} + +func linkPluginHook(rootCmd *cobra.Command, p *plugin.Plugin, hook plugin.Hook) { + cmdPath := hook.PlaceHookOnFull() + cmd := findCommandByPath(rootCmd, cmdPath) + if cmd == nil { + p.Error = errors.Errorf("unable to find commandPath %q for plugin hook %q", cmdPath, hook.Name) + return + } + if !cmd.Runnable() { + p.Error = errors.Errorf("can't attach plugin hook %q to non executable command %q", hook.Name, hook.PlaceHookOn) + return + } + + newExecutedHook := func(hook plugin.Hook, cmd *cobra.Command, args []string) plugin.ExecutedHook { + execHook := plugin.ExecutedHook{ + Hook: hook, + ExecutedCommand: plugin.ExecutedCommand{ + Use: cmd.Use, + Path: cmd.CommandPath(), + Args: args, + OSArgs: os.Args, + With: p.With, + }, + } + execHook.ExecutedCommand.SetFlags(cmd) + return execHook + } + + preRun := cmd.PreRunE + cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + if preRun != nil { + err := preRun(cmd, args) + if err != nil { + return err + } + } + err := p.Interface.ExecuteHookPre(newExecutedHook(hook, cmd, args)) + if err != nil { + return fmt.Errorf("plugin %q ExecuteHookPre() error: %w", p.Path, err) + } + return nil + } + + runCmd := cmd.RunE + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if runCmd != nil { + err := runCmd(cmd, args) + // if the command has failed the `PostRun` will not execute. here we execute the cleanup step before returnning. + if err != nil { + err := p.Interface.ExecuteHookCleanUp(newExecutedHook(hook, cmd, args)) + if err != nil { + fmt.Printf("plugin %q ExecuteHookCleanUp() error: %v", p.Path, err) + } + } + return err + } + + time.Sleep(100 * time.Millisecond) + return nil + } + + postCmd := cmd.PostRunE + cmd.PostRunE = func(cmd *cobra.Command, args []string) error { + execHook := newExecutedHook(hook, cmd, args) + + defer func() { + err := p.Interface.ExecuteHookCleanUp(execHook) + if err != nil { + fmt.Printf("plugin %q ExecuteHookCleanUp() error: %v", p.Path, err) + } + }() + + if preRun != nil { + err := postCmd(cmd, args) + if err != nil { + // dont return the error, log it and let execution continue to `Run` + return err + } + } + + err := p.Interface.ExecuteHookPost(execHook) + if err != nil { + return fmt.Errorf("plugin %q ExecuteHookPost() error : %w", p.Path, err) + } + return nil + } +} + +// linkPluginCmds tries to add the plugin commands to the legacy ignite +// commands. +func linkPluginCmds(rootCmd *cobra.Command, p *plugin.Plugin, pluginCmds []plugin.Command) { + if p.Error != nil { + return + } + for _, pluginCmd := range pluginCmds { + linkPluginCmd(rootCmd, p, pluginCmd) + if p.Error != nil { + return + } + } +} + +func linkPluginCmd(rootCmd *cobra.Command, p *plugin.Plugin, pluginCmd plugin.Command) { + cmdPath := pluginCmd.PlaceCommandUnderFull() + cmd := findCommandByPath(rootCmd, cmdPath) + if cmd == nil { + p.Error = errors.Errorf("unable to find commandPath %q for plugin %q", cmdPath, p.Path) + return + } + if cmd.Runnable() { + p.Error = errors.Errorf("can't attach plugin command %q to runnable command %q", pluginCmd.Use, cmd.CommandPath()) + return + } + + // Check for existing commands + // pluginCmd.Use can be like `command [args]` so we need to remove those + // extra args if any. + pluginCmdName := strings.Split(pluginCmd.Use, " ")[0] + for _, cmd := range cmd.Commands() { + if cmd.Name() == pluginCmdName { + p.Error = errors.Errorf("plugin command %q already exists in ignite's commands", pluginCmdName) + return + } + } + + newCmd, err := pluginCmd.ToCobraCommand() + if err != nil { + p.Error = err + return + } + cmd.AddCommand(newCmd) + + // NOTE(tb) we could probably simplify by removing this condition and call the + // plugin even if the invoked command isn't runnable. If we do so, the plugin + // will be responsible for outputing the standard cobra output, which implies + // it must use cobra too. This is how cli-plugin-network works, but to make + // it for all, we need to change the `plugin scaffold` output (so it outputs + // something similar than the cli-plugin-network) and update the docs. + if len(pluginCmd.Commands) == 0 { + // pluginCmd has no sub commands, so it's runnable + newCmd.RunE = func(cmd *cobra.Command, args []string) error { + return clictx.Do(cmd.Context(), func() error { + execCmd := plugin.ExecutedCommand{ + Use: cmd.Use, + Path: cmd.CommandPath(), + Args: args, + OSArgs: os.Args, + With: p.With, + } + execCmd.SetFlags(cmd) + // Call the plugin Execute + err := p.Interface.Execute(execCmd) + // NOTE(tb): This pause gives enough time for go-plugin to sync the + // output from stdout/stderr of the plugin. Without that pause, this + // output can be discarded and not printed in the user console. + time.Sleep(100 * time.Millisecond) + return err + }) + } + } else { + for _, pluginCmd := range pluginCmd.Commands { + pluginCmd.PlaceCommandUnder = newCmd.CommandPath() + linkPluginCmd(newCmd, p, pluginCmd) + if p.Error != nil { + return + } + } + } +} + +func findCommandByPath(cmd *cobra.Command, cmdPath string) *cobra.Command { + if cmd.CommandPath() == cmdPath { + return cmd + } + for _, cmd := range cmd.Commands() { + if cmd := findCommandByPath(cmd, cmdPath); cmd != nil { + return cmd + } + } + return nil +} + +// NewPlugin returns a command that groups plugin related sub commands. +func NewPlugin() *cobra.Command { + c := &cobra.Command{ + Use: "plugin [command]", + Short: "Handle plugins", + } + + c.AddCommand(NewPluginList()) + c.AddCommand(NewPluginUpdate()) + c.AddCommand(NewPluginAdd()) + c.AddCommand(NewPluginRemove()) + + return c +} + +func NewPluginList() *cobra.Command { + lstCmd := &cobra.Command{ + Use: "list", + Short: "List declared plugins and status", + Long: "Prints status and information of declared plugins", + RunE: func(cmd *cobra.Command, args []string) error { + s := cliui.New(cliui.WithStdout(os.Stdout)) + return printPlugins(s) + }, + } + return lstCmd +} + +func NewPluginUpdate() *cobra.Command { + return &cobra.Command{ + Use: "update [path]", + Short: "Update plugins", + Long: "Updates a plugin specified by path. If no path is specified all declared plugins are updated", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + // update all plugins + err := plugin.Update(plugins...) + if err != nil { + return err + } + fmt.Printf("All plugins updated.\n") + return nil + } + // find the plugin to update + for _, p := range plugins { + if p.Path == args[0] { + err := plugin.Update(p) + if err != nil { + return err + } + fmt.Printf("Plugin %q updated.\n", p.Path) + return nil + } + } + return errors.Errorf("Plugin %q not found", args[0]) + }, + } +} + +func NewPluginAdd() *cobra.Command { + cmdPluginAdd := &cobra.Command{ + Use: "add [path] [key=value]...", + Short: "Adds a plugin declaration to a plugin configuration", + Long: `Adds a plugin declaration to a plugin configuration. +Respects key value pairs declared after the plugin path to be added to the +generated configuration definition. +Example: + ignite plugin add github.com/org/my-plugin/ foo=bar baz=qux`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + session := cliui.New(cliui.WithStdout(os.Stdout)) + defer session.End() + + var ( + conf *pluginsconfig.Config + err error + ) + + global := flagGetPluginsGlobal(cmd) + if global { + conf, err = parseGlobalPlugins() + } else { + conf, err = parseLocalPlugins(cmd) + } + if err != nil { + return err + } + + for _, p := range conf.Plugins { + if p.Path == args[0] { + return fmt.Errorf("cannot add duplicate plugin %s", args[0]) + } + } + + p := pluginsconfig.Plugin{ + Path: args[0], + With: make(map[string]string), + Global: global, + } + + pluginsOptions := []plugin.Option{ + plugin.CollectEvents(session.EventBus()), + } + + var pluginArgs []string + if len(args) > 1 { + pluginArgs = args[1:] + } + + for _, pa := range pluginArgs { + kv := strings.Split(pa, "=") + if len(kv) != 2 { + return fmt.Errorf("malformed key=value arg: %s", pa) + } + p.With[kv[0]] = kv[1] + } + + session.StartSpinner("Loading plugin") + plugins, err := plugin.Load(cmd.Context(), []pluginsconfig.Plugin{p}, pluginsOptions...) + if err != nil { + return err + } + defer plugins[0].KillClient() + + if plugins[0].Error != nil { + return fmt.Errorf("error while loading plugin %q: %w", args[0], plugins[0].Error) + } + session.Println("Done loading plugin") + conf.Plugins = append(conf.Plugins, p) + + if err := conf.Save(); err != nil { + return err + } + + session.Printf("🎉 %s added \n", args[0]) + return nil + }, + } + + cmdPluginAdd.Flags().AddFlagSet(flagSetPluginsGlobal()) + + return cmdPluginAdd +} + +func NewPluginRemove() *cobra.Command { + cmdPluginRemove := &cobra.Command{ + Use: "remove [path]", + Aliases: []string{"rm"}, + Short: "Removes a plugin declaration from a chain's plugin configuration", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + s := cliui.New(cliui.WithStdout(os.Stdout)) + + var ( + conf *pluginsconfig.Config + err error + ) + + global := flagGetPluginsGlobal(cmd) + if global { + conf, err = parseGlobalPlugins() + } else { + conf, err = parseLocalPlugins(cmd) + } + if err != nil { + return err + } + + removed := false + for i, cp := range conf.Plugins { + if cp.Path == args[0] { + conf.Plugins = append(conf.Plugins[:i], conf.Plugins[i+1:]...) + removed = true + break + } + } + + if !removed { + // return if no matching plugin path found + return fmt.Errorf("plugin %s not found", args[0]) + } + + if err := conf.Save(); err != nil { + return err + } + + s.Printf("%s %s removed\n", icons.OK, args[0]) + s.Printf("\t%s updated\n", conf.Path()) + + return nil + }, + } + + cmdPluginRemove.Flags().AddFlagSet(flagSetPluginsGlobal()) + + return cmdPluginRemove +} + +func printPlugins(session *cliui.Session) error { + var ( + entries [][]string + buildStatus = func(p *plugin.Plugin) string { + if p.Error != nil { + return fmt.Sprintf("%s Error: %v", icons.NotOK, p.Error) + } + manifest, err := p.Interface.Manifest() + if err != nil { + return fmt.Sprintf("%s Error: Manifest() returned %v", icons.NotOK, err) + } + var ( + hookCount = len(manifest.Hooks) + cmdCount = len(manifest.Commands) + ) + return fmt.Sprintf("%s Loaded: %s %d %s%d ", icons.OK, icons.Command, cmdCount, icons.Hook, hookCount) + } + installedStatus = func(p *plugin.Plugin) string { + if p.IsGlobal() { + return "global" + } + return "local" + } + ) + for _, p := range plugins { + entries = append(entries, []string{p.Path, buildStatus(p), installedStatus(p)}) + } + if err := session.PrintTable([]string{"Path", "Status", "Config"}, entries...); err != nil { + return fmt.Errorf("error while printing plugins: %w", err) + } + return nil +} + +func flagSetPluginsGlobal() *flag.FlagSet { + fs := flag.NewFlagSet("", flag.ContinueOnError) + fs.BoolP(flagPluginsGlobal, "g", false, "use global plugins configuration"+ + " ($HOME/.ignite/plugins/plugins.yml)") + return fs +} + +func flagGetPluginsGlobal(cmd *cobra.Command) bool { + global, _ := cmd.Flags().GetBool(flagPluginsGlobal) + return global +} diff --git a/cmd/sonrd/cmd/plugin_default.go b/cmd/sonrd/cmd/plugin_default.go new file mode 100644 index 000000000..a74b9ed81 --- /dev/null +++ b/cmd/sonrd/cmd/plugin_default.go @@ -0,0 +1,108 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + pluginsconfig "github.com/sonr-io/core/config/plugins" + "github.com/sonr-io/core/pkg/cliui" + "github.com/sonr-io/core/services/plugin" +) + +type defaultPlugin struct { + use string + short string + aliases []string + path string +} + +const ( + PluginNetworkVersion = "v0.1.0" + PluginNetworkPath = "github.com/ignite/cli-plugin-network@" + PluginNetworkVersion +) + +// defaultPlugins holds the plugin that are considered trustable and for which +// a command will added if the plugin is not already installed. +// When the user executes that command, the plugin is automatically installed. +var defaultPlugins = []defaultPlugin{ + { + use: "network", + short: "Launch a blockchain in production", + aliases: []string{"n"}, + path: PluginNetworkPath, + }, +} + +// ensureDefaultPlugins ensures that all defaultPlugins are wether registered +// in cfg OR have an install command added to rootCmd. +func ensureDefaultPlugins(rootCmd *cobra.Command, cfg *pluginsconfig.Config) { + for _, dp := range defaultPlugins { + // Check if plugin is declared in global config + if cfg.HasPlugin(dp.path) { + // plugin found nothing to do + continue + } + // plugin not found in config, add a proxy install command + rootCmd.AddCommand(newPluginInstallCmd(dp)) + } +} + +// newPluginInstallCmd mimics the plugin command but acts as proxy to first: +// - register the config in the global config +// - load the plugin +// - execute the command thanks to the loaded plugin. +func newPluginInstallCmd(dp defaultPlugin) *cobra.Command { + return &cobra.Command{ + Use: dp.use, + Short: dp.short, + Aliases: dp.aliases, + DisableFlagParsing: true, // Avoid -h to skip command run + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := parseGlobalPlugins() + if err != nil { + return err + } + if cfg.HasPlugin(dp.path) { + // plugin already declared in global plugins, this shouldn't happen + // because this is actually why this command has been added, so let's + // break violently + panic(fmt.Sprintf("plugin %q unexpected in global config", dp.path)) + } + + // add plugin to config + pluginCfg := pluginsconfig.Plugin{ + Path: dp.path, + } + cfg.Plugins = append(cfg.Plugins, pluginCfg) + if err := cfg.Save(); err != nil { + return err + } + + session := cliui.New() + defer session.End() + + // load and link the plugin + plugins, err := plugin.Load( + cmd.Context(), + []pluginsconfig.Plugin{pluginCfg}, + plugin.CollectEvents(session.EventBus()), + ) + if err != nil { + return err + } + defer plugins[0].KillClient() + + // Keep reference of the root command before removal + rootCmd := cmd.Root() + // Remove this command before call to linkPlugins because a plugin is + // usually not allowed to override an existing command. + rootCmd.RemoveCommand(cmd) + if err := linkPlugins(rootCmd, plugins); err != nil { + return err + } + // Execute the command + return rootCmd.Execute() + }, + } +} diff --git a/cmd/sonrd/cmd/root.go b/cmd/sonrd/cmd/root.go index 808208de6..6f1dd001f 100644 --- a/cmd/sonrd/cmd/root.go +++ b/cmd/sonrd/cmd/root.go @@ -9,6 +9,7 @@ import ( "strings" // this line is used by starport scaffolding # root/moduleImport + "github.com/CosmWasm/wasmd/x/wasm" dbm "github.com/cometbft/cometbft-db" tmcfg "github.com/cometbft/cometbft/config" @@ -36,8 +37,6 @@ import ( "github.com/cosmos/cosmos-sdk/x/genutil" genutilcli "github.com/cosmos/cosmos-sdk/x/genutil/client/cli" genutiltypes "github.com/cosmos/cosmos-sdk/x/genutil/types" - sonrdconfig "github.com/sonr-io/core/config" - "github.com/sonr-io/core/internal/highway" "github.com/spf13/cast" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -45,6 +44,8 @@ import ( "github.com/sonr-io/core/app" appparams "github.com/sonr-io/core/app/params" + sonrdconfig "github.com/sonr-io/core/config" + "github.com/sonr-io/core/internal/highway" ) var ( @@ -149,6 +150,7 @@ func initRootCmd( banktypes.GenesisBalancesIterator{}, app.DefaultNodeHome, ), + NewPlugin(), genutilcli.ValidateGenesisCmd(app.ModuleBasics), AddGenesisAccountCmd(app.DefaultNodeHome), tmcli.NewCompletionCmd(rootCmd, true), diff --git a/config/config.go b/config/config.go index c09abe331..416845f1b 100644 --- a/config/config.go +++ b/config/config.go @@ -1,5 +1,10 @@ package config +import ( + "github.com/sonr-io/core/pkg/env" + "github.com/sonr-io/core/pkg/xfilepath" +) + var c Config // Config is defining a struct type named `Config`. This struct is used to store the configuration values for the application. It has various fields that correspond to different configuration parameters, such as version, chain ID, launch settings, database settings, node settings, genesis settings, etc. Each field is annotated with `mapstructure` tags, which are used to map the corresponding configuration values from a YAML file to the struct fields. @@ -100,3 +105,6 @@ type Config struct { } `mapstructure:"validators"` } `mapstructure:"genesis"` } + +// DirPath returns the path of configuration directory of Ignite. +var DirPath = xfilepath.Mkdir(env.ConfigDir()) diff --git a/config/plugins/config.go b/config/plugins/config.go new file mode 100644 index 000000000..7143b9248 --- /dev/null +++ b/config/plugins/config.go @@ -0,0 +1,152 @@ +package plugins + +import ( + "errors" + "fmt" + "os" + "strings" + + "golang.org/x/exp/slices" + "gopkg.in/yaml.v2" + + "github.com/sonr-io/core/pkg/gomodule" +) + +type Config struct { + // path to the config file + path string + + Plugins []Plugin `yaml:"plugins"` +} + +// Plugin keeps plugin name and location. +type Plugin struct { + // Path holds the location of the plugin. + // A path can be local, in that case it must start with a `/`. + // A remote path on the other hand, is an URL to a public remote git + // repository. For example: + // + // path: github.com/foo/bar + // + // It can contain a path inside that repository, if for instance the repo + // contains multiple plugins, for example: + // + // path: github.com/foo/bar/plugin1 + // + // It can also specify a tag or a branch, by adding a `@` and the branch/tag + // name at the end of the path. For example: + // + // path: github.com/foo/bar/plugin1@v42 + Path string `yaml:"path"` + // With holds arguments passed to the plugin interface + With map[string]string `yaml:"with,omitempty"` + // Global holds whether the plugin is installed globally + // (default: $HOME/.ignite/plugins/plugins.yml) or locally for a chain. + Global bool `yaml:"-"` +} + +// RemoveDuplicates takes a list of Plugins and returns a new list with only unique values. +// Local plugins take precedence over global plugins if duplicate paths exist. +// Duplicates are compared regardless of version. +func RemoveDuplicates(plugins []Plugin) (unique []Plugin) { + // struct to track plugin configs + type check struct { + hasPath bool + global bool + prevIndex int + } + + keys := make(map[string]check) + for i, plugin := range plugins { + c := keys[plugin.CanonicalPath()] + if !c.hasPath { + keys[plugin.CanonicalPath()] = check{ + hasPath: true, + global: plugin.Global, + prevIndex: i, + } + unique = append(unique, plugin) + } else if c.hasPath && !plugin.Global && c.global { // overwrite global plugin if local duplicate exists + unique[c.prevIndex] = plugin + } + } + + return unique +} + +// IsGlobal returns whether the plugin is installed globally or locally for a chain. +func (p Plugin) IsGlobal() bool { + return p.Global +} + +// IsLocalPath returns true if the plugin path is a local directory. +func (p Plugin) IsLocalPath() bool { + return strings.HasPrefix(p.Path, "/") +} + +// HasPath verifies if a plugin has the given path regardless of version. +// Example: +// github.com/foo/bar@v1 and github.com/foo/bar@v2 have the same path so "true" +// will be returned. +func (p Plugin) HasPath(path string) bool { + if path == "" { + return false + } + if p.Path == path { + return true + } + pluginPath := p.CanonicalPath() + path = strings.Split(path, "@")[0] + return pluginPath == path +} + +// CanonicalPath returns the canonical path of a plugin (excludes version ref). +func (p Plugin) CanonicalPath() string { + return strings.Split(p.Path, "@")[0] +} + +// Path return the path of the config file. +func (c Config) Path() string { + return c.path +} + +// Save persists a config yaml to a specified path on disk. +// Must be writable. +func (c *Config) Save() error { + errf := func(err error) error { + return fmt.Errorf("plugin config save: %w", err) + } + if c.path == "" { + return errf(errors.New("empty path")) + } + file, err := os.Create(c.path) + if err != nil { + return errf(err) + } + defer file.Close() + if err := yaml.NewEncoder(file).Encode(c); err != nil { + return errf(err) + } + return nil +} + +// HasPlugin returns true if c contains a plugin with given path. +// Returns also true if there's a local plugin with the module name equal to +// that path. +func (c Config) HasPlugin(path string) bool { + return slices.ContainsFunc(c.Plugins, func(cp Plugin) bool { + if cp.HasPath(path) { + return true + } + if cp.IsLocalPath() { + // check local plugin go.mod to see if module name match plugin path + gm, err := gomodule.ParseAt(cp.Path) + if err != nil { + // Skip if we can't parse gomod + return false + } + return Plugin{Path: gm.Module.Mod.Path}.HasPath(path) + } + return false + }) +} diff --git a/config/plugins/config_test.go b/config/plugins/config_test.go new file mode 100644 index 000000000..57e7ce9d9 --- /dev/null +++ b/config/plugins/config_test.go @@ -0,0 +1,246 @@ +package plugins_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + pluginsconfig "github.com/sonr-io/core/config/plugins" +) + +func TestPluginIsGlobal(t *testing.T) { + assert.False(t, pluginsconfig.Plugin{}.IsGlobal()) + assert.True(t, pluginsconfig.Plugin{Global: true}.IsGlobal()) +} + +func TestPluginIsLocalPath(t *testing.T) { + assert.False(t, pluginsconfig.Plugin{}.IsLocalPath()) + assert.False(t, pluginsconfig.Plugin{Path: "github.com/ignite/example"}.IsLocalPath()) + assert.True(t, pluginsconfig.Plugin{Path: "/home/bob/example"}.IsLocalPath()) +} + +func TestPluginHasPath(t *testing.T) { + tests := []struct { + name string + plugin pluginsconfig.Plugin + path string + expectedRes bool + }{ + { + name: "empty both path", + plugin: pluginsconfig.Plugin{}, + expectedRes: false, + }, + { + name: "simple path", + plugin: pluginsconfig.Plugin{ + Path: "github.com/ignite/example", + }, + path: "github.com/ignite/example", + expectedRes: true, + }, + { + name: "plugin path with ref", + plugin: pluginsconfig.Plugin{ + Path: "github.com/ignite/example@v1", + }, + path: "github.com/ignite/example", + expectedRes: true, + }, + { + name: "plugin path with empty ref", + plugin: pluginsconfig.Plugin{ + Path: "github.com/ignite/example@", + }, + path: "github.com/ignite/example", + expectedRes: true, + }, + { + name: "both path with different ref", + plugin: pluginsconfig.Plugin{ + Path: "github.com/ignite/example@v1", + }, + path: "github.com/ignite/example@v2", + expectedRes: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := tt.plugin.HasPath(tt.path) + + require.Equal(t, tt.expectedRes, res) + }) + } +} + +func TestPluginCanonicalPath(t *testing.T) { + tests := []struct { + name string + plugin pluginsconfig.Plugin + expectedPath string + }{ + { + name: "empty both path", + plugin: pluginsconfig.Plugin{}, + expectedPath: "", + }, + { + name: "simple path", + plugin: pluginsconfig.Plugin{ + Path: "github.com/ignite/example", + }, + expectedPath: "github.com/ignite/example", + }, + { + name: "plugin path with ref", + plugin: pluginsconfig.Plugin{ + Path: "github.com/ignite/example@v1", + }, + expectedPath: "github.com/ignite/example", + }, + { + name: "plugin path with empty ref", + plugin: pluginsconfig.Plugin{ + Path: "github.com/ignite/example@", + }, + expectedPath: "github.com/ignite/example", + }, + { + name: "plugin local directory path", + plugin: pluginsconfig.Plugin{ + Path: "/home/user/go/foo/bar", + }, + expectedPath: "/home/user/go/foo/bar", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := tt.plugin.CanonicalPath() + require.Equal(t, tt.expectedPath, res) + }) + } +} + +func TestRemoveDuplicates(t *testing.T) { + tests := []struct { + name string + configs []pluginsconfig.Plugin + expected []pluginsconfig.Plugin + }{ + { + name: "do nothing for empty list", + configs: []pluginsconfig.Plugin(nil), + expected: []pluginsconfig.Plugin(nil), + }, + { + name: "remove duplicates", + configs: []pluginsconfig.Plugin{ + { + Path: "foo/bar", + }, + { + Path: "foo/bar", + }, + { + Path: "bar/foo", + }, + }, + expected: []pluginsconfig.Plugin{ + { + Path: "foo/bar", + }, + { + Path: "bar/foo", + }, + }, + }, + { + name: "do nothing for no duplicates", + configs: []pluginsconfig.Plugin{ + { + Path: "foo/bar", + }, + { + Path: "bar/foo", + }, + }, + expected: []pluginsconfig.Plugin{ + { + Path: "foo/bar", + }, + { + Path: "bar/foo", + }, + }, + }, + { + name: "prioritize local plugins", + configs: []pluginsconfig.Plugin{ + { + Path: "foo/bar", + Global: true, + }, + { + Path: "bar/foo", + Global: true, + }, + { + Path: "foo/bar", + Global: false, + }, + { + Path: "bar/foo", + Global: false, + }, + }, + expected: []pluginsconfig.Plugin{ + { + Path: "foo/bar", + Global: false, + }, + { + Path: "bar/foo", + Global: false, + }, + }, + }, + { + name: "prioritize local plugins different versions", + configs: []pluginsconfig.Plugin{ + { + Path: "foo/bar@v1", + Global: true, + }, + { + Path: "bar/foo", + Global: true, + }, + { + Path: "foo/bar@v2", + Global: false, + }, + { + Path: "bar/foo", + Global: false, + }, + }, + expected: []pluginsconfig.Plugin{ + { + Path: "foo/bar@v2", + Global: false, + }, + { + Path: "bar/foo", + Global: false, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + unique := pluginsconfig.RemoveDuplicates(tt.configs) + require.EqualValues(t, tt.expected, unique) + }) + } +} diff --git a/config/plugins/parse.go b/config/plugins/parse.go new file mode 100644 index 000000000..589cc5c1d --- /dev/null +++ b/config/plugins/parse.go @@ -0,0 +1,75 @@ +package plugins + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "gopkg.in/yaml.v2" +) + +// ParseDir expects to find a plugin config file in dir. If dir is not a folder, +// an error is returned. +// The plugin config file format can be `plugins.yml` or `plugins.yaml`. If +// found, the file is parsed into a Config and returned. If no file from the +// given names above are found, then an empty config is returned, w/o errors. +func ParseDir(dir string) (*Config, error) { + // handy function that wraps and prefix err with a common label + errf := func(err error) (*Config, error) { + return nil, fmt.Errorf("plugin config parse: %w", err) + } + fi, err := os.Stat(dir) + if err != nil { + return errf(err) + } + if !fi.IsDir() { + return errf(fmt.Errorf("path %s is not a dir", dir)) + } + + filename, err := locateFile(dir) + if err != nil { + return errf(err) + } + c := Config{ + path: filename, + } + + f, err := os.Open(filename) + if err != nil { + if os.IsNotExist(err) { + return &c, nil + } + return errf(err) + } + defer f.Close() + + // if the error is end of file meaning an empty file on read return nil + if err := yaml.NewDecoder(f).Decode(&c); err != nil && !errors.Is(err, io.EOF) { + return errf(err) + } + return &c, nil +} + +var ( + // filenames is a list of recognized names as Ignite's plugins config file. + filenames = []string{"plugins.yml", "plugins.yaml"} + defaultFilename = filenames[0] +) + +func locateFile(root string) (string, error) { + for _, name := range filenames { + path := filepath.Join(root, name) + _, err := os.Stat(path) + if err == nil { + // file found + return path, nil + } + if !os.IsNotExist(err) { + return "", err + } + } + // no file found, return the default config name + return filepath.Join(root, defaultFilename), nil +} diff --git a/sonr.yml b/config/sonr.yml similarity index 100% rename from sonr.yml rename to config/sonr.yml diff --git a/docker/Taskfile.yml b/docker/Taskfile.yml index eaa774be5..8acec1ba9 100644 --- a/docker/Taskfile.yml +++ b/docker/Taskfile.yml @@ -25,7 +25,7 @@ tasks: # ---------------------------------------------------------------------------- # -- Run Tasks --------------------------------------------------------------- # ---------------------------------------------------------------------------- - docker: + default: desc: Print the version silent: true cmds: @@ -47,6 +47,15 @@ tasks: cmds: - docker build -f ./docker/deploy/Dockerfile . -t sonrd + start: + silent: true + desc: Start the docker image + dir: ./docker/start + cmds: + - docker compose up + deps: + - build + tag: silent: true desc: Build, tag and push the docker image diff --git a/docker/start/Dockerfile.dev b/docker/start/Dockerfile.dev index 2d581b2ee..9ee5e917a 100644 --- a/docker/start/Dockerfile.dev +++ b/docker/start/Dockerfile.dev @@ -1,51 +1,6 @@ ARG GO_VERSION="1.19" ARG RUNNER_IMAGE="alpine:3.16" -# ! ||--------------------------------------------------------------------------------|| -# ! || Sonrd Builder || -# ! ||--------------------------------------------------------------------------------|| -FROM golang:${GO_VERSION}-alpine as sonr-builder - -ARG arch=x86_64 - -RUN apk add --no-cache \ - ca-certificates \ - build-base \ - linux-headers - -# Download go dependencies -WORKDIR /root -COPY go.mod go.sum ./ -RUN --mount=type=cache,target=/root/.cache/go-build \ - --mount=type=cache,target=/root/go/pkg/mod \ - go mod download - -# Cosmwasm - Download correct libwasmvm version -RUN set -eux; \ - export ARCH=$(uname -m); \ - WASM_VERSION=$(go list -m all | grep github.com/CosmWasm/wasmvm | awk '{print $2}'); \ - if [ ! -z "${WASM_VERSION}" ]; then \ - wget -O /lib/libwasmvm_muslc.a https://github.com/CosmWasm/wasmvm/releases/download/${WASM_VERSION}/libwasmvm_muslc.${ARCH}.a; \ - fi; \ - go mod download; - -# Copy the remaining files -COPY . . -RUN --mount=type=cache,target=/root/.cache/go-build \ - --mount=type=cache,target=/root/go/pkg/mod \ - GOWORK=off go build \ - -mod=readonly \ - -tags "netgo,ledger,muslc" \ - -ldflags \ - "-X github.com/cosmos/cosmos-sdk/version.Name="sonr" \ - -X github.com/cosmos/cosmos-sdk/version.AppName="sonrd" \ - -X github.com/cosmos/cosmos-sdk/version.Version=${GIT_VERSION} \ - -X github.com/cosmos/cosmos-sdk/version.Commit=${GIT_COMMIT} \ - -X github.com/cosmos/cosmos-sdk/version.BuildTags=netgo,ledger,muslc \ - -w -s -linkmode=external -extldflags '-Wl,-z,muldefs -static'" \ - -trimpath \ - -o /root/sonr/build/sonrd ./cmd/sonrd/main.go - # ! ||----------------------------------------------------------------------------------|| # ! || Sonr Standalone Node || @@ -55,9 +10,9 @@ FROM --platform=linux alpine LABEL org.opencontainers.image.source https://github.com/sonr-io/core LABEL org.opencontainers.image.description "Base sonr daemon image" # Copy sonrd binary and config -COPY --from=sonr-builder /root/sonr/build/sonrd /usr/local/bin/sonrd +COPY ../bin/sonrd /usr/local/bin/sonrd +COPY start.sh start.sh COPY sonr.yml sonr.yml -COPY scripts scripts ENV SONR_LAUNCH_CONFIG=/sonr.yml # Expose ports diff --git a/docker/start/docker-compose.yml b/docker/start/docker-compose.yml index b5148b57a..552ced090 100644 --- a/docker/start/docker-compose.yml +++ b/docker/start/docker-compose.yml @@ -6,7 +6,6 @@ services: init: true build: dockerfile: Dockerfile.dev - command: sh start.sh ports: - 1317:1317 # rest - 26656:26656 # p2p @@ -14,13 +13,23 @@ services: - 9090:9090 # grpc - 8000:8080 # highway restart: always - db: + fireice-nosql: pull_policy: always - image: ghcr.io/sonr-io/icefiredb:latest + image: ghcr.io/sonr-io/fireice-nosql:latest init: true - container_name: db + container_name: fireice-nosql networks: - sonr-net ports: - 6001:6001 restart: always + fireice-sqlite: + pull_policy: always + image: ghcr.io/sonr-io/fireice-sqlite:latest + init: true + container_name: fireice-sqlite + networks: + - sonr-net + ports: + - 6002:6002 + restart: always diff --git a/docker/start/sonr.yml b/docker/start/sonr.yml new file mode 100644 index 000000000..49bd1c4ac --- /dev/null +++ b/docker/start/sonr.yml @@ -0,0 +1,51 @@ +version: 1 +launch: + environment: development + chain-id: sonr-localnet-1 + moniker: alice + val_address: "0x0000000000" +highway: + enabled: true + jwt: + key: "sercrethatmaycontainch@r$32chars" + api: + host: "localhost" + port: 8080 + timeout: 15 + icefirekv: + host: "db" + port: 6001 + icefiresql: + host: "sql" + port: 23306 +node: + api: + host: "validator" + port: 1317 + p2p: + host: "validator" + port: 26656 + rpc: + host: "validator" + port: 26657 + grpc: + host: "validator" + port: 9090 +genesis: + accounts: + - name: alice + coins: + - 20000token + - 200000000stake + - name: bob + coins: + - 10000token + - 100000000stake + faucet: + name: bob + coins: + - 5token + - 100000stake + validators: + - name: alice + bonded: 100000000stake diff --git a/go.mod b/go.mod index 35aacc6c0..942e91e07 100644 --- a/go.mod +++ b/go.mod @@ -35,7 +35,7 @@ require ( github.com/spf13/viper v1.16.0 github.com/stretchr/testify v1.8.4 github.com/yoseplee/vrf v0.0.0-20210814110709-d1caf509310b - golang.org/x/crypto v0.11.0 + golang.org/x/crypto v0.14.0 google.golang.org/genproto/googleapis/api v0.0.0-20230726155614-23370e0ffb3e google.golang.org/grpc v1.57.0 gopkg.in/yaml.v2 v2.4.0 @@ -43,10 +43,25 @@ require ( ) require ( + github.com/AlecAivazis/survey/v2 v2.3.7 + github.com/briandowns/spinner v1.23.0 github.com/btcsuite/btcd v0.21.0-beta.0.20201114000516-e9c7a5ac6401 github.com/cayleygraph/cayley v0.7.7 github.com/cayleygraph/quad v1.1.0 + github.com/charmbracelet/bubbles v0.16.1 + github.com/charmbracelet/bubbletea v0.24.2 + github.com/charmbracelet/lipgloss v0.9.1 + github.com/go-git/go-git/v5 v5.10.0 + github.com/goccy/go-yaml v1.11.2 + github.com/hashicorp/go-hclog v1.2.0 github.com/hashicorp/go-plugin v1.5.2 + github.com/manifoldco/promptui v0.9.0 + github.com/muesli/reflow v0.3.0 + go.etcd.io/bbolt v1.3.7 + golang.org/x/exp v0.0.0-20230711153332-06a737ee72cb + golang.org/x/mod v0.12.0 + golang.org/x/sync v0.3.0 + golang.org/x/text v0.13.0 google.golang.org/protobuf v1.31.0 ) @@ -62,26 +77,32 @@ require ( cosmossdk.io/log v1.2.1 // indirect cosmossdk.io/math v1.1.2 // indirect cosmossdk.io/tools/rosetta v0.2.1 // indirect + dario.cat/mergo v1.0.0 // indirect filippo.io/edwards25519 v1.0.0 // indirect git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9 // indirect github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect github.com/99designs/gqlgen v0.17.24 // indirect github.com/99designs/keyring v1.2.1 // indirect github.com/ChainSafe/go-schnorrkel v0.0.0-20200405005733-88cbf1b4c40d // indirect + github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect + github.com/acomagu/bufpipe v1.0.4 // indirect github.com/armon/go-metrics v0.4.1 // indirect github.com/aws/aws-sdk-go v1.44.295 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/bgentry/speakeasy v0.1.1-0.20220910012023-760eaf8b6816 // indirect github.com/bits-and-blooms/bitset v1.7.0 // indirect github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect - github.com/bwesterb/go-ristretto v1.2.0 // indirect + github.com/bwesterb/go-ristretto v1.2.3 // indirect github.com/bytedance/sonic v1.9.2 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/chzyer/readline v1.5.1 // indirect + github.com/cloudflare/circl v1.3.3 // indirect github.com/cockroachdb/apd/v2 v2.0.2 // indirect github.com/cockroachdb/errors v1.10.0 // indirect github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect @@ -90,6 +111,7 @@ require ( github.com/confio/ics23/go v0.9.0 // indirect github.com/consensys/bavard v0.1.13 // indirect github.com/consensys/gnark-crypto v0.10.0 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/cosmos/btcutil v1.0.5 // indirect github.com/cosmos/cosmos-proto v1.0.0-beta.2 // indirect github.com/cosmos/gogogateway v1.2.0 // indirect @@ -98,6 +120,7 @@ require ( github.com/cosmos/ledger-cosmos-go v0.12.1 // indirect github.com/cosmos/rosetta-sdk-go v0.10.0 // indirect github.com/creachadair/taskgroup v0.4.2 // indirect + github.com/cyphar/filepath-securejoin v0.2.4 // indirect github.com/danieljoos/wincred v1.1.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect @@ -109,6 +132,7 @@ require ( github.com/docker/distribution v2.8.2+incompatible // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/dvsekhvalnov/jose2go v1.5.0 // indirect + github.com/emirpasic/gods v1.18.1 // indirect github.com/fatih/color v1.13.0 // indirect github.com/felixge/httpsnoop v1.0.3 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect @@ -117,6 +141,8 @@ require ( github.com/getsentry/sentry-go v0.23.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.5.0 // indirect github.com/go-kit/kit v0.12.0 // indirect github.com/go-kit/log v0.2.1 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect @@ -155,7 +181,6 @@ require ( github.com/gtank/ristretto255 v0.1.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-getter v1.7.1 // indirect - github.com/hashicorp/go-hclog v1.2.0 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-safetemp v1.0.0 // indirect github.com/hashicorp/go-version v1.6.0 // indirect @@ -168,10 +193,13 @@ require ( github.com/huandu/skiplist v1.2.0 // indirect github.com/improbable-eng/grpc-web v0.15.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jmhodges/levigo v1.0.0 // indirect github.com/joho/godotenv v1.3.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.16.7 // indirect github.com/klauspost/cpuid/v2 v2.2.5 // indirect github.com/kr/pretty v0.3.1 // indirect @@ -180,11 +208,14 @@ require ( github.com/lib/pq v1.10.7 // indirect github.com/libp2p/go-buffer-pool v0.1.0 // indirect github.com/linxGnu/grocksdb v1.7.16 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.7 // indirect - github.com/manifoldco/promptui v0.9.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect github.com/mimoo/StrobeGo v0.0.0-20210601165009-122bf33a46e0 // indirect github.com/minio/highwayhash v1.0.2 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect @@ -195,12 +226,16 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mr-tron/base58 v1.2.0 // indirect github.com/mtibben/percent v0.2.1 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.15.2 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect github.com/oklog/run v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/petermattis/goid v0.0.0-20230317030725-371a4b8eda08 // indirect + github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_golang v1.16.0 // indirect github.com/prometheus/client_model v0.3.0 // indirect @@ -208,11 +243,14 @@ require ( github.com/prometheus/procfs v0.10.1 // indirect github.com/rakyll/statik v0.1.7 // indirect github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect + github.com/rivo/uniseg v0.2.0 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/rs/cors v1.8.3 // indirect github.com/rs/zerolog v1.30.0 // indirect github.com/sasha-s/go-deadlock v0.3.1 // indirect + github.com/sergi/go-diff v1.1.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect + github.com/skeema/knownhosts v1.2.0 // indirect github.com/spf13/afero v1.9.5 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/subosito/gotenv v1.4.2 // indirect @@ -224,9 +262,9 @@ require ( github.com/ulikunitz/xz v0.5.11 // indirect github.com/vektah/gqlparser/v2 v2.5.1 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/zondax/hid v0.9.1 // indirect github.com/zondax/ledger-go v0.14.1 // indirect - go.etcd.io/bbolt v1.3.7 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/otel v1.13.0 // indirect go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.13.0 // indirect @@ -236,21 +274,18 @@ require ( go.opentelemetry.io/otel/trace v1.13.0 // indirect go.opentelemetry.io/proto/otlp v0.19.0 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/exp v0.0.0-20230711153332-06a737ee72cb // indirect - golang.org/x/mod v0.11.0 // indirect - golang.org/x/net v0.12.0 // indirect + golang.org/x/net v0.17.0 // indirect golang.org/x/oauth2 v0.10.0 // indirect - golang.org/x/sync v0.3.0 // indirect - golang.org/x/sys v0.11.0 // indirect - golang.org/x/term v0.10.0 // indirect - golang.org/x/text v0.12.0 // indirect - golang.org/x/tools v0.9.3 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/term v0.13.0 // indirect + golang.org/x/tools v0.13.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect google.golang.org/api v0.126.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20230720185612-659f7aaaa771 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230726155614-23370e0ffb3e // indirect gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect nhooyr.io/websocket v1.8.7 // indirect pgregory.net/rapid v0.5.5 // indirect diff --git a/go.sum b/go.sum index 0b9a1bc2c..3edbf7791 100644 --- a/go.sum +++ b/go.sum @@ -204,6 +204,8 @@ cosmossdk.io/simapp v0.0.0-20230323161446-0af178d721ff h1:P1ialzTepD1oxdNPYc5N8E cosmossdk.io/simapp v0.0.0-20230323161446-0af178d721ff/go.mod h1:AKzx6Mb544LjJ9RHmGFHjY9rEOLiUAi8I0F727TR0dY= cosmossdk.io/tools/rosetta v0.2.1 h1:ddOMatOH+pbxWbrGJKRAawdBkPYLfKXutK9IETnjYxw= cosmossdk.io/tools/rosetta v0.2.1/go.mod h1:Pqdc1FdvkNV3LcNIkYWt2RQY6IP1ge6YWZk8MhhO9Hw= +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/edwards25519 v1.0.0 h1:0wAIcmJUqRdI8IJ/3eGi5/HwXZWPujYXXlkrQogz0Ek= filippo.io/edwards25519 v1.0.0/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= @@ -216,6 +218,8 @@ github.com/99designs/gqlgen v0.17.24 h1:pcd/HFIoSdRvyADYQG2dHvQN2KZqX/nXzlVm6TMM github.com/99designs/gqlgen v0.17.24/go.mod h1:BMhYIhe4bp7OlCo5I2PnowSK/Wimpv/YlxfNkqZGwLo= github.com/99designs/keyring v1.2.1 h1:tYLp1ULvO7i3fI5vE21ReQuj99QFSs7lGm0xWyJo87o= github.com/99designs/keyring v1.2.1/go.mod h1:fc+wB5KTk9wQ9sDx0kFXB3A0MaeGHM9AwRStKOQ5vOA= +github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= +github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= @@ -232,16 +236,23 @@ github.com/CosmWasm/wasmvm v1.3.0/go.mod h1:vW/E3h8j9xBQs9bCoijDuawKo9kCtxOaS8N8 github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= github.com/Microsoft/go-winio v0.4.12/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= -github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg= -github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg= +github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= github.com/VividCortex/gohistogram v1.0.0 h1:6+hBz+qvs0JOrrNhhmR7lFxo5sINxBCGXrdtl/UvroE= github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= +github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ= +github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= github.com/adlio/schema v1.3.3 h1:oBJn8I02PyTB466pZO1UZEn1TV5XLlifBSyMrmHl/1I= github.com/adlio/schema v1.3.3/go.mod h1:1EsRssiv9/Ce2CMzq5DoL7RiMshhuigQxrR4DMV9fHg= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= @@ -255,6 +266,8 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= @@ -265,6 +278,8 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= @@ -272,6 +287,8 @@ github.com/aws/aws-sdk-go v1.44.122/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX github.com/aws/aws-sdk-go v1.44.295 h1:SGjU1+MqttXfRiWHD6WU0DRhaanJgAFY+xIhEaugV8Y= github.com/aws/aws-sdk-go v1.44.295/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/badgerodon/peg v0.0.0-20130729175151-9e5f7f4d07ca/go.mod h1:TWe0N2hv5qvpLHT+K16gYcGBllld4h65dQ/5CNuirmk= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= @@ -285,6 +302,8 @@ github.com/bgentry/speakeasy v0.1.1-0.20220910012023-760eaf8b6816/go.mod h1:+zsy github.com/bits-and-blooms/bitset v1.7.0 h1:YjAGVd3XmtK9ktAbX8Zg2g2PwLIMjGREZJHlV4j7NEo= github.com/bits-and-blooms/bitset v1.7.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA= github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= +github.com/briandowns/spinner v1.23.0 h1:alDF2guRWqa/FOZZYWjlMIx2L6H0wyewPxo/CH4Pt2A= +github.com/briandowns/spinner v1.23.0/go.mod h1:rPG4gmXeN3wQV/TsAY4w8lPdIM6RX3yqeBQJSrbXjuE= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= github.com/btcsuite/btcd v0.21.0-beta.0.20201114000516-e9c7a5ac6401 h1:0tjUthKCaF8zwF9Qg7lfnep0xdo4n8WiFUfQPaMHX6g= github.com/btcsuite/btcd v0.21.0-beta.0.20201114000516-e9c7a5ac6401/go.mod h1:Sv4JPQ3/M+teHz9Bo5jBpkNcP0x6r7rdihlNL/7tTAs= @@ -304,8 +323,8 @@ github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtE github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= -github.com/bwesterb/go-ristretto v1.2.0 h1:xxWOVbN5m8NNKiSDZXE1jtZvZnC6JSJ9cYFADiZcWtw= -github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/bwesterb/go-ristretto v1.2.3 h1:1w53tCkGhCQ5djbat3+MH0BAQ5Kfgbt56UZQ/JMzngw= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.9.2 h1:GDaNjuWSGu09guE9Oql0MSTNhNCLlWwO8y/xM5BzcbM= github.com/bytedance/sonic v1.9.2/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= @@ -326,6 +345,12 @@ github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghf github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= +github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= +github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= +github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= +github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= +github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= github.com/cheggaaa/pb v1.0.27/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= @@ -343,6 +368,8 @@ github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6D github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= +github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -375,6 +402,8 @@ github.com/consensys/bavard v0.1.13 h1:oLhMLOFGTLdlda/kma4VOJazblc7IM5y5QPd2A/Yj github.com/consensys/bavard v0.1.13/go.mod h1:9ItSMtA/dXMAiL7BG6bqW2m3NdSEObYWoH223nGHukI= github.com/consensys/gnark-crypto v0.10.0 h1:zRh22SR7o4K35SoNqouS9J/TKHTyU2QWaj5ldehyXtA= github.com/consensys/gnark-crypto v0.10.0/go.mod h1:Iq/P3HHl0ElSjsg2E1gsMwhAyxnxoKK5nVyZKd+/KhU= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/containerd/continuity v0.0.0-20181203112020-004b46473808/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/containerd/continuity v0.3.0 h1:nisirsYROK15TAMVukJOUyGJjz4BNQJBVsNvAXZJ/eg= @@ -421,6 +450,10 @@ github.com/creachadair/taskgroup v0.4.2 h1:jsBLdAJE42asreGss2xZGZ8fJra7WtwnHWeJF github.com/creachadair/taskgroup v0.4.2/go.mod h1:qiXUOSrbwAY3u0JPGTzObbE3yf9hcXHDKBZ2ZjpCbgM= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= +github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/cznic/mathutil v0.0.0-20170313102836-1447ad269d64 h1:oad14P7M0/ZAPSMH1nl1vC8zdKVkA3kfHLO59z1l8Eg= github.com/cznic/mathutil v0.0.0-20170313102836-1447ad269d64/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM= github.com/d4l3k/messagediff v1.2.1/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= @@ -480,6 +513,10 @@ github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5m github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= +github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= +github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -528,9 +565,19 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= +github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= github.com/go-echarts/go-echarts v0.0.0-20190915064101-cbb3b43ade5d/go.mod h1:v4lFmU586g/A0xaH1RMDS86YlYrwpj8eHtR+xBReKE8= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= +github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.10.0 h1:F0x3xXrAWmhwtzoCokU4IMPcBdncG+HAAqi9FcOOjbQ= +github.com/go-git/go-git/v5 v5.10.0/go.mod h1:1FOZ/pQnqw24ghP2n7cunVl0ON55BsjPYvhWHvZGhoo= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -599,6 +646,8 @@ github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.11.2 h1:joq77SxuyIs9zzxEjgyLBugMQ9NEgTWxXfz2wVqwAaQ= +github.com/goccy/go-yaml v1.11.2/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -833,6 +882,8 @@ github.com/hdevalence/ed25519consensus v0.1.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3s github.com/hidal-go/hidalgo v0.0.0-20190814174001-42e03f3b5eaa/go.mod h1:bPkrxDlroXxigw8BMWTEPTv4W5/rQwNgg2BECXsgyX0= github.com/highlight/highlight/sdk/highlight-go v0.9.9 h1:joKICta1t0y4k4j9bxr82UkImkVuDcgBZLED12z0G1Q= github.com/highlight/highlight/sdk/highlight-go v0.9.9/go.mod h1:U+PdcT8fqnnOZGc48O62flvANbZ6x9czxwE3xiunFqU= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/holiman/uint256 v1.2.3 h1:K8UWO1HUJpRMXBxbmaY1Y8IAMZC/RsKB+ArEnnK4l5o= github.com/holiman/uint256 v1.2.3/go.mod h1:SC8Ryt4n+UBbPbIBKaG9zbbDlp4jOru9xFZmPzLUTxw= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= @@ -852,6 +903,8 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80sQsxDoMokWK1W5TQtxBFNpzWTD84ibQ= github.com/jackc/pgx v3.3.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= @@ -883,6 +936,10 @@ github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8 github.com/karrick/godirwalk v1.10.12/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= github.com/kataras/jwt v0.1.8 h1:u71baOsYD22HWeSOg32tCHbczPjdCk7V4MMeJqTtmGk= github.com/kataras/jwt v0.1.8/go.mod h1:Q5j2IkcIHnfwy+oNY3TVWuEBJNw0ADgCcXK9CaZwV4o= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kevinmbeaulieu/eq-go v1.0.0/go.mod h1:G3S8ajA56gKBZm4UB9AOyoOS37JO3roToPzKNM8dtdM= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= @@ -928,6 +985,8 @@ github.com/linkeddata/gojsonld v0.0.0-20170418210642-4f5db6791326/go.mod h1:nfqk github.com/linxGnu/grocksdb v1.7.16 h1:Q2co1xrpdkr5Hx3Fp+f+f7fRGhQFQhvi/+226dtLmA8= github.com/linxGnu/grocksdb v1.7.16/go.mod h1:JkS7pl5qWpGpuVb3bPqTz8nC12X3YtPZT+Xq7+QfQo4= github.com/logrusorgru/aurora/v3 v3.0.0/go.mod h1:vsR12bk5grlLvLXAYrBsb5Oc/N+LxAlxggSjiwMnCUc= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= @@ -938,8 +997,11 @@ github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= +github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= github.com/matryer/moq v0.2.7/go.mod h1:kITsx543GOENm48TUAQyJ9+SAvFSr7iGQXPoth/VUBk= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= @@ -954,12 +1016,19 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/mimoo/StrobeGo v0.0.0-20181016162300-f8f6d4d2b643/go.mod h1:43+3pMjjKimDBf5Kr4ZFNGbLql1zKkbImw+fZbw3geM= github.com/mimoo/StrobeGo v0.0.0-20210601165009-122bf33a46e0 h1:QRUSJEgZn2Snx0EmT/QLXibWjSUDjKWvXIT19NBVp94= @@ -993,6 +1062,14 @@ github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= @@ -1033,8 +1110,8 @@ github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1Cpa github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.20.0 h1:8W0cWlwFkflGPLltQvLRB7ZVD5HuP6ng320w2IS245Q= -github.com/onsi/gomega v1.20.0/go.mod h1:DtrZpjmvpn2mPm4YWQa0/ALMDj9v4YxLgojwPeREyVo= +github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= +github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -1076,6 +1153,8 @@ github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0 github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= +github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -1128,6 +1207,9 @@ github.com/rakyll/statik v0.1.7/go.mod h1:AlZONWzMtEnMs7W4e/1LURLiI49pIMmp6V9Ung github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= @@ -1166,6 +1248,8 @@ github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrf github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/skeema/knownhosts v1.2.0 h1:h9r9cf0+u7wSE+M183ZtMGgOJKiL96brpaz5ekfJCpM= +github.com/skeema/knownhosts v1.2.0/go.mod h1:g4fPeYpque7P0xefxtGzV81ihjC8sX2IqpAoNkjxbMo= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= @@ -1250,6 +1334,8 @@ github.com/vektah/gqlparser/v2 v2.5.1 h1:ZGu+bquAY23jsxDRcYpWjttRZrUz07LbiY77gUO github.com/vektah/gqlparser/v2 v2.5.1/go.mod h1:mPgqFBu/woKTVYWyNk8cO3kh4S/f4aRFZrvOnp3hmCs= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= github.com/xdg/stringprep v1.0.0/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= @@ -1334,9 +1420,12 @@ golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= -golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -1377,8 +1466,9 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= -golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1443,8 +1533,11 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= -golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= -golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1487,6 +1580,7 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1550,6 +1644,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1594,15 +1689,20 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c= -golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1614,8 +1714,10 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= -golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1686,8 +1788,9 @@ golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.9.3 h1:Gn1I8+64MsuTb/HpH+LmQtNas23LhUVr3rYZ0eKuaMM= -golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1954,6 +2057,7 @@ gopkg.in/olivere/elastic.v5 v5.0.81/go.mod h1:uhHoB4o3bvX5sorxBU29rPcmBQdV2Qfg0F gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go new file mode 100644 index 000000000..ad115d788 --- /dev/null +++ b/pkg/cache/cache.go @@ -0,0 +1,149 @@ +package cache + +import ( + "bytes" + "encoding/gob" + "errors" + "os" + "path/filepath" + "strings" + "time" + + bolt "go.etcd.io/bbolt" +) + +var ErrorNotFound = errors.New("no value was found with the provided key") + +// Storage is meant to be passed around and used by the New function (which provides namespacing and type-safety). +type Storage struct { + storagePath string +} + +// Cache is a namespaced and type-safe key-value store. +type Cache[T any] struct { + storage Storage + namespace string +} + +// NewStorage sets up the storage needed for later cache usage +// path is the full path (including filename) to the database file to use. +// It does not need to be closed as this happens automatically in each call to the cache. +func NewStorage(path string) (Storage, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return Storage{}, err + } + + return Storage{path}, nil +} + +// New creates a namespaced and typesafe key-value Cache. +func New[T any](storage Storage, namespace string) Cache[T] { + return Cache[T]{ + storage: storage, + namespace: namespace, + } +} + +// Key creates a single composite key from a list of keyParts. +func Key(keyParts ...string) string { + return strings.Join(keyParts, "") +} + +// Clear deletes all namespaces and cached values. +func (s Storage) Clear() error { + db, err := openDB(s.storagePath) + if err != nil { + return err + } + defer db.Close() + + return db.Update(func(tx *bolt.Tx) error { + return tx.ForEach(func(name []byte, _ *bolt.Bucket) error { + return tx.DeleteBucket(name) + }) + }) +} + +// Put sets key to value within the namespace +// If the key already exists, it will be overwritten. +func (c Cache[T]) Put(key string, value T) error { + db, err := openDB(c.storage.storagePath) + if err != nil { + return err + } + defer db.Close() + + var buf bytes.Buffer + encoder := gob.NewEncoder(&buf) + if err := encoder.Encode(value); err != nil { + return err + } + result := buf.Bytes() + + return db.Update(func(tx *bolt.Tx) error { + b, err := tx.CreateBucketIfNotExists([]byte(c.namespace)) + if err != nil { + return err + } + return b.Put([]byte(key), result) + }) +} + +// Get fetches the value of key within the namespace. +// If no value exists, it will return found == false. +func (c Cache[T]) Get(key string) (val T, err error) { + db, err := openDB(c.storage.storagePath) + if err != nil { + return val, err + } + defer db.Close() + + err = db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(c.namespace)) + if b == nil { + return ErrorNotFound + } + c := b.Cursor() + if k, v := c.Seek([]byte(key)); bytes.Equal(k, []byte(key)) { + if v == nil { + return ErrorNotFound + } + + var decodedVal T + d := gob.NewDecoder(bytes.NewReader(v)) + if err := d.Decode(&decodedVal); err != nil { + return err + } + + val = decodedVal + } else { + return ErrorNotFound + } + + return nil + }) + + return val, err +} + +// Delete removes a value for key within the namespace. +func (c Cache[T]) Delete(key string) error { + db, err := openDB(c.storage.storagePath) + if err != nil { + return err + } + defer db.Close() + + return db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(c.namespace)) + if b == nil { + return nil + } + + return b.Delete([]byte(key)) + }) +} + +func openDB(path string) (*bolt.DB, error) { + return bolt.Open(path, 0o640, &bolt.Options{Timeout: 1 * time.Minute}) +} diff --git a/pkg/cache/cache_test.go b/pkg/cache/cache_test.go new file mode 100644 index 000000000..f8f46d91d --- /dev/null +++ b/pkg/cache/cache_test.go @@ -0,0 +1,170 @@ +package cache_test + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sonr-io/core/pkg/cache" +) + +type TestStruct struct { + Num int +} + +func TestCreateStorage(t *testing.T) { + tmpDir1 := t.TempDir() + tmpDir2 := t.TempDir() + + _, err := cache.NewStorage(filepath.Join(tmpDir1, "test.db")) + require.NoError(t, err) + + _, err = cache.NewStorage(filepath.Join(tmpDir2, "test.db")) + require.NoError(t, err) +} + +func TestStoreString(t *testing.T) { + tmpDir := t.TempDir() + cacheStorage, err := cache.NewStorage(filepath.Join(tmpDir, "testdbfile.db")) + require.NoError(t, err) + + strNamespace := cache.New[string](cacheStorage, "myNameSpace") + + err = strNamespace.Put("myKey", "myValue") + require.NoError(t, err) + + val, err := strNamespace.Get("myKey") + require.NoError(t, err) + require.Equal(t, "myValue", val) + + strNamespaceAgain := cache.New[string](cacheStorage, "myNameSpace") + + valAgain, err := strNamespaceAgain.Get("myKey") + require.NoError(t, err) + require.Equal(t, "myValue", valAgain) +} + +func TestStoreObjects(t *testing.T) { + tmpDir := t.TempDir() + cacheStorage, err := cache.NewStorage(filepath.Join(tmpDir, "testdbfile.db")) + require.NoError(t, err) + + structCache := cache.New[TestStruct](cacheStorage, "mySimpleNamespace") + + err = structCache.Put("myKey", TestStruct{ + Num: 42, + }) + require.NoError(t, err) + + val, err := structCache.Get("myKey") + require.NoError(t, err) + require.Equal(t, val, TestStruct{ + Num: 42, + }) + + arrayNamespace := cache.New[[]TestStruct](cacheStorage, "myArrayNamespace") + + err = arrayNamespace.Put("myKey", []TestStruct{ + { + Num: 42, + }, + { + Num: 420, + }, + }) + require.NoError(t, err) + + val2, err := arrayNamespace.Get("myKey") + require.NoError(t, err) + require.Equal(t, 2, len(val2)) + require.Equal(t, 42, (val2)[0].Num) + require.Equal(t, 420, (val2)[1].Num) + + empty, err := arrayNamespace.Get("doesNotExists") + require.Equal(t, cache.ErrorNotFound, err) + require.Nil(t, empty) +} + +func TestConflicts(t *testing.T) { + tmpDir := t.TempDir() + tmpDir2 := t.TempDir() + cacheStorage1, err := cache.NewStorage(filepath.Join(tmpDir, "testdbfile.db")) + require.NoError(t, err) + cacheStorage2, err := cache.NewStorage(filepath.Join(tmpDir2, "testdbfile.db")) + require.NoError(t, err) + + sameStorageDifferentNamespaceCache1 := cache.New[int](cacheStorage1, "ns1") + + sameStorageDifferentNamespaceCache2 := cache.New[int](cacheStorage1, "ns2") + + differentStorageSameNamespace := cache.New[int](cacheStorage2, "ns1") + + // Put values in caches + err = sameStorageDifferentNamespaceCache1.Put("myKey", 41) + require.NoError(t, err) + + err = sameStorageDifferentNamespaceCache2.Put("myKey", 1337) + require.NoError(t, err) + + err = differentStorageSameNamespace.Put("myKey", 9001) + require.NoError(t, err) + + // Overwrite a value + err = sameStorageDifferentNamespaceCache1.Put("myKey", 42) + require.NoError(t, err) + + // Check that everything comes back as expected + val1, err := sameStorageDifferentNamespaceCache1.Get("myKey") + require.NoError(t, err) + require.Equal(t, 42, val1) + + val2, err := sameStorageDifferentNamespaceCache2.Get("myKey") + require.NoError(t, err) + require.Equal(t, 1337, val2) + + val3, err := differentStorageSameNamespace.Get("myKey") + require.NoError(t, err) + require.Equal(t, 9001, val3) +} + +func TestDeleteKey(t *testing.T) { + tmpDir := t.TempDir() + cacheStorage, err := cache.NewStorage(filepath.Join(tmpDir, "testdbfile.db")) + require.NoError(t, err) + + strNamespace := cache.New[string](cacheStorage, "myNameSpace") + err = strNamespace.Put("myKey", "someValue") + require.NoError(t, err) + + err = strNamespace.Delete("myKey") + require.NoError(t, err) + + _, err = strNamespace.Get("myKey") + require.Equal(t, cache.ErrorNotFound, err) +} + +func TestClearStorage(t *testing.T) { + tmpDir := t.TempDir() + cacheStorage, err := cache.NewStorage(filepath.Join(tmpDir, "testdbfile.db")) + require.NoError(t, err) + + strNamespace := cache.New[string](cacheStorage, "myNameSpace") + + err = strNamespace.Put("myKey", "myValue") + require.NoError(t, err) + + err = cacheStorage.Clear() + require.NoError(t, err) + + _, err = strNamespace.Get("myKey") + require.Equal(t, cache.ErrorNotFound, err) +} + +func TestKey(t *testing.T) { + singleKey := cache.Key("test1") + require.Equal(t, "test1", singleKey) + + multiKey := cache.Key("test1", "test2", "test3") + require.Equal(t, "test1test2test3", multiKey) +} diff --git a/pkg/clictx/clictx.go b/pkg/clictx/clictx.go new file mode 100755 index 000000000..46b09db8e --- /dev/null +++ b/pkg/clictx/clictx.go @@ -0,0 +1,34 @@ +package clictx + +import ( + "context" + "os" + "os/signal" +) + +// From creates a new context from ctx that is canceled when an exit signal received. +func From(ctx context.Context) context.Context { + var ( + ctxend, cancel = context.WithCancel(ctx) + quit = make(chan os.Signal, 1) + ) + signal.Notify(quit, os.Interrupt) + go func() { + <-quit + cancel() + }() + return ctxend +} + +// Do runs fn and waits for its result unless ctx is canceled. +// Returns fn result or canceled context error. +func Do(ctx context.Context, fn func() error) error { + errc := make(chan error) + go func() { errc <- fn() }() + select { + case err := <-errc: + return err + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/pkg/cliui/cliquiz/question.go b/pkg/cliui/cliquiz/question.go new file mode 100755 index 000000000..48b38a6bd --- /dev/null +++ b/pkg/cliui/cliquiz/question.go @@ -0,0 +1,210 @@ +// Package cliquiz is a tool to collect answers from the users on cli. +package cliquiz + +import ( + "context" + "errors" + "fmt" + "reflect" + "strings" + + "github.com/AlecAivazis/survey/v2" + "github.com/AlecAivazis/survey/v2/terminal" + "github.com/spf13/pflag" +) + +// ErrConfirmationFailed is returned when second answer is not the same with first one. +var ErrConfirmationFailed = errors.New("failed to confirm, your answers were different") + +// Question holds information on what to ask user and where +// the answer stored at. +type Question struct { + question string + defaultAnswer interface{} + answer interface{} + hidden bool + shouldConfirm bool + required bool +} + +// Option configures Question. +type Option func(*Question) + +// DefaultAnswer sets a default answer to Question. +func DefaultAnswer(answer interface{}) Option { + return func(q *Question) { + q.defaultAnswer = answer + } +} + +// Required marks the answer as required. +func Required() Option { + return func(q *Question) { + q.required = true + } +} + +// HideAnswer hides the answer to prevent secret information being leaked. +func HideAnswer() Option { + return func(q *Question) { + q.hidden = true + } +} + +// GetConfirmation prompts confirmation for the given answer. +func GetConfirmation() Option { + return func(q *Question) { + q.shouldConfirm = true + } +} + +// NewQuestion creates a new question. +func NewQuestion(question string, answer interface{}, options ...Option) Question { + q := Question{ + question: question, + answer: answer, + } + for _, o := range options { + o(&q) + } + return q +} + +func ask(q Question) error { + var prompt survey.Prompt + + if !q.hidden { + input := &survey.Input{ + Message: q.question, + } + if !q.required { + input.Message += " (optional)" + } + if q.defaultAnswer != nil { + input.Default = fmt.Sprintf("%v", q.defaultAnswer) + } + prompt = input + } else { + prompt = &survey.Password{ + Message: q.question, + } + } + + if err := survey.AskOne(prompt, q.answer); err != nil { + return err + } + + isValid := func() bool { + if answer, ok := q.answer.(string); ok { + if strings.TrimSpace(answer) == "" { + return false + } + } + if reflect.ValueOf(q.answer).Elem().IsZero() { + return false + } + return true + } + + if q.required && !isValid() { + fmt.Println("This information is required, please retry:") + + if err := ask(q); err != nil { + return err + } + } + + return nil +} + +// Ask asks questions and collect answers. +func Ask(question ...Question) (err error) { + defer func() { + if errors.Is(err, terminal.InterruptErr) { + err = context.Canceled + } + }() + + for _, q := range question { + if err := ask(q); err != nil { + return err + } + + if q.shouldConfirm { + var secondAnswer string + + var options []Option + if q.required { + options = append(options, Required()) + } + if q.hidden { + options = append(options, HideAnswer()) + } + if err := ask(NewQuestion("Confirm "+q.question, &secondAnswer, options...)); err != nil { + return err + } + + t := reflect.TypeOf(secondAnswer) + compAnswer := reflect.ValueOf(q.answer).Elem().Convert(t).String() + if secondAnswer != compAnswer { + return ErrConfirmationFailed + } + } + } + return nil +} + +// Flag represents a cmd flag. +type Flag struct { + Name string + IsRequired bool +} + +// NewFlag creates a new flag. +func NewFlag(name string, isRequired bool) Flag { + return Flag{name, isRequired} +} + +// ValuesFromFlagsOrAsk returns values of flags within map[string]string where map's +// key is the name of the flag and value is flag's value. +// when provided, values are collected through command otherwise they're asked by prompting user. +// title used as a message while prompting. +func ValuesFromFlagsOrAsk(fset *pflag.FlagSet, title string, flags ...Flag) (values map[string]string, err error) { + values = make(map[string]string) + + answers := make(map[string]*string) + var questions []Question + + for _, f := range flags { + flag := fset.Lookup(f.Name) + if flag == nil { + return nil, fmt.Errorf("flag %q is not defined", f.Name) + } + if value, _ := fset.GetString(f.Name); value != "" { + values[f.Name] = value + continue + } + + var value string + answers[f.Name] = &value + + var options []Option + if f.IsRequired { + options = append(options, Required()) + } + questions = append(questions, NewQuestion(flag.Usage, &value, options...)) + } + + if len(questions) > 0 && title != "" { + fmt.Println(title) + } + if err := Ask(questions...); err != nil { + return values, err + } + + for name, answer := range answers { + values[name] = *answer + } + + return values, nil +} diff --git a/pkg/cliui/clispinner/clispinner.go b/pkg/cliui/clispinner/clispinner.go new file mode 100755 index 000000000..1a5c628a4 --- /dev/null +++ b/pkg/cliui/clispinner/clispinner.go @@ -0,0 +1,118 @@ +package clispinner + +import ( + "io" + "time" + + "github.com/briandowns/spinner" +) + +// DefaultText defines the default spinner text. +const DefaultText = "Initializing..." + +var ( + refreshRate = time.Millisecond * 200 + charset = spinner.CharSets[4] + spinnerColor = "blue" +) + +type Spinner struct { + sp *spinner.Spinner +} + +type ( + Option func(*Options) + + Options struct { + writer io.Writer + text string + } +) + +// WithWriter configures an output for a spinner. +func WithWriter(w io.Writer) Option { + return func(options *Options) { + options.writer = w + } +} + +// WithText configures the spinner text. +func WithText(text string) Option { + return func(options *Options) { + options.text = text + } +} + +// New creates a new spinner. +func New(options ...Option) *Spinner { + o := Options{} + for _, apply := range options { + apply(&o) + } + + text := o.text + if text == "" { + text = DefaultText + } + + spOptions := []spinner.Option{ + spinner.WithColor(spinnerColor), + spinner.WithSuffix(" " + text), + } + + if o.writer != nil { + spOptions = append(spOptions, spinner.WithWriter(o.writer)) + } + + return &Spinner{ + sp: spinner.New(charset, refreshRate, spOptions...), + } +} + +// SetText sets the text for spinner. +func (s *Spinner) SetText(text string) *Spinner { + s.sp.Lock() + s.sp.Suffix = " " + text + s.sp.Unlock() + return s +} + +// SetPrefix sets the prefix for spinner. +func (s *Spinner) SetPrefix(text string) *Spinner { + s.sp.Lock() + s.sp.Prefix = text + " " + s.sp.Unlock() + return s +} + +// SetCharset sets the prefix for spinner. +func (s *Spinner) SetCharset(charset []string) *Spinner { + s.sp.UpdateCharSet(charset) + return s +} + +// SetColor sets the prefix for spinner. +func (s *Spinner) SetColor(color string) *Spinner { + s.sp.Color(color) + return s +} + +// Start starts spinning. +func (s *Spinner) Start() *Spinner { + s.sp.Start() + return s +} + +// Stop stops spinning. +func (s *Spinner) Stop() *Spinner { + s.sp.Stop() + s.sp.Prefix = "" + s.sp.Color(spinnerColor) + s.sp.UpdateCharSet(charset) + s.sp.Stop() + return s +} + +func (s *Spinner) IsActive() bool { + return s.sp.Active() +} diff --git a/pkg/cliui/cliui.go b/pkg/cliui/cliui.go new file mode 100755 index 000000000..8e00ae90a --- /dev/null +++ b/pkg/cliui/cliui.go @@ -0,0 +1,299 @@ +package cliui + +import ( + "fmt" + "io" + "os" + "sync" + + "github.com/manifoldco/promptui" + + "github.com/sonr-io/core/pkg/cliui/cliquiz" + "github.com/sonr-io/core/pkg/cliui/clispinner" + "github.com/sonr-io/core/pkg/cliui/entrywriter" + uilog "github.com/sonr-io/core/pkg/cliui/log" + "github.com/sonr-io/core/pkg/events" +) + +type sessionOptions struct { + stdin io.ReadCloser + stdout io.WriteCloser + stderr io.WriteCloser + + spinnerStart bool + spinnerText string + + ignoreEvents bool + verbosity uilog.Verbosity +} + +// Session controls command line interaction with users. +type Session struct { + options sessionOptions + ev events.Bus + spinner *clispinner.Spinner + out uilog.Output + wg *sync.WaitGroup + ended bool +} + +// Option configures session options. +type Option func(s *Session) + +// WithStdout sets the starndard output for the session. +func WithStdout(stdout io.WriteCloser) Option { + return func(s *Session) { + s.options.stdout = stdout + } +} + +// WithStderr sets base stderr for a Session. +func WithStderr(stderr io.WriteCloser) Option { + return func(s *Session) { + s.options.stderr = stderr + } +} + +// WithStdin sets the starndard input for the session. +func WithStdin(stdin io.ReadCloser) Option { + return func(s *Session) { + s.options.stdin = stdin + } +} + +// WithVerbosity sets a verbosity level for the Session. +func WithVerbosity(v uilog.Verbosity) Option { + return func(s *Session) { + s.options.verbosity = v + } +} + +// IgnoreEvents configures the session to avoid displaying events. +// This is a compatibility option to be able to use the session and +// the events bus when models are used to manage CLI UI. The session +// won't handle the events when this option is present. +func IgnoreEvents() Option { + return func(s *Session) { + s.options.ignoreEvents = true + } +} + +// StartSpinner forces spinner to be spinning right after creation. +func StartSpinner() Option { + return func(s *Session) { + s.options.spinnerStart = true + } +} + +// StartSpinnerWithText forces spinner to be spinning right after creation +// with a custom status text. +func StartSpinnerWithText(text string) Option { + return func(s *Session) { + s.options.spinnerStart = true + s.options.spinnerText = text + } +} + +// New creates a new Session. +func New(options ...Option) *Session { + session := Session{ + ev: events.NewBus(), + wg: &sync.WaitGroup{}, + options: sessionOptions{ + stdin: os.Stdin, + stdout: os.Stdout, + stderr: os.Stderr, + spinnerText: clispinner.DefaultText, + }, + } + + for _, apply := range options { + apply(&session) + } + + logOptions := []uilog.Option{ + uilog.WithStdout(session.options.stdout), + uilog.WithStderr(session.options.stderr), + } + + if session.options.verbosity == uilog.VerbosityVerbose { + logOptions = append(logOptions, uilog.Verbose()) + } + + session.out = uilog.NewOutput(logOptions...) + + if session.options.spinnerStart { + session.StartSpinner(session.options.spinnerText) + } + + // The main loop that prints the events uses a wait group to block + // the session end until all the events are printed. + if !session.options.ignoreEvents { + session.wg.Add(1) + go session.handleEvents() + } + + return &session +} + +// EventBus returns the event bus of the session. +func (s Session) EventBus() events.Bus { + return s.ev +} + +// Verbosity returns the verbosity level for the session output. +func (s Session) Verbosity() uilog.Verbosity { + return s.options.verbosity +} + +// NewOutput returns a new logging output bound to the session. +// The new output will use the session's verbosity, stderr and stdout. +// Label and color arguments are used to prefix the output when the +// session verbosity is verbose. +func (s Session) NewOutput(label, color string) uilog.Output { + options := []uilog.Option{ + uilog.WithStdout(s.options.stdout), + uilog.WithStderr(s.options.stderr), + } + + if s.options.verbosity == uilog.VerbosityVerbose { + options = append(options, uilog.CustomVerbose(label, color)) + } + + return uilog.NewOutput(options...) +} + +// StartSpinner starts the spinner. +func (s *Session) StartSpinner(text string) { + if s.options.ignoreEvents { + return + } + + // Verbose mode must not render the spinner but instead + // it should just print the text to display next to the + // app label otherwise the verbose logs would be printed + // with an invalid format. + if s.options.verbosity == uilog.VerbosityVerbose { + fmt.Fprint(s.out.Stdout(), text) + return + } + + if s.spinner == nil { + s.spinner = clispinner.New(clispinner.WithWriter(s.out.Stdout())) + } + + s.spinner.SetText(text).Start() +} + +// StopSpinner stops the spinner. +func (s Session) StopSpinner() { + if s.spinner == nil { + return + } + + s.spinner.Stop() +} + +// PauseSpinner pauses spinner and returns a function to restart the spinner. +func (s Session) PauseSpinner() (restart func()) { + isActive := s.spinner != nil && s.spinner.IsActive() + if isActive { + s.spinner.Stop() + } + + return func() { + if isActive { + s.spinner.Start() + } + } +} + +// Printf prints formatted arbitrary message. +func (s Session) Printf(format string, a ...interface{}) error { + defer s.PauseSpinner()() + _, err := fmt.Fprintf(s.out.Stdout(), format, a...) + return err +} + +// Println prints arbitrary message with line break. +func (s Session) Println(messages ...interface{}) error { + defer s.PauseSpinner()() + _, err := fmt.Fprintln(s.out.Stdout(), messages...) + return err +} + +// Print prints arbitrary message. +func (s Session) Print(messages ...interface{}) error { + defer s.PauseSpinner()() + _, err := fmt.Fprint(s.out.Stdout(), messages...) + return err +} + +// Ask asks questions in the terminal and collect answers. +func (s Session) Ask(questions ...cliquiz.Question) error { + defer s.PauseSpinner()() + // TODO provide writer from the session + return cliquiz.Ask(questions...) +} + +// AskConfirm asks yes/no question in the terminal. +func (s Session) AskConfirm(message string) error { + defer s.PauseSpinner()() + prompt := promptui.Prompt{ + Label: message, + IsConfirm: true, + Stdout: s.out.Stdout(), + Stdin: s.options.stdin, + } + _, err := prompt.Run() + return err +} + +// PrintTable prints table data. +func (s Session) PrintTable(header []string, entries ...[]string) error { + defer s.PauseSpinner()() + return entrywriter.MustWrite(s.out.Stdout(), header, entries...) +} + +// End finishes the session by stopping the spinner and the event bus. +// Once the session is ended it should not be used anymore. +func (s *Session) End() { + if s.ended { + return + } + + s.StopSpinner() + s.ev.Stop() + s.wg.Wait() + s.ended = true +} + +func (s *Session) handleEvents() { + defer s.wg.Done() + + stdout := s.out.Stdout() + + for e := range s.ev.Events() { + switch e.ProgressIndication { + case events.IndicationStart: + s.StartSpinner(e.String()) + case events.IndicationUpdate: + if s.spinner == nil { + // When the spinner is not initialized print the event + fmt.Fprintf(stdout, "%s\n", e) + } else { + // Otherwise update the spinner with a new text + s.spinner.SetText(e.String()) + } + case events.IndicationFinish: + s.StopSpinner() + fmt.Fprintf(stdout, "%s\n", e) + case events.IndicationNone: + default: + // The text printed here won't be removed when the spinner stops + resume := s.PauseSpinner() + fmt.Fprintf(stdout, "%s\n", e) + resume() + } + } +} diff --git a/pkg/cliui/colors/colors.go b/pkg/cliui/colors/colors.go new file mode 100755 index 000000000..6b53f41c9 --- /dev/null +++ b/pkg/cliui/colors/colors.go @@ -0,0 +1,71 @@ +package colors + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +const ( + Yellow = "#c4a000" + Red = "#ef2929" + Green = "#4e9a06" + Magenta = "#75507b" + Cyan = "#34e2e2" + White = "#FFFFFF" + HiBlue = "#729FCF" +) + +var ( + info = lipgloss.NewStyle().Foreground(lipgloss.Color(Yellow)) + infof = lipgloss.NewStyle().Foreground(lipgloss.Color(Yellow)) + err = lipgloss.NewStyle().Foreground(lipgloss.Color(Red)) + success = lipgloss.NewStyle().Foreground(lipgloss.Color(Green)) + modified = lipgloss.NewStyle().Foreground(lipgloss.Color(Magenta)) + name = lipgloss.NewStyle().Bold(true) + mnemonic = lipgloss.NewStyle().Foreground(lipgloss.Color(HiBlue)) + faint = lipgloss.NewStyle().Faint(true) +) + +// SprintFunc returns a function to apply a foreground color to any number of texts. +// The returned function receives strings as arguments with the text that should be colorized. +// Color specifies a color by hex or ANSI value. +func SprintFunc(color string) func(i ...interface{}) string { + return func(i ...interface{}) string { + style := lipgloss.NewStyle().Foreground(lipgloss.Color(color)) + return style.Render(fmt.Sprint(i...)) + } +} + +func Info(i ...interface{}) string { + return info.Render(fmt.Sprint(i...)) +} + +func Infof(format string, i ...interface{}) string { + return infof.Render(fmt.Sprintf(format, i...)) +} + +func Error(i ...interface{}) string { + return err.Render(fmt.Sprint(i...)) +} + +func Success(i ...interface{}) string { + return success.Render(fmt.Sprint(i...)) +} + +func Modified(i ...interface{}) string { + return modified.Render(fmt.Sprint(i...)) +} + +func Name(i ...interface{}) string { + return name.Render(fmt.Sprint(i...)) +} + +func Mnemonic(i ...interface{}) string { + return mnemonic.Render(fmt.Sprint(i...)) +} + +// Faint styles the text using a dimmer shade for the foreground color. +func Faint(i ...interface{}) string { + return faint.Render(fmt.Sprint(i...)) +} diff --git a/pkg/cliui/entrywriter/entrywriter.go b/pkg/cliui/entrywriter/entrywriter.go new file mode 100755 index 000000000..566aaeb66 --- /dev/null +++ b/pkg/cliui/entrywriter/entrywriter.go @@ -0,0 +1,66 @@ +package entrywriter + +import ( + "fmt" + "io" + "text/tabwriter" + + "github.com/pkg/errors" + + "github.com/sonr-io/core/pkg/xstrings" +) + +const ( + None = "-" +) + +var ErrInvalidFormat = errors.New("invalid entry format") + +// MustWrite writes into out the tabulated entries and panic if the entry format is invalid. +func MustWrite(out io.Writer, header []string, entries ...[]string) error { + err := Write(out, header, entries...) + if errors.Is(err, ErrInvalidFormat) { + panic(err) + } + return err +} + +// Write writes into out the tabulated entries. +func Write(out io.Writer, header []string, entries ...[]string) error { + w := &tabwriter.Writer{} + w.Init(out, 0, 8, 0, '\t', 0) + + formatLine := func(line []string, title bool) (formatted string) { + for _, cell := range line { + if title { + cell = xstrings.Title(cell) + } + formatted += fmt.Sprintf("%s \t", cell) + } + return formatted + } + + if len(header) == 0 { + return errors.Wrap(ErrInvalidFormat, "empty header") + } + + // write header + if _, err := fmt.Fprintln(w, formatLine(header, true)); err != nil { + return err + } + + // write entries + for i, entry := range entries { + if len(entry) != len(header) { + return errors.Wrapf(ErrInvalidFormat, "entry %d doesn't match header length", i) + } + if _, err := fmt.Fprintf(w, formatLine(entry, false)+"\n"); err != nil { + return err + } + } + + if _, err := fmt.Fprintln(w); err != nil { + return err + } + return w.Flush() +} diff --git a/pkg/cliui/entrywriter/entrywriter_test.go b/pkg/cliui/entrywriter/entrywriter_test.go new file mode 100755 index 000000000..3193304e6 --- /dev/null +++ b/pkg/cliui/entrywriter/entrywriter_test.go @@ -0,0 +1,40 @@ +package entrywriter_test + +import ( + "errors" + "io" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sonr-io/core/pkg/cliui/entrywriter" +) + +type WriterWithError struct{} + +func (WriterWithError) Write(_ []byte) (n int, err error) { + return 0, errors.New("writer with error") +} + +func TestWrite(t *testing.T) { + header := []string{"foobar", "bar", "foo"} + + entries := [][]string{ + {"foo", "bar", "foobar"}, + {"bar", "foobar", "foo"}, + {"foobar", "foo", "bar"}, + } + + require.NoError(t, entrywriter.Write(io.Discard, header, entries...)) + require.NoError(t, entrywriter.Write(io.Discard, header), "should allow no entry") + + err := entrywriter.Write(io.Discard, []string{}) + require.ErrorIs(t, err, entrywriter.ErrInvalidFormat, "should prevent no header") + + entries[0] = []string{"foo", "bar"} + err = entrywriter.Write(io.Discard, header, entries...) + require.ErrorIs(t, err, entrywriter.ErrInvalidFormat, "should prevent entry length mismatch") + + var wErr WriterWithError + require.Error(t, entrywriter.Write(wErr, header, entries...), "should catch writer errors") +} diff --git a/pkg/cliui/icons/icon.go b/pkg/cliui/icons/icon.go new file mode 100755 index 000000000..3c613e1e4 --- /dev/null +++ b/pkg/cliui/icons/icon.go @@ -0,0 +1,22 @@ +package icons + +import ( + "github.com/sonr-io/core/pkg/cliui/colors" +) + +var ( + Earth = "🌍" + CD = "💿" + User = "👤" + Command = "❯⎯" + Hook = "🪝" + + // OK is an OK mark. + OK = colors.SprintFunc(colors.Green)("✔") + // NotOK is a red cross mark. + NotOK = colors.SprintFunc(colors.Red)("✘") + // Bullet is a bullet mark. + Bullet = colors.SprintFunc(colors.Yellow)("⋆") + // Info is an info mark. + Info = colors.SprintFunc(colors.Yellow)("𝓲") +) diff --git a/pkg/cliui/lineprefixer/lineprefixer.go b/pkg/cliui/lineprefixer/lineprefixer.go new file mode 100755 index 000000000..578d9de78 --- /dev/null +++ b/pkg/cliui/lineprefixer/lineprefixer.go @@ -0,0 +1,48 @@ +// Package lineprefixer is a helpers to add prefixes to new lines. +package lineprefixer + +import ( + "bytes" + "io" +) + +// Writer is a prefixed line writer. +type Writer struct { + prefix func() string + w io.Writer + shouldPrefix bool +} + +// NewWriter returns a new Writer that adds prefixes to each line +// written. It then writes prefixed data stream into w. +func NewWriter(w io.Writer, prefix func() string) *Writer { + return &Writer{ + prefix: prefix, + w: w, + shouldPrefix: true, + } +} + +// Write implements io.Writer. +func (p *Writer) Write(b []byte) (n int, err error) { + var ( + numBytes = len(b) + lastChar = b[numBytes-1] + newLine = byte('\n') + snewLine = []byte{newLine} + replaceCount = bytes.Count(b, snewLine) + prefix = []byte(p.prefix()) + ) + if lastChar == newLine { + replaceCount-- + } + b = bytes.Replace(b, snewLine, append(snewLine, prefix...), replaceCount) + if p.shouldPrefix { + b = append(prefix, b...) + } + p.shouldPrefix = lastChar == newLine + if _, err := p.w.Write(b); err != nil { + return 0, err + } + return numBytes, nil +} diff --git a/pkg/cliui/lineprefixer/lineprefixer_test.go b/pkg/cliui/lineprefixer/lineprefixer_test.go new file mode 100755 index 000000000..5b5470756 --- /dev/null +++ b/pkg/cliui/lineprefixer/lineprefixer_test.go @@ -0,0 +1,27 @@ +package lineprefixer + +import ( + "bytes" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWriter(t *testing.T) { + logs := `hello, +this +is +Starport!` + buf := bytes.Buffer{} + w := NewWriter(&buf, func() string { return "[TENDERMINT] " }) + _, err := io.Copy(w, strings.NewReader(logs)) + require.NoError(t, err) + require.Equal(t, `[TENDERMINT] hello, +[TENDERMINT] this +[TENDERMINT] is +[TENDERMINT] Starport!`, + buf.String(), + ) +} diff --git a/pkg/cliui/log/output.go b/pkg/cliui/log/output.go new file mode 100755 index 000000000..b4e0b1d9f --- /dev/null +++ b/pkg/cliui/log/output.go @@ -0,0 +1,146 @@ +package uilog + +import ( + "io" + "os" + + "github.com/sonr-io/core/pkg/cliui/colors" + "github.com/sonr-io/core/pkg/cliui/lineprefixer" + "github.com/sonr-io/core/pkg/cliui/prefixgen" + "github.com/sonr-io/core/pkg/xio" +) + +const ( + defaultVerboseLabel = "ignite" + defaultVerboseLabelColor = colors.Red +) + +// Verbosity enumerates possible verbosity levels for CLI output. +type Verbosity uint8 + +const ( + VerbositySilent = iota + VerbosityDefault + VerbosityVerbose +) + +// Outputer defines an interface for logging output creation. +type Outputer interface { + // NewOutput returns a new logging output. + NewOutput(label, color string) Output + + // Verbosity returns the current verbosity level for the logging output. + Verbosity() Verbosity +} + +// Output stores writers for standard output and error. +type Output struct { + verbosity Verbosity + stdout io.WriteCloser + stderr io.WriteCloser +} + +// Stdout returns the standard output writer. +func (o Output) Stdout() io.WriteCloser { + return o.stdout +} + +// Stderr returns the standard error writer. +func (o Output) Stderr() io.WriteCloser { + return o.stderr +} + +// Verbosity returns the log output verbosity. +func (o Output) Verbosity() Verbosity { + return o.verbosity +} + +type option struct { + stdout io.WriteCloser + stderr io.WriteCloser + verbosity Verbosity + verboseLabel string + verboseLabelColor string +} + +// Option configures log output options. +type Option func(*option) + +// Verbose changes the log output to be prefixed with "ignite". +func Verbose() Option { + return func(o *option) { + o.verbosity = VerbosityVerbose + o.verboseLabel = defaultVerboseLabel + o.verboseLabelColor = defaultVerboseLabelColor + } +} + +// CustomVerbose changes the log output to be prefixed with a custom label. +func CustomVerbose(label, color string) Option { + return func(o *option) { + o.verbosity = VerbosityVerbose + o.verboseLabel = label + o.verboseLabelColor = color + } +} + +// Silent creates a log output that doesn't print any of the written lines. +func Silent() Option { + return func(o *option) { + o.verbosity = VerbositySilent + } +} + +// WithStdout sets a custom writer to use instead of the default `os.Stdout`. +func WithStdout(r io.WriteCloser) Option { + return func(o *option) { + o.stdout = r + } +} + +// WithStderr sets a custom writer to use instead of the default `os.Stderr`. +func WithStderr(r io.WriteCloser) Option { + return func(o *option) { + o.stderr = r + } +} + +// NewOutput creates a new log output. +// By default, the new output uses the default OS stdout and stderr to +// initialize the outputs with a default verbosity that doesn't change +// the output. +func NewOutput(options ...Option) (out Output) { + o := option{ + verbosity: VerbosityDefault, + stdout: os.Stdout, + stderr: os.Stderr, + } + + for _, apply := range options { + apply(&o) + } + + out.verbosity = o.verbosity + + switch o.verbosity { + case VerbositySilent: + out.stdout = xio.NopWriteCloser(io.Discard) + out.stderr = xio.NopWriteCloser(io.Discard) + case VerbosityVerbose: + // Function to add a custom prefix to each log output + prefixer := func(w io.Writer) *lineprefixer.Writer { + options := prefixgen.Common(prefixgen.Color(o.verboseLabelColor)) + prefix := prefixgen.New(o.verboseLabel, options...).Gen() + + return lineprefixer.NewWriter(w, func() string { return prefix }) + } + + out.stdout = xio.NopWriteCloser(prefixer(o.stdout)) + out.stderr = xio.NopWriteCloser(prefixer(o.stderr)) + default: + out.stdout = o.stdout + out.stderr = o.stderr + } + + return out +} diff --git a/pkg/cliui/model/events.go b/pkg/cliui/model/events.go new file mode 100755 index 000000000..e8d6fc50c --- /dev/null +++ b/pkg/cliui/model/events.go @@ -0,0 +1,255 @@ +package cliuimodel + +import ( + "container/list" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + + "github.com/sonr-io/core/pkg/cliui/colors" + "github.com/sonr-io/core/pkg/cliui/icons" + "github.com/sonr-io/core/pkg/events" +) + +// EventMsg defines a message for events. +type EventMsg struct { + events.Event + + Start time.Time + Duration time.Duration +} + +// NewStatusEvents returns a new events model. +func NewStatusEvents(bus events.Provider, maxHistory int) StatusEvents { + return StatusEvents{ + events: list.New(), + spinner: NewSpinner(), + maxHistory: maxHistory, + bus: bus, + } +} + +// StatusEvents defines a model for status events. +// The model renders a view that can be divided in three sections. +// The first one displays the "static" events which are the ones +// that are not status events. The second section displays a spinner +// with the status event that is in progress, and the third one +// displays a list with the past status events. +type StatusEvents struct { + static []events.Event + events *list.List + spinner spinner.Model + maxHistory int + bus events.Provider +} + +func (m *StatusEvents) ClearEvents() { + m.static = nil + m.events.Init() +} + +func (m StatusEvents) Wait() tea.Cmd { + return tea.Batch(spinner.Tick, m.WaitEvent) +} + +func (m StatusEvents) WaitEvent() tea.Msg { + e := <-m.bus.Events() + + return EventMsg{ + Start: time.Now(), + Event: e, + } +} + +func (m StatusEvents) Update(msg tea.Msg) (StatusEvents, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case EventMsg: + if msg.InProgress() { + // Save the duration of the current ongoing event before setting a new one + if e := m.events.Front(); e != nil { + evt := e.Value.(EventMsg) + evt.Duration = time.Since(evt.Start) + e.Value = evt + } + + // Add the event to the queue + m.events.PushFront(msg) + + // Only show a reduced history of events + if m.events.Len() > m.maxHistory { + m.events.Remove(m.events.Back()) + } + } else { + // Events that have no progress status are considered static, + // so they will be printed without the spinner and won't be + // removed from the output until the view is removed. + m.static = append(m.static, msg.Event) + } + + // Return a command to wait for the next event + cmd = m.Wait() + default: + // Update the spinner state and get a new tick command + m.spinner, cmd = m.spinner.Update(msg) + } + + return m, cmd +} + +func (m StatusEvents) View() string { + var view strings.Builder + + // Display static events first + for _, evt := range m.static { + view.WriteString(evt.String()) + + if !strings.HasSuffix(evt.Message, "\n") { + view.WriteRune('\n') + } + } + + // Make sure there is a line between the static and status events + if m.static != nil && m.events.Len() > 0 { + view.WriteRune('\n') + } + + // Display status events + if m.events.Len() > 0 { + for e := m.events.Front(); e != nil; e = e.Next() { + evt := e.Value.(EventMsg) + + // The first event is displayed using a spinner + if e.Prev() == nil { + fmt.Fprintf(&view, "%s%s\n", m.spinner.View(), evt) + + if e.Next() != nil { + view.WriteRune('\n') + } + + continue + } + + // Display finished status event + d := evt.Duration.Round(time.Second) + s := strings.TrimSuffix(evt.String(), "...") + + fmt.Fprintf(&view, "%s %s %s\n", icons.OK, s, colors.Faint(d.String())) + } + } + + return view.String() +} + +// NewEvents returns a new events model. +func NewEvents(bus events.Provider) Events { + return Events{ + events: list.New(), + bus: bus, + spinner: NewSpinner(), + } +} + +// Events defines a model for events. +// The model renders a view that prints all received events one after +// the other. Status events are displayed with a spinner and removed +// from the list once they finish. +type Events struct { + events *list.List + bus events.Provider + spinner spinner.Model +} + +func (m *Events) ClearEvents() { + m.events.Init() +} + +func (m Events) Wait() tea.Cmd { + // Check if the last added event is a status event + // and if so make sure that the spinner is updated. + if e := m.events.Back(); e != nil { + if evt := e.Value.(events.Event); evt.InProgress() { + return tea.Batch(spinner.Tick, m.WaitEvent) + } + } + + // By default, just wait until the next event is received + return m.WaitEvent +} + +func (m Events) WaitEvent() tea.Msg { + e := <-m.bus.Events() + + return EventMsg{ + Event: e, + Start: time.Now(), + } +} + +func (m Events) Update(msg tea.Msg) (Events, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case EventMsg: + // Remove the last event if is a status one. + // Status events must always be the last event in the list so the + // spinner is displayed at the bottom and not in between events. + // They are removed when another status event is received. + if e := m.events.Back(); e != nil { + if evt := e.Value.(events.Event); evt.InProgress() { + m.events.Remove(e) + } + } + + // Append event at the end of the list + m.events.PushBack(msg.Event) + + // Return a command to wait for the next event + cmd = m.Wait() + default: + // Update the spinner state and get a new tick command + m.spinner, cmd = m.spinner.Update(msg) + } + + return m, cmd +} + +func (m Events) View() string { + var ( + view strings.Builder + group string + ) + + // Display the list of events + for e := m.events.Front(); e != nil; e = e.Next() { + evt := e.Value.(events.Event) + + // Add an empty line when the event group changes but omit it + // for the first event to avoid adding an initial empty line. + if group != evt.Group && e.Prev() != nil { + // Update the group being displayed + group = evt.Group + + view.WriteRune('\n') + } + + if e.Next() == nil && evt.InProgress() { + // When the event is the last one and is a status event display a spinner... + fmt.Fprintf(&view, "\n%s%s", m.spinner.View(), evt) + } else { + // Otherwise display the event without the spinner + view.WriteString(evt.String()) + } + + // Make sure that events have an EOL, so they are displayed right below each other + if !strings.HasSuffix(evt.Message, "\n") { + view.WriteRune('\n') + } + } + + return view.String() +} diff --git a/pkg/cliui/model/events_test.go b/pkg/cliui/model/events_test.go new file mode 100755 index 000000000..a8ab79f95 --- /dev/null +++ b/pkg/cliui/model/events_test.go @@ -0,0 +1,88 @@ +package cliuimodel_test + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/sonr-io/core/pkg/cliui/colors" + "github.com/sonr-io/core/pkg/cliui/icons" + cliuimodel "github.com/sonr-io/core/pkg/cliui/model" + "github.com/sonr-io/core/pkg/events" +) + +func TestStatusEventsView(t *testing.T) { + // Arrange + spinner := cliuimodel.NewSpinner() + queue := []string{"Event 1...", "Event 2..."} + model := cliuimodel.NewStatusEvents(dummyEventsProvider{}, len(queue)) + want := fmt.Sprintf( + "Static event\n\n%s%s\n\n%s %s %s\n", + spinner.View(), + queue[1], + icons.OK, + strings.TrimSuffix(queue[0], "..."), + colors.Faint("0s"), + ) + + // Arrange: Update model with status events + for _, s := range queue { + model, _ = model.Update(cliuimodel.EventMsg{ + Event: events.New(s, events.ProgressStart()), + Start: time.Now(), + }) + } + + // Arrange: Add one static event + model, _ = model.Update(cliuimodel.EventMsg{ + Event: events.New("Static event"), + }) + + // Act + view := model.View() + + // Assert + require.Equal(t, want, view) +} + +func TestEventsView(t *testing.T) { + // Arrange + spinner := cliuimodel.NewSpinner() + model := cliuimodel.NewEvents(dummyEventsProvider{}) + queue := []string{"Event 1", "Event 2"} + want := fmt.Sprintf( + "%s\n%s\n\n%sStatus Event...\n", + queue[0], + queue[1], + spinner.View(), + ) + + // Arrange: Update model with events + for _, s := range queue { + model, _ = model.Update(cliuimodel.EventMsg{ + Event: events.New(s), + }) + } + + // Arrange: Add one status event + model, _ = model.Update(cliuimodel.EventMsg{ + Event: events.New("Status Event...", events.ProgressStart()), + }) + + // Act + view := model.View() + + // Assert + require.Equal(t, want, view) +} + +type dummyEventsProvider struct{} + +func (dummyEventsProvider) Events() <-chan events.Event { + c := make(chan events.Event) + close(c) + return c +} diff --git a/pkg/cliui/model/model.go b/pkg/cliui/model/model.go new file mode 100755 index 000000000..ede2681ea --- /dev/null +++ b/pkg/cliui/model/model.go @@ -0,0 +1,29 @@ +package cliuimodel + +import ( + "fmt" + + "github.com/muesli/reflow/indent" +) + +const ( + defaultIndent = 2 +) + +type ( + // ErrorMsg defines a message for errors. + ErrorMsg struct { + Error error + } + + // QuitMsg defines a message for stopping the command. + QuitMsg struct{} +) + +// FormatView formats a model view padding and indentation. +func FormatView(view string) string { + // Indent the view lines + view = indent.String(view, defaultIndent) + // Add top and bottom paddings + return fmt.Sprintf("\n%s\n", view) +} diff --git a/pkg/cliui/model/spinner.go b/pkg/cliui/model/spinner.go new file mode 100755 index 000000000..a65ee2271 --- /dev/null +++ b/pkg/cliui/model/spinner.go @@ -0,0 +1,25 @@ +package cliuimodel + +import ( + "time" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/lipgloss" +) + +// ColorSpinner defines the foreground color for the spinner. +const ColorSpinner = "#3465A4" + +// Spinner defines the spinner model animation. +var Spinner = spinner.Spinner{ + Frames: []string{"◢ ", "◣ ", "◤ ", "◥ "}, + FPS: time.Second / 5, +} + +// NewSpinner returns a new spinner model. +func NewSpinner() spinner.Model { + s := spinner.New() + s.Spinner = Spinner + s.Style.Foreground(lipgloss.Color(ColorSpinner)) + return s +} diff --git a/pkg/cliui/prefixgen/prefixgen.go b/pkg/cliui/prefixgen/prefixgen.go new file mode 100755 index 000000000..c0c2d3fc3 --- /dev/null +++ b/pkg/cliui/prefixgen/prefixgen.go @@ -0,0 +1,88 @@ +// Package prefixgen is a prefix generation helper for log messages +// and any other kind. +package prefixgen + +import ( + "fmt" + "strings" + + "github.com/sonr-io/core/pkg/cliui/colors" +) + +// Prefixer generates prefixes. +type Prefixer struct { + format string + color string + left, right string + convertUppercase bool +} + +// Option configures Prefixer. +type Option func(p *Prefixer) + +// Color sets color to the prefix. +func Color(color string) Option { + return func(p *Prefixer) { + p.color = color + } +} + +// SquareBrackets adds square brackets to the prefix. +func SquareBrackets() Option { + return func(p *Prefixer) { + p.left = "[" + p.right = "]" + } +} + +// SpaceRight adds rights space to the prefix. +func SpaceRight() Option { + return func(p *Prefixer) { + p.right += " " + } +} + +// Uppercase formats the prefix to uppercase. +func Uppercase() Option { + return func(p *Prefixer) { + p.convertUppercase = true + } +} + +// Common holds some common prefix options and extends those +// options by given options. +func Common(options ...Option) []Option { + return append([]Option{ + SquareBrackets(), + SpaceRight(), + Uppercase(), + }, options...) +} + +// New creates a new Prefixer with format and options. +// Format is an fmt.Sprintf() like format to dynamically create prefix texts +// as needed. +func New(format string, options ...Option) *Prefixer { + p := &Prefixer{ + format: format, + } + for _, o := range options { + o(p) + } + return p +} + +// Gen generates a new prefix by applying s to format given during New(). +func (p *Prefixer) Gen(s ...interface{}) string { + format := p.format + format = p.left + format + format += p.right + prefix := fmt.Sprintf(format, s...) + if p.convertUppercase { + prefix = strings.ToUpper(prefix) + } + if p.color != "" { + return colors.SprintFunc(p.color)(prefix) + } + return prefix +} diff --git a/pkg/cliui/prefixgen/prefixgen_test.go b/pkg/cliui/prefixgen/prefixgen_test.go new file mode 100755 index 000000000..17168aa5d --- /dev/null +++ b/pkg/cliui/prefixgen/prefixgen_test.go @@ -0,0 +1,23 @@ +package prefixgen + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGen(t *testing.T) { + cases := []struct { + expected string + given string + }{ + {"[TENDERMINT] ", New("Tendermint", Common()...).Gen()}, + {"Tendermint", New("Tendermint").Gen()}, + {"appd", New("%sd").Gen("app")}, + } + for _, tt := range cases { + t.Run(tt.expected, func(t *testing.T) { + require.Equal(t, tt.expected, tt.given) + }) + } +} diff --git a/pkg/cliui/view/accountview/account.go b/pkg/cliui/view/accountview/account.go new file mode 100755 index 000000000..31cd2d95d --- /dev/null +++ b/pkg/cliui/view/accountview/account.go @@ -0,0 +1,82 @@ +package accountview + +import ( + "fmt" + "strings" + + "github.com/muesli/reflow/indent" + "github.com/muesli/reflow/wordwrap" + + "github.com/sonr-io/core/pkg/cliui/colors" + "github.com/sonr-io/core/pkg/cliui/icons" +) + +var ( + fmtExistingAccount = "%s %s's account address: %s\n" + fmtNewAccount = "%s Added account %s with address %s and mnemonic:\n%s\n" +) + +type Option func(*Account) + +type Account struct { + Name string + Address string + Mnemonic string +} + +func WithMnemonic(mnemonic string) Option { + return func(a *Account) { + a.Mnemonic = mnemonic + } +} + +func NewAccount(name, address string, options ...Option) Account { + a := Account{ + Name: name, + Address: address, + } + + for _, apply := range options { + apply(&a) + } + + return a +} + +func (a Account) String() string { + name := colors.Name(a.Name) + + // The account is new when the mnemonic is available + if a.Mnemonic != "" { + m := wordwrap.String(a.Mnemonic, 80) + m = indent.String(m, 2) + + return fmt.Sprintf(fmtNewAccount, icons.OK, name, a.Address, colors.Mnemonic(m)) + } + + return fmt.Sprintf(fmtExistingAccount, icons.User, name, a.Address) +} + +type Accounts []Account + +func (a Accounts) String() string { + b := strings.Builder{} + + for i, acc := range a { + // Make sure accounts are separated by an + // empty line when the mnemonic is available. + if i > 0 && acc.Mnemonic != "" { + b.WriteRune('\n') + } + + b.WriteString(acc.String()) + } + + b.WriteRune('\n') + + return b.String() +} + +func (a Accounts) Append(acc Account) Accounts { + return append(a, acc) +} diff --git a/pkg/cliui/view/errorview/error.go b/pkg/cliui/view/errorview/error.go new file mode 100755 index 000000000..c4ba92f5e --- /dev/null +++ b/pkg/cliui/view/errorview/error.go @@ -0,0 +1,28 @@ +package errorview + +import ( + "strings" + + "github.com/muesli/reflow/wordwrap" + + "github.com/sonr-io/core/pkg/cliui/colors" +) + +func NewError(err error) Error { + return Error{err} +} + +type Error struct { + Err error +} + +func (e Error) String() string { + s := strings.TrimSpace(e.Err.Error()) + + w := wordwrap.NewWriter(80) + w.Breakpoints = []rune{' '} + w.Write([]byte(s)) + w.Close() + + return colors.Error(w.String()) +} diff --git a/pkg/cmdrunner/cmdrunner.go b/pkg/cmdrunner/cmdrunner.go new file mode 100644 index 000000000..b1e6dffe0 --- /dev/null +++ b/pkg/cmdrunner/cmdrunner.go @@ -0,0 +1,262 @@ +package cmdrunner + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "golang.org/x/sync/errgroup" + + "github.com/sonr-io/core/pkg/cmdrunner/step" + "github.com/sonr-io/core/pkg/env" + "github.com/sonr-io/core/pkg/goenv" +) + +// Runner is an object to run commands. +type Runner struct { + endSignal os.Signal + stdout io.Writer + stderr io.Writer + stdin io.Reader + workdir string + runParallel bool + debug bool +} + +// Option defines option to run commands. +type Option func(*Runner) + +// DefaultStdout provides the default stdout for the commands to run. +func DefaultStdout(writer io.Writer) Option { + return func(r *Runner) { + r.stdout = writer + } +} + +// DefaultStderr provides the default stderr for the commands to run. +func DefaultStderr(writer io.Writer) Option { + return func(r *Runner) { + r.stderr = writer + } +} + +// DefaultStdin provides the default stdin for the commands to run. +func DefaultStdin(reader io.Reader) Option { + return func(r *Runner) { + r.stdin = reader + } +} + +// DefaultWorkdir provides the default working directory for the commands to run. +func DefaultWorkdir(path string) Option { + return func(r *Runner) { + r.workdir = path + } +} + +// RunParallel allows commands to run concurrently. +func RunParallel() Option { + return func(r *Runner) { + r.runParallel = true + } +} + +// EndSignal configures s to be signaled to the processes to end them. +func EndSignal(s os.Signal) Option { + return func(r *Runner) { + r.endSignal = s + } +} + +func EnableDebug() Option { + return func(r *Runner) { + r.debug = true + } +} + +// New returns a new command runner. +func New(options ...Option) *Runner { + runner := &Runner{ + endSignal: os.Interrupt, + debug: env.DebugEnabled(), + } + for _, apply := range options { + apply(runner) + } + return runner +} + +// Run blocks until all steps have completed their executions. +func (r *Runner) Run(ctx context.Context, steps ...*step.Step) error { + if len(steps) == 0 { + return nil + } + g, ctx := errgroup.WithContext(ctx) + for i, step := range steps { + // copy s to a new variable to allocate a new address, + // so we can safely use it inside goroutines spawned in this loop. + if r.debug { + var cd string + if step.Workdir != "" { + cd = fmt.Sprintf("cd %s;", step.Workdir) + } + fmt.Printf("Step %d: %s%s %s %s\n", i, cd, strings.Join(step.Env, " "), + step.Exec.Command, + strings.Join(step.Exec.Args, " ")) + } + step := step + if err := ctx.Err(); err != nil { + return err + } + if err := step.PreExec(); err != nil { + return err + } + runPostExecs := func(processErr error) error { + // if context is canceled, then we can ignore exit error of the + // process because it should be exited because of the cancellation. + var err error + ctxErr := ctx.Err() + if ctxErr != nil { + err = ctxErr + } else { + err = processErr + } + for _, exec := range step.PostExecs { + if err := exec(err); err != nil { + return err + } + } + if len(step.PostExecs) > 0 { + return nil + } + return err + } + command := r.newCommand(step) + startErr := command.Start() + if startErr != nil { + if err := runPostExecs(startErr); err != nil { + return err + } + continue + } + go func() { + <-ctx.Done() + command.Signal(r.endSignal) + }() + if err := step.InExec(); err != nil { + return err + } + if len(step.WriteData) > 0 { + if _, err := command.Write(step.WriteData); err != nil { + return err + } + } + if r.runParallel { + g.Go(func() error { + return runPostExecs(command.Wait()) + }) + } else if err := runPostExecs(command.Wait()); err != nil { + return err + } + } + return g.Wait() +} + +// Executor represents a command to execute. +type Executor interface { + Wait() error + Start() error + Signal(os.Signal) + Write(data []byte) (n int, err error) +} + +// dummyExecutor is an executor that does nothing. +type dummyExecutor struct{} + +func (e *dummyExecutor) Start() error { return nil } + +func (e *dummyExecutor) Wait() error { return nil } + +func (e *dummyExecutor) Signal(os.Signal) {} + +func (e *dummyExecutor) Write([]byte) (int, error) { return 0, nil } + +// cmdSignal is an executor with signal processing. +type cmdSignal struct { + *exec.Cmd +} + +func (e *cmdSignal) Signal(s os.Signal) { _ = e.Cmd.Process.Signal(s) } + +func (e *cmdSignal) Write([]byte) (n int, err error) { return 0, nil } + +// cmdSignalWithWriter is an executor with signal processing and that can write into stdin. +type cmdSignalWithWriter struct { + *exec.Cmd + w io.WriteCloser +} + +func (e *cmdSignalWithWriter) Signal(s os.Signal) { _ = e.Cmd.Process.Signal(s) } + +func (e *cmdSignalWithWriter) Write(data []byte) (n int, err error) { + defer e.w.Close() + return e.w.Write(data) +} + +// newCommand returns a new command to execute. +func (r *Runner) newCommand(step *step.Step) Executor { + // Return a dummy executor in case of an empty command + if step.Exec.Command == "" { + return &dummyExecutor{} + } + var ( + stdout = step.Stdout + stderr = step.Stderr + stdin = step.Stdin + dir = step.Workdir + ) + + // Define standard input and outputs + if stdout == nil { + stdout = r.stdout + } + if stderr == nil { + stderr = r.stderr + } + if stdin == nil { + stdin = r.stdin + } + if dir == "" { + dir = r.workdir + } + + // Initialize command + command := exec.Command(step.Exec.Command, step.Exec.Args...) + command.Stdout = stdout + command.Stderr = stderr + command.Dir = dir + command.Env = append(os.Environ(), step.Env...) + command.Env = append(command.Env, Env("PATH", goenv.Path())) + + // If a custom stdin is provided it will be as the stdin for the command + if stdin != nil { + command.Stdin = stdin + return &cmdSignal{command} + } + + // If no custom stdin, the executor can write into the stdin of the program + writer, err := command.StdinPipe() + if err != nil { + // TODO do not panic + panic(err) + } + return &cmdSignalWithWriter{command, writer} +} + +// Env returns a new env var value from key and val. +func Env(key, val string) string { + return fmt.Sprintf("%s=%s", key, val) +} diff --git a/pkg/cmdrunner/exec/exec.go b/pkg/cmdrunner/exec/exec.go new file mode 100644 index 000000000..5e588bd4f --- /dev/null +++ b/pkg/cmdrunner/exec/exec.go @@ -0,0 +1,87 @@ +// Package exec provides easy access to command execution for basic uses. +package exec + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + + "github.com/pkg/errors" + + "github.com/sonr-io/core/pkg/cmdrunner" + "github.com/sonr-io/core/pkg/cmdrunner/step" +) + +// ExitError is an alias to exec.ExitError. +type ExitError = exec.ExitError + +type execConfig struct { + stepOptions []step.Option + includeStdLogsToError bool +} + +type Option func(*execConfig) + +func StepOption(o step.Option) Option { + return func(c *execConfig) { + c.stepOptions = append(c.stepOptions, o) + } +} + +func IncludeStdLogsToError() Option { + return func(c *execConfig) { + c.includeStdLogsToError = true + } +} + +// Exec executes a command with args, it's a shortcut func for basic command executions. +func Exec(ctx context.Context, fullCommand []string, options ...Option) error { + errb := &bytes.Buffer{} + logs := &bytes.Buffer{} + + c := &execConfig{ + stepOptions: []step.Option{ + step.Exec(fullCommand[0], fullCommand[1:]...), + step.Stdout(logs), + step.Stderr(errb), + }, + } + + for _, apply := range options { + apply(c) + } + + err := cmdrunner.New().Run(ctx, step.New(c.stepOptions...)) + if err != nil { + return &Error{ + Err: errors.Wrap(err, errb.String()), + Command: strings.Join(fullCommand, " "), + StdLogs: logs.String(), + includeStdLogsToError: c.includeStdLogsToError, + } + } + + return nil +} + +// Error provides detailed errors from the executed program. +type Error struct { + Err error + Command string + StdLogs string // collected logs from code generation tools. + includeStdLogsToError bool +} + +func (e *Error) Unwrap() error { + return e.Err +} + +func (e *Error) Error() string { + message := fmt.Sprintf("error while running command %s: %s", e.Command, e.Err.Error()) + if e.includeStdLogsToError && strings.TrimSpace(e.StdLogs) != "" { + return fmt.Sprintf("%s\n\n%s", message, e.StdLogs) + } + return message +} diff --git a/pkg/cmdrunner/step/step.go b/pkg/cmdrunner/step/step.go new file mode 100644 index 000000000..b085fb9c9 --- /dev/null +++ b/pkg/cmdrunner/step/step.go @@ -0,0 +1,118 @@ +package step + +import ( + "io" +) + +type Step struct { + Exec Execution + PreExec func() error + InExec func() error + PostExecs []func(error) error + Stdout io.Writer + Stderr io.Writer + Stdin io.Reader + Workdir string + Env []string + WriteData []byte +} + +type Option func(*Step) + +type Options []Option + +func NewOptions() Options { + return Options{} +} + +func (o Options) Add(options ...Option) Options { + return append(o, options...) +} + +func New(options ...Option) *Step { + s := &Step{ + PreExec: func() error { return nil }, + InExec: func() error { return nil }, + PostExecs: make([]func(error) error, 0), + } + for _, o := range options { + o(s) + } + return s +} + +type Execution struct { + Command string + Args []string +} + +func Exec(command string, args ...string) Option { + return func(s *Step) { + s.Exec = Execution{command, args} + } +} + +func PreExec(hook func() error) Option { + return func(s *Step) { + s.PreExec = hook + } +} + +func InExec(hook func() error) Option { + return func(s *Step) { + s.InExec = hook + } +} + +func PostExec(hook func(exitErr error) error) Option { // *os.ExitError + return func(s *Step) { + s.PostExecs = append(s.PostExecs, hook) + } +} + +func Stdout(w io.Writer) Option { + return func(s *Step) { + s.Stdout = w + } +} + +func Stderr(w io.Writer) Option { + return func(s *Step) { + s.Stderr = w + } +} + +func Stdin(r io.Reader) Option { + return func(s *Step) { + s.Stdin = r + } +} + +func Workdir(path string) Option { + return func(s *Step) { + s.Workdir = path + } +} + +func Env(e ...string) Option { + return func(s *Step) { + s.Env = e + } +} + +func Write(data []byte) Option { + return func(s *Step) { + s.WriteData = data + } +} + +type Steps []*Step + +func NewSteps(steps ...*Step) Steps { + return steps +} + +func (s *Steps) Add(steps ...*Step) Steps { + *s = append(*s, steps...) + return *s +} diff --git a/pkg/env/env.go b/pkg/env/env.go new file mode 100644 index 000000000..390fd12d1 --- /dev/null +++ b/pkg/env/env.go @@ -0,0 +1,37 @@ +package env + +import ( + "fmt" + "os" + "path" + + "github.com/sonr-io/core/pkg/xfilepath" +) + +const ( + debug = "SONR_DEBUG" + configDir = "SONR_CONFIG_DIR" +) + +func DebugEnabled() bool { + return os.Getenv(debug) == "1" +} + +func ConfigDir() xfilepath.PathRetriever { + return func() (string, error) { + if dir := os.Getenv(configDir); dir != "" { + if !path.IsAbs(dir) { + panic(fmt.Sprintf("%s must be an absolute path", configDir)) + } + return dir, nil + } + return xfilepath.JoinFromHome(xfilepath.Path(".ignite"))() + } +} + +func SetConfigDir(dir string) { + err := os.Setenv(configDir, dir) + if err != nil { + panic(fmt.Sprintf("set config dir env: %v", err)) + } +} diff --git a/pkg/events/bus.go b/pkg/events/bus.go new file mode 100644 index 000000000..e82f69ed7 --- /dev/null +++ b/pkg/events/bus.go @@ -0,0 +1,95 @@ +package events + +import ( + "fmt" +) + +// DefaultBufferSize defines the default maximum number +// of events that the bus can cache before they are handled. +const DefaultBufferSize = 50 + +// Provider defines an interface for event providers. +type Provider interface { + // Events returns a read only channel to read the events. + Events() <-chan Event +} + +type ( + // Bus defines a bus to send and receive events. + Bus struct { + evChan chan Event + stopped bool + } + + // BusOption configures the Bus. + BusOption func(*Bus) +) + +// WithBufferSize assigns the size of the buffer to use for buffering events. +func WithBufferSize(size int) BusOption { + return func(bus *Bus) { + bus.evChan = make(chan Event, size) + } +} + +// NewBus creates a new event bus. +func NewBus(options ...BusOption) Bus { + bus := Bus{ + evChan: make(chan Event, DefaultBufferSize), + } + + for _, apply := range options { + apply(&bus) + } + + return bus +} + +// Send sends a new event to bus. +// This method will block if the event bus buffer is full. +func (b Bus) Send(message string, options ...Option) { + if b.evChan == nil || b.stopped { + return + } + + b.evChan <- New(message, options...) +} + +// Sendf sends a new event with a formatted message to bus. +func (b Bus) Sendf(format string, a ...any) { + b.Send(fmt.Sprintf(format, a...)) +} + +// SendInfo sends an info event to the bus. +func (b Bus) SendInfo(message string, options ...Option) { + b.Send(message, options...) +} + +// SendError sends an error event to the bus. +func (b Bus) SendError(err error, options ...Option) { + b.Send(err.Error(), options...) +} + +// SendView sends a new event for a view to the bus. +// Views are types that implement the `fmt.Stringer` interface +// which allow events with complex message formats. +func (b Bus) SendView(s fmt.Stringer, options ...Option) { + b.Send(s.String(), options...) +} + +// Events returns a read only channel to read the events. +func (b Bus) Events() <-chan Event { + return b.evChan +} + +// Stop stops the event bus. +// All new events are ignored once the event bus is stopped. +func (b *Bus) Stop() { + if b.evChan == nil { + return + } + + b.stopped = true + + close(b.evChan) +} diff --git a/pkg/events/events.go b/pkg/events/events.go new file mode 100644 index 000000000..d5a47d79c --- /dev/null +++ b/pkg/events/events.go @@ -0,0 +1,116 @@ +// Package events provides functionalities for packages to log their states as events +// for others to consume and display to end users in meaningful ways. +package events + +import ( + "fmt" + + "github.com/muesli/reflow/indent" +) + +// ProgressIndication enumerates possible states of progress indication for an Event. +type ProgressIndication uint8 + +const ( + GroupError = "error" +) + +const ( + IndicationNone ProgressIndication = iota + IndicationStart + IndicationUpdate + IndicationFinish +) + +type ( + // Event represents a state. + Event struct { + ProgressIndication ProgressIndication + Icon string + Indent uint + Message string + Verbose bool + Group string + } + + // Option event options. + Option func(*Event) +) + +// ProgressStart indicates that a status event starts the progress indicator. +func ProgressStart() Option { + return func(e *Event) { + e.ProgressIndication = IndicationStart + } +} + +// ProgressUpdate indicates that a status event updated the current progress. +func ProgressUpdate() Option { + return func(e *Event) { + e.ProgressIndication = IndicationUpdate + } +} + +// ProgressFinish indicates that a status event finished the ongoing task. +func ProgressFinish() Option { + return func(e *Event) { + e.ProgressIndication = IndicationFinish + } +} + +// Verbose sets high verbosity for the Event. +func Verbose() Option { + return func(e *Event) { + e.Verbose = true + } +} + +// Icon sets the text icon prefix. +func Icon(icon string) Option { + return func(e *Event) { + e.Icon = icon + } +} + +// Indent sets the text indentation. +func Indent(indent uint) Option { + return func(e *Event) { + e.Indent = indent + } +} + +// Group sets a group name for the event. +func Group(name string) Option { + return func(e *Event) { + e.Group = name + } +} + +// New creates a new event with given config. +func New(message string, options ...Option) Event { + ev := Event{Message: message} + + for _, applyOption := range options { + applyOption(&ev) + } + + return ev +} + +func (e Event) String() string { + s := e.Message + if e.Icon != "" { + s = fmt.Sprintf("%s %s", e.Icon, s) + } + + if e.Indent > 0 { + s = indent.String(s, e.Indent) + } + + return s +} + +// InProgress returns true when the event is in progress. +func (e Event) InProgress() bool { + return e.ProgressIndication == IndicationStart || e.ProgressIndication == IndicationUpdate +} diff --git a/pkg/events/events_test.go b/pkg/events/events_test.go new file mode 100644 index 000000000..977031b82 --- /dev/null +++ b/pkg/events/events_test.go @@ -0,0 +1,54 @@ +package events_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sonr-io/core/pkg/events" +) + +func TestNew(t *testing.T) { + msg := "message" + cases := []struct { + name, message string + inProgress, hasIcon bool + options []events.Option + event events.Event + }{ + { + name: "event", + event: events.Event{}, + }, + { + name: "event start", + message: msg, + inProgress: true, + options: []events.Option{events.ProgressStart()}, + event: events.New(msg, events.ProgressStart()), + }, + { + name: "event update", + message: msg, + inProgress: true, + options: []events.Option{events.ProgressUpdate()}, + event: events.New(msg, events.ProgressUpdate()), + }, + { + name: "event finish", + message: msg, + options: []events.Option{events.ProgressFinish()}, + event: events.New(msg, events.ProgressFinish()), + }, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + // Act + e := events.New(tt.message, tt.options...) + + // Assert + require.Equal(t, tt.event, e) + require.Equal(t, tt.inProgress, e.InProgress()) + }) + } +} diff --git a/pkg/gocmd/gocmd.go b/pkg/gocmd/gocmd.go new file mode 100644 index 000000000..e5d7b0f1e --- /dev/null +++ b/pkg/gocmd/gocmd.go @@ -0,0 +1,230 @@ +package gocmd + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/sonr-io/core/pkg/cmdrunner/exec" + "github.com/sonr-io/core/pkg/cmdrunner/step" + "github.com/sonr-io/core/pkg/goenv" +) + +const ( + // CommandInstall represents go "install" command. + CommandInstall = "install" + + // CommandGet represents go "get" command. + CommandGet = "get" + + // CommandBuild represents go "build" command. + CommandBuild = "build" + + // CommandMod represents go "mod" command. + CommandMod = "mod" + + // CommandModTidy represents go mod "tidy" command. + CommandModTidy = "tidy" + + // CommandModVerify represents go mod "verify" command. + CommandModVerify = "verify" + + // CommandFmt represents go "fmt" command. + CommandFmt = "fmt" + + // CommandEnv represents go "env" command. + CommandEnv = "env" + + // CommandList represents go "list" command. + CommandList = "list" + + // EnvGOARCH represents GOARCH variable. + EnvGOARCH = "GOARCH" + // EnvGOMOD represents GOMOD variable. + EnvGOMOD = "GOMOD" + // EnvGOOS represents GOOS variable. + EnvGOOS = "GOOS" + + // FlagGcflags represents gcflags go flag. + FlagGcflags = "-gcflags" + // FlagGcflagsValueDebug represents debug go flags. + FlagGcflagsValueDebug = "all=-N -l" + // FlagLdflags represents ldflags go flag. + FlagLdflags = "-ldflags" + // FlagTags represents tags go flag. + FlagTags = "-tags" + // FlagMod represents mod go flag. + FlagMod = "-mod" + // FlagModValueReadOnly represents readonly go flag. + FlagModValueReadOnly = "readonly" + // FlagOut represents out go flag. + FlagOut = "-o" +) + +// Env returns the value of `go env name`. +func Env(name string) (string, error) { + var b bytes.Buffer + err := exec.Exec(context.Background(), []string{Name(), CommandEnv, name}, exec.StepOption(step.Stdout(&b))) + return b.String(), err +} + +// Name returns the name of Go binary to use. +func Name() string { + custom := os.Getenv("GONAME") + if custom != "" { + return custom + } + return "go" +} + +// Fmt runs go fmt on path. +func Fmt(ctx context.Context, path string, options ...exec.Option) error { + return exec.Exec(ctx, []string{Name(), CommandFmt, "./..."}, append(options, exec.StepOption(step.Workdir(path)))...) +} + +// ModTidy runs go mod tidy on path with options. +func ModTidy(ctx context.Context, path string, options ...exec.Option) error { + return exec.Exec(ctx, []string{Name(), CommandMod, CommandModTidy}, + append(options, + exec.StepOption(step.Workdir(path)), + // FIXME(tb) untagged version of ignite/cli triggers a 404 not found when go + // mod tidy requests the sumdb, until we understand why, we disable sumdb. + // related issue: https://github.com/golang/go/issues/56174 + // Also disable Go toolchain download because it doesn't work without a valid + // GOSUMDB value: https://go.dev/doc/toolchain#download + exec.StepOption(step.Env("GOSUMDB=off", "GOTOOLCHAIN=local+path")), + )...) +} + +// ModVerify runs go mod verify on path with options. +func ModVerify(ctx context.Context, path string, options ...exec.Option) error { + return exec.Exec(ctx, []string{Name(), CommandMod, CommandModVerify}, append(options, exec.StepOption(step.Workdir(path)))...) +} + +// BuildPath runs go install on cmd folder with options. +func BuildPath(ctx context.Context, output, binary, path string, flags []string, options ...exec.Option) error { + binaryOutput, err := binaryPath(output, binary) + if err != nil { + return err + } + command := []string{ + Name(), + CommandBuild, + FlagOut, binaryOutput, + } + command = append(command, flags...) + command = append(command, ".") + return exec.Exec(ctx, command, append(options, exec.StepOption(step.Workdir(path)))...) +} + +// Build runs go build on path with options. +func Build(ctx context.Context, out, path string, flags []string, options ...exec.Option) error { + command := []string{ + Name(), + CommandBuild, + FlagOut, out, + } + command = append(command, flags...) + return exec.Exec(ctx, command, append(options, exec.StepOption(step.Workdir(path)))...) +} + +// InstallAll runs go install ./... on path with options. +func InstallAll(ctx context.Context, path string, flags []string, options ...exec.Option) error { + command := []string{ + Name(), + CommandInstall, + } + command = append(command, flags...) + command = append(command, "./...") + return exec.Exec(ctx, command, append(options, exec.StepOption(step.Workdir(path)))...) +} + +// Install runs go install pkgs on path with options. +func Install(ctx context.Context, path string, pkgs []string, options ...exec.Option) error { + command := []string{ + Name(), + CommandInstall, + } + command = append(command, pkgs...) + return exec.Exec(ctx, command, append(options, exec.StepOption(step.Workdir(path)))...) +} + +// IsInstallError returns true if err is interpreted as a go install error. +func IsInstallError(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "no required module provides package") +} + +// Get runs go get pkgs on path with options. +func Get(ctx context.Context, path string, pkgs []string, options ...exec.Option) error { + command := []string{ + Name(), + CommandGet, + } + command = append(command, pkgs...) + return exec.Exec(ctx, command, append(options, exec.StepOption(step.Workdir(path)))...) +} + +// List returns the list of packages in path. +func List(ctx context.Context, path string, flags []string, options ...exec.Option) ([]string, error) { + command := []string{ + Name(), + CommandList, + } + command = append(command, flags...) + var b bytes.Buffer + err := exec.Exec(ctx, command, + append(options, exec.StepOption(step.Workdir(path)), exec.StepOption(step.Stdout(&b)))...) + if err != nil { + return nil, err + } + return strings.Fields(b.String()), nil +} + +// Ldflags returns a combined ldflags set from flags. +func Ldflags(flags ...string) string { + return strings.Join(flags, " ") +} + +// Tags returns a combined tags set from flags. +func Tags(tags ...string) string { + return strings.Join(tags, " ") +} + +// BuildTarget builds a GOOS:GOARCH pair. +func BuildTarget(goos, goarch string) string { + return fmt.Sprintf("%s:%s", goos, goarch) +} + +// ParseTarget parses GOOS:GOARCH pair. +func ParseTarget(t string) (goos, goarch string, err error) { + parsed := strings.Split(t, ":") + if len(parsed) != 2 { + return "", "", errors.New("invalid Go target, expected in GOOS:GOARCH format") + } + + return parsed[0], parsed[1], nil +} + +// PackageLiteral returns the string representation of package part of go get [package]. +func PackageLiteral(path, version string) string { + return fmt.Sprintf("%s@%s", path, version) +} + +// binaryPath determines the path where binary will be located at. +func binaryPath(output, binary string) (string, error) { + if output != "" { + outputAbs, err := filepath.Abs(output) + if err != nil { + return "", err + } + return filepath.Join(outputAbs, binary), nil + } + return filepath.Join(goenv.Bin(), binary), nil +} diff --git a/pkg/goenv/goenv.go b/pkg/goenv/goenv.go new file mode 100644 index 000000000..17bc30428 --- /dev/null +++ b/pkg/goenv/goenv.go @@ -0,0 +1,57 @@ +// Package goenv defines env variables known by Go and some utilities around it. +package goenv + +import ( + "fmt" + "go/build" + "os" + "path/filepath" +) + +const ( + // GOBIN is the env var for GOBIN. + GOBIN = "GOBIN" + + // GOPATH is the env var for GOPATH. + GOPATH = "GOPATH" + + // GOMODCACHE is the env var for GOMODCACHE. + GOMODCACHE = "GOMODCACHE" +) + +const ( + binDir = "bin" + modDir = "pkg/mod" +) + +// Bin returns the path of where Go binaries are installed. +func Bin() string { + if binPath := os.Getenv(GOBIN); binPath != "" { + return binPath + } + if goPath := os.Getenv(GOPATH); goPath != "" { + return filepath.Join(goPath, binDir) + } + return filepath.Join(build.Default.GOPATH, binDir) +} + +// Path returns $PATH with correct go bin configuration set. +func Path() string { + return os.ExpandEnv(fmt.Sprintf("$PATH:%s", Bin())) +} + +// ConfigurePath configures the env with correct $PATH that has go bin setup. +func ConfigurePath() error { + return os.Setenv("PATH", Path()) +} + +// GoModCache returns the path to Go's module cache. +func GoModCache() string { + if path := os.Getenv(GOMODCACHE); path != "" { + return path + } + if path := os.Getenv(GOPATH); path != "" { + return filepath.Join(path, modDir) + } + return filepath.Join(build.Default.GOPATH, modDir) +} diff --git a/pkg/gomodule/gomodule.go b/pkg/gomodule/gomodule.go new file mode 100644 index 000000000..456559ada --- /dev/null +++ b/pkg/gomodule/gomodule.go @@ -0,0 +1,136 @@ +package gomodule + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + + "golang.org/x/mod/modfile" + "golang.org/x/mod/module" + + "github.com/sonr-io/core/pkg/cache" + "github.com/sonr-io/core/pkg/cmdrunner" + "github.com/sonr-io/core/pkg/cmdrunner/step" +) + +const pathCacheNamespace = "gomodule.path" + +// ErrGoModNotFound returned when go.mod file cannot be found for an app. +var ErrGoModNotFound = errors.New("go.mod not found") + +// ParseAt finds and parses go.mod at app's path. +func ParseAt(path string) (*modfile.File, error) { + gomod, err := os.ReadFile(filepath.Join(path, "go.mod")) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, ErrGoModNotFound + } + return nil, err + } + return modfile.Parse("", gomod, nil) +} + +// FilterVersions filters dependencies under require section by their paths. +func FilterVersions(dependencies []module.Version, paths ...string) []module.Version { + var filtered []module.Version + + for _, dep := range dependencies { + for _, path := range paths { + if dep.Path == path { + filtered = append(filtered, dep) + break + } + } + } + + return filtered +} + +func ResolveDependencies(f *modfile.File, includeIndirect bool) ([]module.Version, error) { + var versions []module.Version + + isReplacementAdded := func(rv module.Version) bool { + for _, rep := range f.Replace { + if rv.Path == rep.Old.Path { + versions = append(versions, rep.New) + + return true + } + } + + return false + } + + for _, req := range f.Require { + if req.Indirect && !includeIndirect { + continue + } + if !isReplacementAdded(req.Mod) { + versions = append(versions, req.Mod) + } + } + + return versions, nil +} + +// LocatePath locates pkg's absolute path managed by 'go mod' on the local filesystem. +func LocatePath(ctx context.Context, cacheStorage cache.Storage, src string, pkg module.Version) (path string, err error) { + // can be a local package. + if pkg.Version == "" { // indicates that this is a local package. + if filepath.IsAbs(pkg.Path) { + return pkg.Path, nil + } + return filepath.Join(src, pkg.Path), nil + } + + pathCache := cache.New[string](cacheStorage, pathCacheNamespace) + cacheKey := cache.Key(pkg.Path, pkg.Version) + path, err = pathCache.Get(cacheKey) + if err != nil && !errors.Is(err, cache.ErrorNotFound) { + return "", err + } + if !errors.Is(err, cache.ErrorNotFound) { + return path, nil + } + + // otherwise, it is hosted. + out := &bytes.Buffer{} + + if err := cmdrunner. + New(). + Run(ctx, step.New( + step.Exec("go", "mod", "download", "-json"), + step.Workdir(src), + step.Stdout(out), + )); err != nil { + return "", err + } + + d := json.NewDecoder(out) + + for { + var mod struct { + Path, Version, Dir string + } + if err := d.Decode(&mod); err != nil { + if errors.Is(err, io.EOF) { + break + } + return "", err + } + if mod.Path == pkg.Path && mod.Version == pkg.Version { + if err := pathCache.Put(cacheKey, mod.Dir); err != nil { + return "", err + } + return mod.Dir, nil + } + } + + return "", fmt.Errorf("module %q not found", pkg.Path) +} diff --git a/pkg/randstr/randstr.go b/pkg/randstr/randstr.go new file mode 100644 index 000000000..df2e27f23 --- /dev/null +++ b/pkg/randstr/randstr.go @@ -0,0 +1,16 @@ +package randstr + +import ( + "math/rand" +) + +var letterRunes = []rune("abcdefghijklmnopqrstuvwxyz") + +// Runes generates a random string with n length from runes. +func Runes(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + return string(b) +} diff --git a/pkg/xfilepath/xfilepath.go b/pkg/xfilepath/xfilepath.go new file mode 100644 index 000000000..19dfe14ff --- /dev/null +++ b/pkg/xfilepath/xfilepath.go @@ -0,0 +1,88 @@ +// Package xfilepath defines functions to define path retrievers that support error handling +package xfilepath + +import ( + "os" + "path/filepath" +) + +// PathRetriever is a function that retrieves the contained path or an error. +type PathRetriever func() (path string, err error) + +// PathsRetriever is a function that retrieves the contained list of paths or an error. +type PathsRetriever func() (path []string, err error) + +// MustInvoke invokes the PathsRetriever func and panics if it returns an error. +func MustInvoke(p PathRetriever) string { + path, err := p() + if err != nil { + panic(err) + } + return path +} + +// Path returns a path retriever from the provided path. +func Path(path string) PathRetriever { + return func() (string, error) { return path, nil } +} + +// PathWithError returns a path retriever from the provided path and error. +func PathWithError(path string, err error) PathRetriever { + return func() (string, error) { return path, err } +} + +// Join returns a path retriever from the join of the provided path retrievers. +// The returned path retriever eventually returns the error from the first provided path retrievers +// that returns a non-nil error. +func Join(paths ...PathRetriever) PathRetriever { + return func() (string, error) { + var components []string + var err error + for _, path := range paths { + var component string + component, err = path() + if err != nil { + break + } + components = append(components, component) + } + path := filepath.Join(components...) + return path, err + } +} + +// JoinFromHome returns a path retriever from the join of the user home and the provided path retrievers. +// The returned path retriever eventually returns the error from the first provided path retrievers that returns a non-nil error. +func JoinFromHome(paths ...PathRetriever) PathRetriever { + return Join(append([]PathRetriever{os.UserHomeDir}, paths...)...) +} + +// List returns a paths retriever from a list of path retrievers. +// The returned paths retriever eventually returns the error from the first provided path retrievers that returns a non-nil error. +func List(paths ...PathRetriever) PathsRetriever { + return func() ([]string, error) { + var list []string + var err error + for _, path := range paths { + var resolved string + resolved, err = path() + if err != nil { + break + } + list = append(list, resolved) + } + + return list, err + } +} + +// Mkdir ensure path exists before returning it. +func Mkdir(path PathRetriever) PathRetriever { + return func() (string, error) { + p, err := path() + if err != nil { + return "", err + } + return p, os.MkdirAll(p, 0o755) + } +} diff --git a/pkg/xfilepath/xfilepath_test.go b/pkg/xfilepath/xfilepath_test.go new file mode 100644 index 000000000..b4395dc3d --- /dev/null +++ b/pkg/xfilepath/xfilepath_test.go @@ -0,0 +1,101 @@ +package xfilepath_test + +import ( + "errors" + "os" + "path" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sonr-io/core/pkg/xfilepath" +) + +func TestJoin(t *testing.T) { + retriever := xfilepath.Join( + xfilepath.Path("foo"), + xfilepath.PathWithError("bar", nil), + xfilepath.Path("foobar/barfoo"), + ) + p, err := retriever() + require.NoError(t, err) + require.Equal(t, filepath.Join( + "foo", + "bar", + "foobar", + "barfoo", + ), p) + + retriever = xfilepath.Join( + xfilepath.Path("foo"), + xfilepath.PathWithError("bar", errors.New("foo")), + xfilepath.Path("foobar/barfoo"), + ) + _, err = retriever() + require.Error(t, err) +} + +func TestJoinFromHome(t *testing.T) { + home, err := os.UserHomeDir() + require.NoError(t, err) + + retriever := xfilepath.JoinFromHome( + xfilepath.Path("foo"), + xfilepath.PathWithError("bar", nil), + xfilepath.Path("foobar/barfoo"), + ) + p, err := retriever() + require.NoError(t, err) + require.Equal(t, filepath.Join( + home, + "foo", + "bar", + "foobar", + "barfoo", + ), p) + + retriever = xfilepath.JoinFromHome( + xfilepath.Path("foo"), + xfilepath.PathWithError("bar", errors.New("foo")), + xfilepath.Path("foobar/barfoo"), + ) + _, err = retriever() + require.Error(t, err) +} + +func TestList(t *testing.T) { + retriever := xfilepath.List() + list, err := retriever() + require.NoError(t, err) + require.Equal(t, []string(nil), list) + + retriever1 := xfilepath.Join( + xfilepath.Path("foo/bar"), + ) + retriever2 := xfilepath.Join( + xfilepath.Path("bar/foo"), + ) + retriever = xfilepath.List(retriever1, retriever2) + list, err = retriever() + require.NoError(t, err) + require.Equal(t, []string{ + filepath.Join("foo", "bar"), + filepath.Join("bar", "foo"), + }, list) + + retrieverError := xfilepath.PathWithError("foo", errors.New("foo")) + retriever = xfilepath.List(retriever1, retrieverError, retriever2) + _, err = retriever() + require.Error(t, err) +} + +func TestMkdir(t *testing.T) { + newdir := path.Join(t.TempDir(), "hey") + + dir, err := xfilepath.Mkdir(xfilepath.Path(newdir))() + + require.NoError(t, err) + require.Equal(t, newdir, dir) + require.DirExists(t, dir) +} diff --git a/pkg/xgit/xgit.go b/pkg/xgit/xgit.go new file mode 100644 index 000000000..919889c98 --- /dev/null +++ b/pkg/xgit/xgit.go @@ -0,0 +1,156 @@ +package xgit + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" +) + +var ( + commitMsg = "Initialized with Ignite CLI" + defaultOpenOpts = git.PlainOpenOptions{DetectDotGit: true} + devXAuthor = &object.Signature{ + Name: "Developer Experience team at Ignite", + Email: "hello@ignite.com", + When: time.Now(), + } +) + +// InitAndCommit creates a git repo in path if path isn't already inside a git +// repository, then commits path content. +func InitAndCommit(path string) error { + repo, err := git.PlainOpenWithOptions(path, &defaultOpenOpts) + if err != nil { + if !errors.Is(err, git.ErrRepositoryNotExists) { + return fmt.Errorf("open git repo %s: %w", path, err) + } + // not a git repo, creates a new one + repo, err = git.PlainInit(path, false) + if err != nil { + return fmt.Errorf("init git repo %s: %w", path, err) + } + } + wt, err := repo.Worktree() + if err != nil { + return fmt.Errorf("worktree %s: %w", path, err) + } + // wt.Add(path) takes only relative path, we need to turn path relative to + // repo path. + repoPath := wt.Filesystem.Root() + path, err = filepath.Rel(repoPath, path) + if err != nil { + return fmt.Errorf("find relative path %s %s: %w", repoPath, path, err) + } + if _, err := wt.Add(path); err != nil { + return fmt.Errorf("git add %s: %w", path, err) + } + _, err = wt.Commit(commitMsg, &git.CommitOptions{ + All: true, + Author: devXAuthor, + }) + if err != nil { + return fmt.Errorf("git commit %s: %w", path, err) + } + return nil +} + +// AreChangesCommitted returns true if dir is a clean git repository with no +// pending changes. It returns also true if dir is NOT a git repository. +func AreChangesCommitted(dir string) (bool, error) { + dir, err := filepath.Abs(dir) + if err != nil { + return false, err + } + + repository, err := git.PlainOpen(dir) + if err != nil { + if errors.Is(err, git.ErrRepositoryNotExists) { + return true, nil + } + return false, err + } + + w, err := repository.Worktree() + if err != nil { + return false, err + } + + ws, err := w.Status() + if err != nil { + return false, err + } + return ws.IsClean(), nil +} + +// Clone clones a git repository represented by urlRef, into dir. +// urlRef is the URL of the repository, with an optional ref, suffixed to the +// URL with a `@`. Ref can be a tag, a branch or a hash. +// Valid examples of urlRef: github.com/org/repo, github.com/org/repo@v1, +// github.com/org/repo@develop, github.com/org/repo@ab88cdf. +func Clone(ctx context.Context, urlRef, dir string) error { + // Ensure dir is empty if it exists (if it doesn't exist, the call to + // git.PlainCloneContext below will create it). + files, _ := os.ReadDir(dir) + if len(files) > 0 { + return fmt.Errorf("clone: target directory %q is not empty", dir) + } + // Split urlRef + var ( + parts = strings.Split(urlRef, "@") + url = parts[0] + ref string + ) + if len(parts) > 1 { + ref = parts[1] + } + // First clone the repo + repo, err := git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{ + URL: url, + }) + if err != nil { + return err + } + if ref == "" { + // if ref is not provided, job is done + return nil + } + // Reference provided, try to resolve + wt, err := repo.Worktree() + if err != nil { + return err + } + var h *plumbing.Hash + for _, ref := range []string{ref, "origin/" + ref} { + h, err = repo.ResolveRevision(plumbing.Revision(ref)) + if err == nil { + break + } + } + if err != nil { + // Ref not found, clean up dir and return error + os.RemoveAll(dir) + return err + } + return wt.Checkout(&git.CheckoutOptions{ + Hash: *h, + }) +} + +// IsRepository checks if a path contains a Git repository. +func IsRepository(path string) (bool, error) { + if _, err := git.PlainOpenWithOptions(path, &defaultOpenOpts); err != nil { + if errors.Is(err, git.ErrRepositoryNotExists) { + return false, nil + } + return false, err + } + return true, nil +} diff --git a/pkg/xgit/xgit_test.go b/pkg/xgit/xgit_test.go new file mode 100644 index 000000000..62b1dac54 --- /dev/null +++ b/pkg/xgit/xgit_test.go @@ -0,0 +1,434 @@ +package xgit_test + +import ( + "context" + "fmt" + "os" + "path" + "testing" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sonr-io/core/pkg/randstr" + "github.com/sonr-io/core/pkg/xgit" +) + +func TestInitAndCommit(t *testing.T) { + tests := []struct { + name string + dirFunc func(*testing.T) string + expectDotGitFolder bool + expectedNumCommits int + expectedFilesInCommit []string + }{ + { + name: "dir is not inside an existing repo", + dirFunc: func(t *testing.T) string { + dir := t.TempDir() + err := os.WriteFile(path.Join(dir, "foo"), []byte("hello"), 0o755) + require.NoError(t, err) + return dir + }, + expectDotGitFolder: true, + expectedNumCommits: 1, + expectedFilesInCommit: []string{"foo"}, + }, + { + name: "dir is inside an existing repo", + // In this repo, there's no existing commit but a standalone uncommitted + // foo file that shouldn't be included in the xgit.InitAndCommit's commit. + dirFunc: func(t *testing.T) string { + dir := t.TempDir() + _, err := git.PlainInit(dir, false) + require.NoError(t, err) + err = os.WriteFile(path.Join(dir, "foo"), []byte("hello"), 0o755) + require.NoError(t, err) + dirInsideRepo := path.Join(dir, "bar") + err = os.Mkdir(dirInsideRepo, 0o0755) + require.NoError(t, err) + err = os.WriteFile(path.Join(dirInsideRepo, "baz"), []byte("hello"), 0o755) + require.NoError(t, err) + return dirInsideRepo + }, + expectDotGitFolder: false, + expectedNumCommits: 1, + expectedFilesInCommit: []string{"bar/baz"}, + }, + { + name: "dir is an existing repo", + dirFunc: func(t *testing.T) string { + // In this repo, there's one existing commit, and an uncommitted baz file + // that must be included in the xgit.InitAndCommit's commit. + dir := t.TempDir() + _, err := git.PlainInit(dir, false) + require.NoError(t, err) + err = os.WriteFile(path.Join(dir, "foo"), []byte("hello"), 0o755) + require.NoError(t, err) + repo, err := git.PlainOpenWithOptions(dir, &git.PlainOpenOptions{}) + require.NoError(t, err) + wt, err := repo.Worktree() + require.NoError(t, err) + _, err = wt.Add(".") + require.NoError(t, err) + _, err = wt.Commit("First commit", &git.CommitOptions{ + Author: &object.Signature{}, + }) + require.NoError(t, err) + err = os.WriteFile(path.Join(dir, "bar"), []byte("hello"), 0o755) + require.NoError(t, err) + return dir + }, + expectDotGitFolder: true, + expectedNumCommits: 2, + expectedFilesInCommit: []string{"bar"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.dirFunc(t) + + err := xgit.InitAndCommit(dir) + + require.NoError(t, err) + _, err = os.Stat(path.Join(dir, ".git")) + require.Equal(t, tt.expectDotGitFolder, !os.IsNotExist(err)) + // Assert repository commits. For that we need to open the repo and + // iterate over existing commits. + repo, err := git.PlainOpenWithOptions(dir, &git.PlainOpenOptions{ + DetectDotGit: true, + }) + require.NoError(t, err) + logs, err := repo.Log(&git.LogOptions{}) + require.NoError(t, err) + var ( + numCommits int + lastCommit *object.Commit + ) + err = logs.ForEach(func(c *object.Commit) error { + if numCommits == 0 { + lastCommit = c + } + numCommits++ + return nil + }) + require.NoError(t, err) + require.Equal(t, tt.expectedNumCommits, numCommits) + if assert.NotNil(t, lastCommit) { + require.Equal(t, "Initialized with Ignite CLI", lastCommit.Message) + require.WithinDuration(t, time.Now(), lastCommit.Committer.When, 10*time.Second) + require.Equal(t, "Developer Experience team at Ignite", lastCommit.Author.Name) + require.Equal(t, "hello@ignite.com", lastCommit.Author.Email) + stats, err := lastCommit.Stats() + require.NoError(t, err) + var files []string + for _, s := range stats { + files = append(files, s.Name) + } + require.Equal(t, tt.expectedFilesInCommit, files) + } + }) + } +} + +func TestAreChangesCommitted(t *testing.T) { + tests := []struct { + name string + dirFunc func(*testing.T) string + expectedResult bool + }{ + { + name: "dir is not a git repo", + dirFunc: func(t *testing.T) string { + return t.TempDir() + }, + expectedResult: true, + }, + { + name: "dir is a empty git repo", + dirFunc: func(t *testing.T) string { + dir := t.TempDir() + _, err := git.PlainInit(dir, false) + require.NoError(t, err) + return dir + }, + expectedResult: true, + }, + { + name: "dir is a dirty empty git repo", + dirFunc: func(t *testing.T) string { + dir := t.TempDir() + _, err := git.PlainInit(dir, false) + require.NoError(t, err) + err = os.WriteFile(path.Join(dir, "foo"), []byte("hello"), 0o755) + require.NoError(t, err) + return dir + }, + expectedResult: false, + }, + { + name: "dir is a cleaned git repo", + dirFunc: func(t *testing.T) string { + dir := t.TempDir() + _, err := git.PlainInit(dir, false) + require.NoError(t, err) + err = os.WriteFile(path.Join(dir, "foo"), []byte("hello"), 0o755) + require.NoError(t, err) + repo, err := git.PlainOpenWithOptions(dir, &git.PlainOpenOptions{}) + require.NoError(t, err) + wt, err := repo.Worktree() + require.NoError(t, err) + _, err = wt.Add(".") + require.NoError(t, err) + _, err = wt.Commit("First commit", &git.CommitOptions{ + Author: &object.Signature{}, + }) + require.NoError(t, err) + return dir + }, + expectedResult: true, + }, + { + name: "dir is a dirty git repo", + dirFunc: func(t *testing.T) string { + dir := t.TempDir() + _, err := git.PlainInit(dir, false) + require.NoError(t, err) + err = os.WriteFile(path.Join(dir, "foo"), []byte("hello"), 0o755) + require.NoError(t, err) + repo, err := git.PlainOpenWithOptions(dir, &git.PlainOpenOptions{}) + require.NoError(t, err) + wt, err := repo.Worktree() + require.NoError(t, err) + _, err = wt.Add(".") + require.NoError(t, err) + _, err = wt.Commit("First commit", &git.CommitOptions{ + Author: &object.Signature{}, + }) + require.NoError(t, err) + err = os.WriteFile(path.Join(dir, "bar"), []byte("hello"), 0o755) + require.NoError(t, err) + return dir + }, + expectedResult: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.dirFunc(t) + + res, err := xgit.AreChangesCommitted(dir) + + require.NoError(t, err) + assert.Equal(t, tt.expectedResult, res) + }) + } +} + +func TestClone(t *testing.T) { + // Create a folder with content + notEmptyDir := t.TempDir() + err := os.WriteFile(path.Join(notEmptyDir, ".foo"), []byte("hello"), 0o755) + require.NoError(t, err) + // Create a local git repo for all the test cases + repoDir := t.TempDir() + repo, err := git.PlainInit(repoDir, false) + require.NoError(t, err) + err = os.WriteFile(path.Join(repoDir, "foo"), []byte("hello"), 0o755) + require.NoError(t, err) + // Add a first commit + w, err := repo.Worktree() + require.NoError(t, err) + _, err = w.Add(".") + require.NoError(t, err) + commit1, err := w.Commit("commit1", &git.CommitOptions{ + Author: &object.Signature{ + Name: "bob", + Email: "bob@example.com", + When: time.Now(), + }, + }) + // Add a branch on commit1 + require.NoError(t, err) + err = w.Checkout(&git.CheckoutOptions{ + Branch: plumbing.NewBranchReferenceName("my-branch"), + Create: true, + }) + require.NoError(t, err) + // Back to master + err = w.Checkout(&git.CheckoutOptions{Branch: plumbing.NewBranchReferenceName("master")}) + require.NoError(t, err) + // Add a tag on commit1 + _, err = repo.CreateTag("v1", commit1, &git.CreateTagOptions{ + Tagger: &object.Signature{Name: "me"}, + Message: "v1", + }) + require.NoError(t, err) + // Add a second commit + err = os.WriteFile(path.Join(repoDir, "bar"), []byte("hello"), 0o755) + require.NoError(t, err) + _, err = w.Add(".") + require.NoError(t, err) + commit2, err := w.Commit("commit2", &git.CommitOptions{ + Author: &object.Signature{ + Name: "bob", + Email: "bob@example.com", + When: time.Now(), + }, + }) + require.NoError(t, err) + + tests := []struct { + name string + dir string + urlRef string + expectedError string + expectedRef plumbing.Hash + }{ + { + name: "fail: repo doesn't exist", + dir: t.TempDir(), + urlRef: "/tmp/not/exists", + expectedError: "repository not found", + }, + { + name: "fail: target dir isn't empty", + dir: notEmptyDir, + urlRef: repoDir, + expectedError: fmt.Sprintf(`clone: target directory "%s" is not empty`, notEmptyDir), + }, + { + name: "ok: target dir doesn't exists", + dir: "/tmp/not/exists/" + randstr.Runes(6), + urlRef: repoDir, + expectedRef: commit2, + }, + { + name: "ok: no ref", + dir: t.TempDir(), + urlRef: repoDir, + expectedRef: commit2, + }, + { + name: "ok: empty ref", + dir: t.TempDir(), + urlRef: repoDir + "@", + expectedRef: commit2, + }, + { + name: "ok: with tag ref", + dir: t.TempDir(), + urlRef: repoDir + "@v1", + expectedRef: commit1, + }, + { + name: "ok: with branch ref", + dir: t.TempDir(), + urlRef: repoDir + "@my-branch", + expectedRef: commit1, + }, + { + name: "ok: with commit1 hash ref", + dir: t.TempDir(), + urlRef: repoDir + "@" + commit1.String(), + expectedRef: commit1, + }, + { + name: "ok: with commit2 hash ref", + dir: t.TempDir(), + urlRef: repoDir + "@" + commit2.String(), + expectedRef: commit2, + }, + { + name: "fail: ref not found", + dir: t.TempDir(), + urlRef: repoDir + "@what", + expectedError: "reference not found", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ( + files, _ = os.ReadDir(tt.dir) + dirWasEmpty = len(files) == 0 + ) + + err := xgit.Clone(context.Background(), tt.urlRef, tt.dir) + + if tt.expectedError != "" { + require.EqualError(t, err, tt.expectedError) + if dirWasEmpty { + // If it was empty, ensure target dir is still clean + files, _ := os.ReadDir(tt.dir) + require.Empty(t, files, "target dir should be empty in case of error") + } + return + } + require.NoError(t, err) + _, err = os.Stat(tt.dir) + require.False(t, os.IsNotExist(err), "dir %s should exist", tt.dir) + repo, err := git.PlainOpen(tt.dir) + require.NoError(t, err) + h, err := repo.Head() + require.NoError(t, err) + require.Equal(t, tt.expectedRef, h.Hash()) + }) + } +} + +func TestIsRepository(t *testing.T) { + tests := []struct { + name string + dirFunc func(*testing.T) string + shouldFail bool + expected bool + }{ + { + name: "path is a repository", + dirFunc: func(t *testing.T) string { + dir := t.TempDir() + _, err := git.PlainInit(dir, false) + require.NoError(t, err) + return dir + }, + expected: true, + }, + { + name: "path is not a repository", + dirFunc: func(t *testing.T) string { + return t.TempDir() + }, + expected: false, + }, + { + name: "repository error", + dirFunc: func(t *testing.T) string { + dir := t.TempDir() + err := os.Chmod(dir, 0) + require.NoError(t, err) + return dir + }, + shouldFail: true, + expected: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Act + exists, err := xgit.IsRepository(tt.dirFunc(t)) + + // Assert + require.Equal(t, tt.expected, exists) + + if tt.shouldFail { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/xio/xio.go b/pkg/xio/xio.go new file mode 100755 index 000000000..4d0b7e067 --- /dev/null +++ b/pkg/xio/xio.go @@ -0,0 +1,16 @@ +package xio + +import "io" + +type nopWriteCloser struct { + io.Writer +} + +func (w *nopWriteCloser) Close() error { + return nil +} + +// NopWriteCloser returns a WriteCloser. +func NopWriteCloser(w io.Writer) io.WriteCloser { + return &nopWriteCloser{w} +} diff --git a/pkg/xnet/xnet.go b/pkg/xnet/xnet.go new file mode 100755 index 000000000..62c784846 --- /dev/null +++ b/pkg/xnet/xnet.go @@ -0,0 +1,55 @@ +package xnet + +import ( + "fmt" + "net" + "strconv" +) + +// LocalhostIPv4Address returns a localhost IPv4 address with a port +// that represents the localhost IP address listening on that port. +func LocalhostIPv4Address(port int) string { + return fmt.Sprintf("localhost:%d", port) +} + +// AnyIPv4Address returns an IPv4 meta address "0.0.0.0" with a port +// that represents any IP address listening on that port. +func AnyIPv4Address(port int) string { + return fmt.Sprintf("0.0.0.0:%d", port) +} + +// IncreasePort increases a port number by 1. +// This can be useful to generate port ranges or consecutive +// port numbers for the same address. +func IncreasePort(addr string) (string, error) { + return IncreasePortBy(addr, 1) +} + +// IncreasePortBy increases a port number by a factor of "inc". +// This can be useful to generate port ranges or consecutive +// port numbers for the same address. +func IncreasePortBy(addr string, inc uint64) (string, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return "", err + } + + v, err := strconv.ParseUint(port, 10, 0) + if err != nil { + return "", err + } + + port = strconv.FormatUint(v+inc, 10) + + return net.JoinHostPort(host, port), nil +} + +// MustIncreasePortBy calls IncreasePortBy and panics on error. +func MustIncreasePortBy(addr string, inc uint64) string { + s, err := IncreasePortBy(addr, inc) + if err != nil { + panic(err) + } + + return s +} diff --git a/pkg/xnet/xnet_test.go b/pkg/xnet/xnet_test.go new file mode 100755 index 000000000..99810a1cc --- /dev/null +++ b/pkg/xnet/xnet_test.go @@ -0,0 +1,55 @@ +package xnet_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sonr-io/core/pkg/xnet" +) + +func TestLocalhostIPv4Address(t *testing.T) { + require.Equal(t, "localhost:42", xnet.LocalhostIPv4Address(42)) +} + +func TestAnyIPv4Address(t *testing.T) { + require.Equal(t, "0.0.0.0:42", xnet.AnyIPv4Address(42)) +} + +func TestIncreasePort(t *testing.T) { + addr, err := xnet.IncreasePort("localhost:41") + + require.NoError(t, err) + require.Equal(t, "localhost:42", addr) +} + +func TestIncreasePortWithInvalidAddress(t *testing.T) { + _, err := xnet.IncreasePort("localhost:x:41") + + require.Error(t, err) +} + +func TestIncreasePortWithInvalidPort(t *testing.T) { + _, err := xnet.IncreasePort("localhost:x") + + require.Error(t, err) +} + +func TestIncreasePortBy(t *testing.T) { + addr, err := xnet.IncreasePortBy("localhost:32", 10) + + require.NoError(t, err) + require.Equal(t, "localhost:42", addr) +} + +func TestIncreasePortByWithInvalidAddress(t *testing.T) { + _, err := xnet.IncreasePortBy("localhost:x:32", 10) + + require.Error(t, err) +} + +func TestIncreasePortByWithInvalidPort(t *testing.T) { + _, err := xnet.IncreasePortBy("localhost:x", 10) + + require.Error(t, err) +} diff --git a/pkg/xos/cp.go b/pkg/xos/cp.go new file mode 100755 index 000000000..9004208a5 --- /dev/null +++ b/pkg/xos/cp.go @@ -0,0 +1,63 @@ +package xos + +import ( + "io" + "os" + "path/filepath" +) + +// CopyFolder copy the source folder to the destination folder. +func CopyFolder(srcPath, dstPath string) error { + return filepath.Walk(srcPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip the root folder + if path == srcPath { + return nil + } + + // Get the relative path within the source folder + relativePath, err := filepath.Rel(srcPath, path) + if err != nil { + return err + } + + // Create the corresponding destination path + destPath := filepath.Join(dstPath, relativePath) + + if info.IsDir() { + // Create the directory in the destination + err = os.MkdirAll(destPath, 0o755) + if err != nil { + return err + } + } else { + // Copy the file content + err = CopyFile(path, destPath) + if err != nil { + return err + } + } + return nil + }) +} + +// CopyFile copy the source file to the destination file. +func CopyFile(srcPath, dstPath string) error { + srcFile, err := os.OpenFile(srcPath, os.O_RDONLY, 0o666) + if err != nil { + return err + } + defer srcFile.Close() + + destFile, err := os.Create(dstPath) + if err != nil { + return err + } + defer destFile.Close() + + _, err = io.Copy(destFile, srcFile) + return err +} diff --git a/pkg/xos/cp_test.go b/pkg/xos/cp_test.go new file mode 100755 index 000000000..9a830ace9 --- /dev/null +++ b/pkg/xos/cp_test.go @@ -0,0 +1,168 @@ +package xos_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sonr-io/core/pkg/xos" +) + +func TestCopyFolder(t *testing.T) { + tempDir, err := os.MkdirTemp("", "TestCopyFile") + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, os.RemoveAll(tempDir)) + }) + + // Create temporary source and destination directories + srcDir := filepath.Join(tempDir, "source") + err = os.MkdirAll(srcDir, 0o755) + require.NoError(t, err) + + dstDir := filepath.Join(tempDir, "destination") + err = os.MkdirAll(dstDir, 0o755) + require.NoError(t, err) + + emptyDir := filepath.Join(tempDir, "empty") + err = os.MkdirAll(emptyDir, 0o755) + require.NoError(t, err) + + // Create a temporary source file + srcFile1 := filepath.Join(srcDir, "file_1.txt") + err = os.WriteFile(srcFile1, []byte("File content 1"), 0o644) + require.NoError(t, err) + + srcFile2 := filepath.Join(srcDir, "file_2.txt") + err = os.WriteFile(srcFile2, []byte("File content 2"), 0o644) + require.NoError(t, err) + + tests := []struct { + name string + srcPath string + dstPath string + expectedErr error + expectedFileCount int + }{ + { + name: "valid paths", + srcPath: srcDir, + dstPath: dstDir, + expectedFileCount: 2, + }, + { + name: "non existent destination", + srcPath: srcDir, + dstPath: filepath.Join(dstDir, "non-existent-destination"), + expectedErr: os.ErrNotExist, + }, + { + name: "non existent source", + srcPath: filepath.Join(dstDir, "non-existent-source"), + dstPath: dstDir, + expectedErr: os.ErrNotExist, + }, + { + name: "same source and destination", + srcPath: srcDir, + dstPath: srcDir, + expectedFileCount: 2, + }, + { + name: "empty source", + srcPath: emptyDir, + dstPath: filepath.Join(tempDir, "empty"), + expectedFileCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := xos.CopyFolder(tt.srcPath, tt.dstPath) + if tt.expectedErr != nil { + require.ErrorIs(t, err, tt.expectedErr) + return + } + require.NoError(t, err) + + // Check the number of files in the destination directory + files, err := os.ReadDir(tt.dstPath) + require.NoError(t, err) + require.Equal(t, tt.expectedFileCount, len(files)) + }) + } +} + +func TestCopyFile(t *testing.T) { + tempDir, err := os.MkdirTemp("", "TestCopyFile") + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, os.RemoveAll(tempDir)) + }) + + // Create temporary source and destination directories + srcDir := filepath.Join(tempDir, "source") + dstDir := filepath.Join(tempDir, "destination") + err = os.MkdirAll(srcDir, 0o755) + require.NoError(t, err) + err = os.MkdirAll(dstDir, 0o755) + require.NoError(t, err) + + // Create a temporary source file + srcFile := filepath.Join(srcDir, "file.txt") + err = os.WriteFile(srcFile, []byte("File content"), 0o644) + require.NoError(t, err) + + tests := []struct { + name string + srcPath string + dstPath string + expectedErr error + expectedBytes int64 // Provide the expected number of bytes copied + }{ + { + name: "valid path", + srcPath: srcFile, + dstPath: filepath.Join(dstDir, "test_1.txt"), + expectedBytes: 12, + }, + { + name: "non existent file", + srcPath: filepath.Join(srcDir, "non_existent_file.txt"), + dstPath: filepath.Join(dstDir, "test_2.txt"), + expectedErr: os.ErrNotExist, + }, + { + name: "non existent destination", + srcPath: srcFile, + dstPath: "/path/to/nonexistent/file.txt", + expectedErr: os.ErrNotExist, + }, + { + name: "same source and destination", + srcPath: srcFile, + dstPath: srcFile, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := xos.CopyFile(tt.srcPath, tt.dstPath) + if tt.expectedErr != nil { + require.ErrorIs(t, err, tt.expectedErr) + return + } + require.NoError(t, err) + + destFile, err := os.Open(tt.dstPath) + require.NoError(t, err) + + destFileInfo, err := destFile.Stat() + require.NoError(t, err) + require.NoError(t, destFile.Close()) + require.Equal(t, tt.expectedBytes, destFileInfo.Size()) + }) + } +} diff --git a/pkg/xos/files.go b/pkg/xos/files.go new file mode 100755 index 000000000..60eb6e4a0 --- /dev/null +++ b/pkg/xos/files.go @@ -0,0 +1,37 @@ +package xos + +import ( + "fmt" + "os" + "path/filepath" +) + +const ( + JSONFile = "json" + ProtoFile = "proto" +) + +func FindFiles(directory, extension string) ([]string, error) { + files := make([]string, 0) + return files, filepath.Walk(directory, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if !info.IsDir() { + if filepath.Ext(path) == fmt.Sprintf(".%s", extension) { + files = append(files, path) + } + } + return nil + }) +} + +// FileExists check if a file from a given path exists. +func FileExists(filename string) bool { + info, err := os.Stat(filename) + if os.IsNotExist(err) { + return false + } + return !info.IsDir() +} diff --git a/pkg/xos/files_test.go b/pkg/xos/files_test.go new file mode 100755 index 000000000..164ccd6bd --- /dev/null +++ b/pkg/xos/files_test.go @@ -0,0 +1,132 @@ +package xos_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sonr-io/core/pkg/xos" +) + +func TestFindFiles(t *testing.T) { + tests := []struct { + name string + files []string + extension string + want []string + err error + }{ + { + name: "test 3 json files", + files: []string{"file1.json", "file2.txt", "file3.json", "file4.json"}, + extension: "json", + want: []string{"file1.json", "file3.json", "file4.json"}, + err: nil, + }, + { + name: "test 1 txt files", + files: []string{"file1.json", "file2.txt", "file3.json", "file4.json"}, + extension: "txt", + want: []string{"file2.txt"}, + err: nil, + }, + { + name: "test 1 json files", + files: []string{"file1.json"}, + extension: "json", + want: []string{"file1.json"}, + err: nil, + }, + { + name: "test no files", + files: []string{"file1.json", "file2.json", "file3.json", "file4.json"}, + extension: "txt", + want: []string{}, + err: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dirName := strings.ReplaceAll(t.Name(), "/", "_") + tempDir, err := os.MkdirTemp("", dirName) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, os.RemoveAll(tempDir)) + }) + + for _, filename := range tt.files { + filePath := filepath.Join(tempDir, filename) + file, err := os.Create(filePath) + require.NoError(t, err) + require.NoError(t, file.Close()) + } + + gotFiles, err := xos.FindFiles(tempDir, tt.extension) + if tt.err != nil { + require.Error(t, err) + require.ErrorIs(t, err, tt.err) + return + } + require.NoError(t, err) + + want := make([]string, len(tt.want)) + for i, filename := range tt.want { + want[i] = filepath.Join(tempDir, filename) + } + require.EqualValues(t, want, gotFiles) + }) + } +} + +func TestFileExists(t *testing.T) { + tempDir, err := os.MkdirTemp("", "TestCopyFile") + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, os.RemoveAll(tempDir)) + }) + + srcDir := filepath.Join(tempDir, "source") + err = os.MkdirAll(srcDir, 0o755) + require.NoError(t, err) + + srcFile := filepath.Join(srcDir, "file.txt") + err = os.WriteFile(srcFile, []byte("File content"), 0o644) + require.NoError(t, err) + + tests := []struct { + name string + filename string + want bool + }{ + { + name: "existing file", + filename: srcFile, + want: true, + }, + { + name: "non existing file", + filename: "non_existing_file.txt", + want: false, + }, + { + name: "directory", + filename: srcDir, + want: false, + }, + { + name: "empty filename", + filename: "", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := xos.FileExists(tt.filename) + require.EqualValues(t, tt.want, got) + }) + } +} diff --git a/pkg/xos/mv.go b/pkg/xos/mv.go new file mode 100755 index 000000000..801af7bf3 --- /dev/null +++ b/pkg/xos/mv.go @@ -0,0 +1,33 @@ +package xos + +import ( + "fmt" + "io" + "os" +) + +// Rename copy oldPath to newPath and then delete oldPath. +// Unlike os.Rename, it doesn't fail when the oldPath and newPath are in +// different partitions (error: invalid cross-device link). +func Rename(oldPath, newPath string) error { + inputFile, err := os.Open(oldPath) + if err != nil { + return fmt.Errorf("rename %s %s: couldn't open oldpath: %w", oldPath, newPath, err) + } + defer inputFile.Close() + outputFile, err := os.Create(newPath) + if err != nil { + return fmt.Errorf("rename %s %s: couldn't open dest file: %w", oldPath, newPath, err) + } + defer outputFile.Close() + _, err = io.Copy(outputFile, inputFile) + if err != nil { + return fmt.Errorf("rename %s %s: writing to output file failed: %w", oldPath, newPath, err) + } + // The copy was successful, so now delete the original file + err = os.Remove(oldPath) + if err != nil { + return fmt.Errorf("rename %s %s: failed removing original file: %w", oldPath, newPath, err) + } + return nil +} diff --git a/pkg/xos/mv_test.go b/pkg/xos/mv_test.go new file mode 100755 index 000000000..1c5839ac4 --- /dev/null +++ b/pkg/xos/mv_test.go @@ -0,0 +1,32 @@ +package xos_test + +import ( + "fmt" + "os" + "path" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sonr-io/core/pkg/xos" +) + +func TestRename(t *testing.T) { + var ( + dir = t.TempDir() + oldpath = path.Join(dir, "old") + newpath = path.Join(dir, "new") + require = require.New(t) + ) + err := os.WriteFile(oldpath, []byte("foo"), os.ModePerm) + require.NoError(err) + + err = xos.Rename(oldpath, newpath) + + require.NoError(err) + bz, err := os.ReadFile(newpath) + require.NoError(err) + require.Equal([]byte("foo"), bz) + _, err = os.Open(oldpath) + require.EqualError(err, fmt.Sprintf("open %s: no such file or directory", oldpath)) +} diff --git a/pkg/xos/rm.go b/pkg/xos/rm.go new file mode 100755 index 000000000..993e3456b --- /dev/null +++ b/pkg/xos/rm.go @@ -0,0 +1,14 @@ +package xos + +import ( + "os" + "path/filepath" +) + +func RemoveAllUnderHome(path string) error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + return os.RemoveAll(filepath.Join(home, path)) +} diff --git a/pkg/xstrings/xstrings.go b/pkg/xstrings/xstrings.go new file mode 100755 index 000000000..ff038a95d --- /dev/null +++ b/pkg/xstrings/xstrings.go @@ -0,0 +1,72 @@ +package xstrings + +import ( + "strings" + "unicode" + + "golang.org/x/exp/slices" // TODO: replace with slices.Contains when it will be available in stdlib (1.21) + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +// AllOrSomeFilter filters elems out from the list as they present in filterList and +// returns the remaining ones. +// if filterList is empty, all elems from list returned. +func AllOrSomeFilter(list, filterList []string) []string { + if len(filterList) == 0 { + return list + } + + var elems []string + + for _, elem := range list { + if !slices.Contains(filterList, elem) { + elems = append(elems, elem) + } + } + + return elems +} + +// List returns a slice of strings captured after the value returned by do which is +// called n times. +func List(n int, do func(i int) string) []string { + var list []string + + for i := 0; i < n; i++ { + list = append(list, do(i)) + } + + return list +} + +// FormatUsername formats a username to make it usable as a variable. +func FormatUsername(s string) string { + return NoDash(NoNumberPrefix(s)) +} + +// NoDash removes dash from the string. +func NoDash(s string) string { + return strings.ReplaceAll(s, "-", "") +} + +// NoNumberPrefix adds an underscore at the beginning of the string if it stars with a number +// this is used for package of proto files template because the package name can't start with a number. +func NoNumberPrefix(s string) string { + // Check if it starts with a digit + if unicode.IsDigit(rune(s[0])) { + return "_" + s + } + return s +} + +// Title returns a copy of the string s with all Unicode letters that begin words +// mapped to their Unicode title case. +func Title(s string) string { + return cases.Title(language.English).String(s) +} + +// ToUpperFirst returns a copy of the string with the first unicode letter in upper case. +func ToUpperFirst(s string) string { + return strings.ToUpper(s[:1]) + s[1:] +} diff --git a/pkg/xstrings/xstrings_test.go b/pkg/xstrings/xstrings_test.go new file mode 100755 index 000000000..6557ecea4 --- /dev/null +++ b/pkg/xstrings/xstrings_test.go @@ -0,0 +1,20 @@ +package xstrings_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sonr-io/core/pkg/xstrings" +) + +func TestNoDash(t *testing.T) { + require.Equal(t, "foo", xstrings.NoDash("foo")) + require.Equal(t, "foo", xstrings.NoDash("-f-o-o---")) +} + +func TestNoNumberPrefix(t *testing.T) { + require.Equal(t, "foo", xstrings.NoNumberPrefix("foo")) + require.Equal(t, "_0foo", xstrings.NoNumberPrefix("0foo")) + require.Equal(t, "_999foo", xstrings.NoNumberPrefix("999foo")) +} diff --git a/pkg/xtime/clock.go b/pkg/xtime/clock.go new file mode 100755 index 000000000..ca9fa8792 --- /dev/null +++ b/pkg/xtime/clock.go @@ -0,0 +1,49 @@ +package xtime + +import "time" + +// Clock represents a clock that can retrieve current time. +type Clock interface { + Now() time.Time + Add(duration time.Duration) +} + +// ClockSystem is a clock that retrieves system time. +type ClockSystem struct{} + +// NewClockSystem returns a new ClockSystem. +func NewClockSystem() ClockSystem { + return ClockSystem{} +} + +// Now implements Clock. +func (ClockSystem) Now() time.Time { + return time.Now() +} + +// Add implements Clock. +func (ClockSystem) Add(_ time.Duration) { + panic("Add can't be called for ClockSystem") +} + +// ClockMock is a clock mocking time with an internal counter. +type ClockMock struct { + t time.Time +} + +// NewClockMock returns a new ClockMock. +func NewClockMock(originalTime time.Time) *ClockMock { + return &ClockMock{ + t: originalTime, + } +} + +// Now implements Clock. +func (c ClockMock) Now() time.Time { + return c.t +} + +// Add implements Clock. +func (c *ClockMock) Add(duration time.Duration) { + c.t = c.t.Add(duration) +} diff --git a/pkg/xtime/clock_test.go b/pkg/xtime/clock_test.go new file mode 100755 index 000000000..b9b1def6a --- /dev/null +++ b/pkg/xtime/clock_test.go @@ -0,0 +1,24 @@ +package xtime_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/sonr-io/core/pkg/xtime" +) + +func TestClockSystem(t *testing.T) { + c := xtime.NewClockSystem() + require.False(t, c.Now().IsZero()) + require.Panics(t, func() { c.Add(time.Second) }) +} + +func TestClockMock(t *testing.T) { + timeSample := time.Now() + c := xtime.NewClockMock(timeSample) + require.True(t, c.Now().Equal(timeSample)) + c.Add(time.Second) + require.True(t, c.Now().Equal(timeSample.Add(time.Second))) +} diff --git a/pkg/xtime/unix.go b/pkg/xtime/unix.go new file mode 100755 index 000000000..a10b4b2d7 --- /dev/null +++ b/pkg/xtime/unix.go @@ -0,0 +1,26 @@ +package xtime + +import ( + "time" +) + +// Seconds creates a time.Duration based on the seconds parameter. +func Seconds(seconds int64) time.Duration { + return time.Duration(seconds) * time.Second +} + +// NowAfter returns a unix date string from now plus the duration. +func NowAfter(unix time.Duration) string { + date := time.Now().Add(unix) + return FormatUnix(date) +} + +// FormatUnix formats the time.Time to unix date string. +func FormatUnix(date time.Time) string { + return date.Format(time.UnixDate) +} + +// FormatUnixInt formats the int timestamp to unix date string. +func FormatUnixInt(unix int64) string { + return FormatUnix(time.Unix(unix, 0)) +} diff --git a/pkg/xtime/unix_test.go b/pkg/xtime/unix_test.go new file mode 100755 index 000000000..c64d0242c --- /dev/null +++ b/pkg/xtime/unix_test.go @@ -0,0 +1,68 @@ +package xtime_test + +import ( + "fmt" + "testing" + "time" + + "github.com/sonr-io/core/pkg/xtime" + + "github.com/stretchr/testify/require" +) + +func TestSeconds(t *testing.T) { + tests := []int64{ + 9999999999, + 10000, + 100, + 0, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("test %d value", tt), func(t *testing.T) { + got := xtime.Seconds(tt) + require.Equal(t, time.Duration(tt)*time.Second, got) + }) + } +} + +func TestNowAfter(t *testing.T) { + tests := []int64{ + 9999999999, + 10000, + 100, + 0, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("test %d value", tt), func(t *testing.T) { + got := xtime.NowAfter(xtime.Seconds(tt)) + date := time.Now().Add(time.Duration(tt) * time.Second) + require.Equal(t, date.Format(time.UnixDate), got) + }) + } +} + +func TestFormatUnix(t *testing.T) { + tests := []struct { + date time.Time + want string + }{ + { + date: time.Time{}, + want: "Mon Jan 1 00:00:00 UTC 0001", + }, + { + date: time.Unix(10000000000, 100).In(time.UTC), + want: "Sat Nov 20 17:46:40 UTC 2286", + }, + { + date: time.Date(2020, 10, 11, 12, 30, 50, 0, time.FixedZone("Europe/Berlin", 3*60*60)), + want: "Sun Oct 11 12:30:50 Europe/Berlin 2020", + }, + } + for _, tt := range tests { + t.Run("test date "+tt.date.String(), func(t *testing.T) { + got := xtime.FormatUnix(tt.date) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/xurl/xurl.go b/pkg/xurl/xurl.go new file mode 100644 index 000000000..49d0f9685 --- /dev/null +++ b/pkg/xurl/xurl.go @@ -0,0 +1,138 @@ +package xurl + +import ( + "errors" + "fmt" + "net" + "net/url" + "strings" +) + +const ( + schemeTCP = "tcp" + schemeHTTP = "http" + schemeHTTPS = "https" + schemeWS = "ws" +) + +// TCP ensures that a URL contains a TCP scheme. +func TCP(s string) (string, error) { + u, err := parseURL(s) + if err != nil { + return "", err + } + + u.Scheme = schemeTCP + + return u.String(), nil +} + +// HTTP ensures that a URL contains an HTTP scheme. +func HTTP(s string) (string, error) { + u, err := parseURL(s) + if err != nil { + return "", err + } + + u.Scheme = schemeHTTP + + return u.String(), nil +} + +// HTTPS ensures that a URL contains an HTTPS scheme. +func HTTPS(s string) (string, error) { + u, err := parseURL(s) + if err != nil { + return "", err + } + + u.Scheme = schemeHTTPS + + return u.String(), nil +} + +// MightHTTPS ensures that a URL contains an HTTPS scheme when the current scheme is not HTTP. +// When the URL contains an HTTP scheme it is not modified. +func MightHTTPS(s string) (string, error) { + if strings.HasPrefix(strings.ToLower(s), "http://") { + return s, nil + } + + return HTTPS(s) +} + +// WS ensures that a URL contains a WS scheme. +func WS(s string) (string, error) { + u, err := parseURL(s) + if err != nil { + return "", err + } + + u.Scheme = schemeWS + + return u.String(), nil +} + +// HTTPEnsurePort ensures that url has a port number suits with the connection type. +func HTTPEnsurePort(s string) string { + u, err := url.Parse(s) + if err != nil || u.Port() != "" { + return s + } + + port := "80" + + if u.Scheme == schemeHTTPS { + port = "443" + } + + u.Host = fmt.Sprintf("%s:%s", u.Hostname(), port) + + return u.String() +} + +// Address ensures that address contains localhost as host if non specified. +func Address(address string) string { + if strings.HasPrefix(address, ":") { + return "localhost" + address + } + return address +} + +func IsHTTP(address string) bool { + return strings.HasPrefix(address, "http") +} + +func parseURL(s string) (*url.URL, error) { + if s == "" { + return nil, errors.New("url is empty") + } + + // Handle the case where the URI is an IP:PORT or HOST:PORT + // without scheme prefix because that case can't be URL parsed. + // When the URI has no scheme it is parsed as a path by "url.Parse" + // placing the colon within the path, which is invalid. + if host, isAddrPort := addressPort(s); isAddrPort { + return &url.URL{Host: host}, nil + } + + p, err := url.Parse(Address(s)) + return p, err +} + +func addressPort(s string) (string, bool) { + // Check that the value doesn't contain a URI path + if strings.Contains(s, "/") { + return "", false + } + + // Use the net split function to support IPv6 addresses + host, port, err := net.SplitHostPort(s) + if err != nil { + return "", false + } + if host == "" { + host = "0.0.0.0" + } + return net.JoinHostPort(host, port), true +} diff --git a/pkg/xurl/xurl_test.go b/pkg/xurl/xurl_test.go new file mode 100644 index 000000000..9032c45fc --- /dev/null +++ b/pkg/xurl/xurl_test.go @@ -0,0 +1,378 @@ +package xurl + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHTTPEnsurePort(t *testing.T) { + cases := []struct { + name string + addr string + want string + }{ + { + name: "http", + addr: "http://localhost", + want: "http://localhost:80", + }, + { + name: "https", + addr: "https://localhost", + want: "https://localhost:443", + }, + { + name: "custom", + addr: "http://localhost:4000", + want: "http://localhost:4000", + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + addr := HTTPEnsurePort(tt.addr) + require.Equal(t, tt.want, addr) + }) + } +} + +func TestTCP(t *testing.T) { + cases := []struct { + name string + addr string + want string + error bool + }{ + { + name: "with scheme", + addr: "tcp://github.com/ignite/cli", + want: "tcp://github.com/ignite/cli", + }, + { + name: "without scheme", + addr: "github.com/ignite/cli", + want: "tcp://github.com/ignite/cli", + }, + { + name: "with invalid scheme", + addr: "ftp://github.com/ignite/cli", + want: "tcp://github.com/ignite/cli", + }, + { + name: "with ip and port", + addr: "0.0.0.0:4500", + want: "tcp://0.0.0.0:4500", + }, + { + name: "with localhost and port", + addr: "localhost:4500", + want: "tcp://localhost:4500", + }, + { + name: "with invalid url", + addr: "tcp://github.com:x", + error: true, + }, + { + name: "empty", + addr: "", + error: true, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + addr, err := TCP(tt.addr) + if tt.error { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.want, addr) + } + }) + } +} + +func TestHTTP(t *testing.T) { + cases := []struct { + name string + addr string + want string + error bool + }{ + { + name: "with scheme", + addr: "http://github.com/ignite/cli", + want: "http://github.com/ignite/cli", + }, + { + name: "without scheme", + addr: "github.com/ignite/cli", + want: "http://github.com/ignite/cli", + }, + { + name: "with invalid scheme", + addr: "ftp://github.com/ignite/cli", + want: "http://github.com/ignite/cli", + }, + { + name: "with ip and port", + addr: "0.0.0.0:4500", + want: "http://0.0.0.0:4500", + }, + { + name: "with localhost and port", + addr: "localhost:4500", + want: "http://localhost:4500", + }, + { + name: "with invalid url", + addr: "http://github.com:x", + error: true, + }, + { + name: "empty", + addr: "", + error: true, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + addr, err := HTTP(tt.addr) + if tt.error { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.want, addr) + } + }) + } +} + +func TestHTTPS(t *testing.T) { + cases := []struct { + name string + addr string + want string + error bool + }{ + { + name: "with scheme", + addr: "https://github.com/ignite/cli", + want: "https://github.com/ignite/cli", + }, + { + name: "without scheme", + addr: "github.com/ignite/cli", + want: "https://github.com/ignite/cli", + }, + { + name: "with invalid scheme", + addr: "ftp://github.com/ignite/cli", + want: "https://github.com/ignite/cli", + }, + { + name: "with ip and port", + addr: "0.0.0.0:4500", + want: "https://0.0.0.0:4500", + }, + { + name: "with localhost and port", + addr: "localhost:4500", + want: "https://localhost:4500", + }, + { + name: "with invalid url", + addr: "https://github.com:x", + error: true, + }, + { + name: "empty", + addr: "", + error: true, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + addr, err := HTTPS(tt.addr) + if tt.error { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.want, addr) + } + }) + } +} + +func TestWS(t *testing.T) { + cases := []struct { + name string + addr string + want string + error bool + }{ + { + name: "with scheme", + addr: "ws://github.com/ignite/cli", + want: "ws://github.com/ignite/cli", + }, + { + name: "without scheme", + addr: "github.com/ignite/cli", + want: "ws://github.com/ignite/cli", + }, + { + name: "with invalid scheme", + addr: "ftp://github.com/ignite/cli", + want: "ws://github.com/ignite/cli", + }, + { + name: "with ip and port", + addr: "0.0.0.0:4500", + want: "ws://0.0.0.0:4500", + }, + { + name: "with localhost and port", + addr: "localhost:4500", + want: "ws://localhost:4500", + }, + { + name: "with invalid url", + addr: "ws://github.com:x", + error: true, + }, + { + name: "empty", + addr: "", + error: true, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + addr, err := WS(tt.addr) + if tt.error { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.want, addr) + } + }) + } +} + +func TestMightHTTPS(t *testing.T) { + cases := []struct { + name string + addr string + want string + error bool + }{ + { + name: "with http scheme", + addr: "http://github.com/ignite/cli", + want: "http://github.com/ignite/cli", + }, + { + name: "with https scheme", + addr: "https://github.com/ignite/cli", + want: "https://github.com/ignite/cli", + }, + { + name: "without scheme", + addr: "github.com/ignite/cli", + want: "https://github.com/ignite/cli", + }, + { + name: "with invalid scheme", + addr: "ftp://github.com/ignite/cli", + want: "https://github.com/ignite/cli", + }, + { + name: "with ip and port", + addr: "0.0.0.0:4500", + want: "https://0.0.0.0:4500", + }, + { + name: "with localhost and port", + addr: "localhost:4500", + want: "https://localhost:4500", + }, + { + name: "with invalid url", + addr: "https://github.com:x", + error: true, + }, + { + name: "empty", + addr: "", + error: true, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + addr, err := MightHTTPS(tt.addr) + if tt.error { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.want, addr) + } + }) + } +} + +func Test_addressPort(t *testing.T) { + tests := []struct { + name string + arg string + wantHost string + want bool + }{ + { + name: "URI path", + arg: "/test/false", + want: false, + }, + { + name: "invalid address", + arg: "aeihf3/aef/f..//", + want: false, + }, + { + name: "host and port", + arg: "102.33.3.43:10000", + wantHost: "102.33.3.43:10000", + want: true, + }, + { + name: "local port", + arg: "0.0.0.0:10000", + wantHost: "0.0.0.0:10000", + want: true, + }, + { + name: "only port", + arg: ":10000", + wantHost: "0.0.0.0:10000", + want: true, + }, + { + name: "only host", + arg: "102.33.3.43", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotHost, got := addressPort(tt.arg) + require.Equal(t, tt.want, got) + require.Equal(t, tt.wantHost, gotHost) + }) + } +} diff --git a/pkg/yaml/map.go b/pkg/yaml/map.go new file mode 100755 index 000000000..5385f0e64 --- /dev/null +++ b/pkg/yaml/map.go @@ -0,0 +1,53 @@ +package yaml + +// Map defines a map type that uses strings as key value. +// The map implements the Unmarshaller interface to convert +// the unmarshalled map keys type from interface{} to string. +type Map map[string]interface{} + +func (m *Map) UnmarshalYAML(unmarshal func(interface{}) error) error { + var raw map[interface{}]interface{} + + if err := unmarshal(&raw); err != nil { + return err + } + + *m = convertMapKeys(raw) + + return nil +} + +func convertSlice(raw []interface{}) []interface{} { + if len(raw) == 0 { + return raw + } + + if _, ok := raw[0].(map[interface{}]interface{}); !ok { + return raw + } + + values := make([]interface{}, len(raw)) + for i, v := range raw { + values[i] = convertMapKeys(v.(map[interface{}]interface{})) + } + + return values +} + +func convertMapKeys(raw map[interface{}]interface{}) map[string]interface{} { + m := make(map[string]interface{}) + + for k, v := range raw { + if value, _ := v.(map[interface{}]interface{}); value != nil { + // Convert map keys to string + v = convertMapKeys(value) + } else if values, _ := v.([]interface{}); values != nil { + // Make sure that maps inside slices also use strings as key + v = convertSlice(values) + } + + m[k.(string)] = v + } + + return m +} diff --git a/pkg/yaml/map_test.go b/pkg/yaml/map_test.go new file mode 100755 index 000000000..b8ff34da2 --- /dev/null +++ b/pkg/yaml/map_test.go @@ -0,0 +1,44 @@ +package yaml_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" + + xyaml "github.com/sonr-io/core/pkg/yaml" +) + +func TestUnmarshalWithCustomMapType(t *testing.T) { + // Arrange + input := ` + foo: + bar: baz + ` + output := xyaml.Map{} + + // Act + err := yaml.Unmarshal([]byte(input), &output) + + // Assert + require.NoError(t, err) + require.NotNil(t, output["foo"]) + require.IsType(t, (map[string]interface{})(nil), output["foo"]) +} + +func TestUnmarshalWithNativeMapType(t *testing.T) { + // Arrange + input := ` + foo: + bar: baz + ` + output := make(map[string]interface{}) + + // Act + err := yaml.Unmarshal([]byte(input), &output) + + // Assert + require.NoError(t, err) + require.NotNil(t, output["foo"]) + require.IsType(t, (map[interface{}]interface{})(nil), output["foo"]) +} diff --git a/pkg/yaml/yaml.go b/pkg/yaml/yaml.go new file mode 100755 index 000000000..9bb156944 --- /dev/null +++ b/pkg/yaml/yaml.go @@ -0,0 +1,43 @@ +package yaml + +import ( + "context" + "errors" + "strings" + + "github.com/goccy/go-yaml" + "github.com/goccy/go-yaml/parser" +) + +// Marshal converts an object to a string in a YAML format and transforms +// the byte slice fields from the path to string to be more readable. +func Marshal(ctx context.Context, obj interface{}, paths ...string) (string, error) { + requestYaml, err := yaml.MarshalContext(ctx, obj) + if err != nil { + return "", err + } + file, err := parser.ParseBytes(requestYaml, 0) + if err != nil { + return "", err + } + + // normalize the structure converting the byte slice fields to string + for _, path := range paths { + pathString, err := yaml.PathString(path) + if err != nil { + return "", err + } + var byteSlice []byte + err = pathString.Read(strings.NewReader(string(requestYaml)), &byteSlice) + if err != nil && !errors.Is(err, yaml.ErrNotFoundNode) { + return "", err + } + if err := pathString.ReplaceWithReader(file, + strings.NewReader(string(byteSlice)), + ); err != nil { + return "", err + } + } + + return file.String(), nil +} diff --git a/services/plugin/cache.go b/services/plugin/cache.go new file mode 100644 index 000000000..2c829ff2b --- /dev/null +++ b/services/plugin/cache.go @@ -0,0 +1,88 @@ +package plugin + +import ( + "encoding/gob" + "fmt" + "net" + "path" + + hplugin "github.com/hashicorp/go-plugin" + + "github.com/sonr-io/core/pkg/cache" +) + +const ( + cacheFileName = "ignite_plugin_cache.db" + cacheNamespace = "plugin.rpc.context" +) + +var storageCache *cache.Cache[hplugin.ReattachConfig] + +func init() { + gob.Register(hplugin.ReattachConfig{}) + gob.Register(&net.UnixAddr{}) +} + +func writeConfigCache(pluginPath string, conf hplugin.ReattachConfig) error { + if pluginPath == "" { + return fmt.Errorf("provided path is invalid: %s", pluginPath) + } + if conf.Addr == nil { + return fmt.Errorf("app Address info cannot be empty") + } + cache, err := newCache() + if err != nil { + return err + } + return cache.Put(pluginPath, conf) +} + +func readConfigCache(pluginPath string) (hplugin.ReattachConfig, error) { + if pluginPath == "" { + return hplugin.ReattachConfig{}, fmt.Errorf("provided path is invalid: %s", pluginPath) + } + cache, err := newCache() + if err != nil { + return hplugin.ReattachConfig{}, err + } + return cache.Get(pluginPath) +} + +func checkConfCache(pluginPath string) bool { + if pluginPath == "" { + return false + } + cache, err := newCache() + if err != nil { + return false + } + _, err = cache.Get(pluginPath) + return err == nil +} + +func deleteConfCache(pluginPath string) error { + if pluginPath == "" { + return fmt.Errorf("provided path is invalid: %s", pluginPath) + } + cache, err := newCache() + if err != nil { + return err + } + return cache.Delete(pluginPath) +} + +func newCache() (*cache.Cache[hplugin.ReattachConfig], error) { + cacheRootDir, err := PluginsPath() + if err != nil { + return nil, err + } + if storageCache == nil { + storage, err := cache.NewStorage(path.Join(cacheRootDir, cacheFileName)) + if err != nil { + return nil, err + } + c := cache.New[hplugin.ReattachConfig](storage, cacheNamespace) + storageCache = &c + } + return storageCache, nil +} diff --git a/services/plugin/plugin.go b/services/plugin/plugin.go index 49befa050..3b7b8083e 100644 --- a/services/plugin/plugin.go +++ b/services/plugin/plugin.go @@ -4,22 +4,51 @@ package plugin import ( + "context" + "fmt" + "io/fs" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + "time" + + "github.com/hashicorp/go-hclog" hplugin "github.com/hashicorp/go-plugin" + "github.com/pkg/errors" + + "github.com/sonr-io/core/config" + pluginsconfig "github.com/sonr-io/core/config/plugins" + "github.com/sonr-io/core/pkg/env" + "github.com/sonr-io/core/pkg/events" + "github.com/sonr-io/core/pkg/gocmd" + "github.com/sonr-io/core/pkg/xfilepath" + "github.com/sonr-io/core/pkg/xgit" + "github.com/sonr-io/core/pkg/xurl" ) +// PluginsPath holds the plugin cache directory. +var PluginsPath = xfilepath.Mkdir(xfilepath.Join( + config.DirPath, + xfilepath.Path("apps"), +)) + // Plugin represents a ignite plugin. type Plugin struct { + // Embed the plugin configuration + pluginsconfig.Plugin // Interface allows to communicate with the plugin via net/rpc. Interface Interface // If any error occurred during the plugin load, it's stored here Error error - repoPath string - cloneURL string - cloneDir string - reference string - srcPath string - binaryName string + name string + repoPath string + cloneURL string + cloneDir string + reference string + srcPath string client *hplugin.Client @@ -29,228 +58,341 @@ type Plugin struct { // plugin instance is controlling the rpc server. isHost bool + ev events.Bus } // Option configures Plugin. type Option func(*Plugin) +// CollectEvents collects events from the chain. +func CollectEvents(ev events.Bus) Option { + return func(p *Plugin) { + p.ev = ev + } +} + +// Load loads the plugins found in the chain config. +// +// There's 2 kinds of plugins, local or remote. +// Local plugins have their path starting with a `/`, while remote plugins don't. +// Local plugins are useful for development purpose. +// Remote plugins require to be fetched first, in $HOME/.ignite/apps folder, +// then they are loaded from there. +// +// If an error occurs during a plugin load, it's not returned but rather stored in +// the `Plugin.Error` field. This prevents the loading of other plugins to be interrupted. +func Load(ctx context.Context, plugins []pluginsconfig.Plugin, options ...Option) ([]*Plugin, error) { + pluginsDir, err := PluginsPath() + if err != nil { + return nil, errors.WithStack(err) + } + var loaded []*Plugin + for _, cp := range plugins { + p := newPlugin(pluginsDir, cp, options...) + p.load(ctx) + + loaded = append(loaded, p) + } + return loaded, nil +} + +// Update removes the cache directory of plugins and fetch them again. +func Update(plugins ...*Plugin) error { + for _, p := range plugins { + err := p.clean() + if err != nil { + return err + } + p.fetch() + } + return nil +} + +// newPlugin creates a Plugin from configuration. +func newPlugin(pluginsDir string, cp pluginsconfig.Plugin, options ...Option) *Plugin { + var ( + p = &Plugin{ + Plugin: cp, + } + pluginPath = cp.Path + ) + if pluginPath == "" { + p.Error = errors.Errorf(`missing app property "path"`) + return p + } + + // Apply the options + for _, apply := range options { + apply(p) + } + + if strings.HasPrefix(pluginPath, "/") { + // This is a local plugin, check if the file exists + st, err := os.Stat(pluginPath) + if err != nil { + p.Error = errors.Wrapf(err, "local app path %q not found", pluginPath) + return p + } + if !st.IsDir() { + p.Error = errors.Errorf("local app path %q is not a directory", pluginPath) + return p + } + p.srcPath = pluginPath + p.name = path.Base(pluginPath) + return p + } + // This is a remote plugin, parse the URL + if i := strings.LastIndex(pluginPath, "@"); i != -1 { + // path contains a reference + p.reference = pluginPath[i+1:] + pluginPath = pluginPath[:i] + } + parts := strings.Split(pluginPath, "/") + if len(parts) < 3 { + p.Error = errors.Errorf("app path %q is not a valid repository URL", pluginPath) + return p + } + p.repoPath = path.Join(parts[:3]...) + p.cloneURL, _ = xurl.HTTPS(p.repoPath) + + if len(p.reference) > 0 { + ref := strings.ReplaceAll(p.reference, "/", "-") + p.cloneDir = path.Join(pluginsDir, fmt.Sprintf("%s-%s", p.repoPath, ref)) + p.repoPath += "@" + p.reference + } else { + p.cloneDir = path.Join(pluginsDir, p.repoPath) + } + + // Plugin can have a subpath within its repository. + // For example, "github.com/ignite/apps/app1" where "app1" is the subpath. + repoSubPath := path.Join(parts[3:]...) + + p.srcPath = path.Join(p.cloneDir, repoSubPath) + p.name = path.Base(pluginPath) + + return p +} + +// KillClient kills the running plugin client. +func (p *Plugin) KillClient() { + if p.manifest.SharedHost && !p.isHost { + // Don't send kill signal to a shared-host plugin when this process isn't + // the one who initiated it. + return + } + + if p.client != nil { + p.client.Kill() + } + + if p.isHost { + _ = deleteConfCache(p.Path) + p.isHost = false + } +} + +func (p Plugin) binaryName() string { + return fmt.Sprintf("%s.app", p.name) +} -// // Load loads the plugins found in the chain config. -// // -// // There's 2 kinds of plugins, local or remote. -// // Local plugins have their path starting with a `/`, while remote plugins -// // don't. -// // Local plugins are useful for development purpose. -// // Remote plugins require to be fetched first, in $HOME/.ignite/plugins -// // folder, then they are loaded from there. -// // -// // If an error occurs during a plugin load, it's not returned but rather stored -// // in the Plugin.Error field. This prevents the loading of other plugins to be -// // interrupted. -// func Load(ctx context.Context, plugins []pluginsconfig.Plugin, options ...Option) ([]*Plugin, error) { -// pluginsDir, err := PluginsPath() -// if err != nil { -// return nil, errors.WithStack(err) -// } -// var loaded []*Plugin -// for _, cp := range plugins { -// p := newPlugin(pluginsDir, cp, options...) -// p.load(ctx) - -// loaded = append(loaded, p) -// } -// return loaded, nil -// } - -// // Update removes the cache directory of plugins and fetch them again. -// func Update(plugins ...*Plugin) error { -// for _, p := range plugins { -// err := p.clean() -// if err != nil { -// return err -// } -// p.fetch() -// } -// return nil -// } - -// // newPlugin creates a Plugin from configuration. -// func newPlugin(pluginsDir string, cp pluginsconfig.Plugin, options ...Option) *Plugin { -// var ( -// p = &Plugin{ -// Plugin: cp, -// } -// pluginPath = cp.Path -// ) -// if pluginPath == "" { -// p.Error = errors.Errorf(`missing plugin property "path"`) -// return p -// } - -// // Apply the options -// for _, apply := range options { -// apply(p) -// } - -// if strings.HasPrefix(pluginPath, "/") { -// // This is a local plugin, check if the file exists -// st, err := os.Stat(pluginPath) -// if err != nil { -// p.Error = errors.Wrapf(err, "local plugin path %q not found", pluginPath) -// return p -// } -// if !st.IsDir() { -// p.Error = errors.Errorf("local plugin path %q is not a dir", pluginPath) -// return p -// } -// p.srcPath = pluginPath -// p.binaryName = path.Base(pluginPath) -// return p -// } -// // This is a remote plugin, parse the URL -// if i := strings.LastIndex(pluginPath, "@"); i != -1 { -// // path contains a reference -// p.reference = pluginPath[i+1:] -// pluginPath = pluginPath[:i] -// } -// parts := strings.Split(pluginPath, "/") -// if len(parts) < 3 { -// p.Error = errors.Errorf("plugin path %q is not a valid repository URL", pluginPath) -// return p -// } -// p.repoPath = path.Join(parts[:3]...) - -// if len(p.reference) > 0 { -// ref := strings.ReplaceAll(p.reference, "/", "-") -// p.cloneDir = path.Join(pluginsDir, fmt.Sprintf("%s-%s", p.repoPath, ref)) -// p.repoPath += "@" + p.reference -// } else { -// p.cloneDir = path.Join(pluginsDir, p.repoPath) -// } - -// // Plugin can have a subpath within its repository. For example, -// // "github.com/ignite/plugins/plugin1" where "plugin1" is the subpath. -// repoSubPath := path.Join(parts[3:]...) - -// p.srcPath = path.Join(p.cloneDir, repoSubPath) -// p.binaryName = path.Base(pluginPath) - -// return p -// } - -// // KillClient kills the running plugin client. -// func (p *Plugin) KillClient() { -// if p.manifest.SharedHost && !p.isHost { -// // Don't send kill signal to a shared-host plugin when this process isn't -// // the one who initiated it. -// return -// } - -// if p.client != nil { -// p.client.Kill() -// } - -// if p.isHost { -// p.isHost = false -// } -// } - -// func (p *Plugin) binaryPath() string { -// return path.Join(p.srcPath, p.binaryName) -// } - -// // load tries to fill p.Interface, ensuring the plugin is usable. -// func (p *Plugin) load(ctx context.Context) { -// if p.Error != nil { -// return -// } -// _, err := os.Stat(p.srcPath) -// if err != nil { -// // srcPath found, need to fetch the plugin -// p.fetch() -// if p.Error != nil { -// return -// } -// } -// if p.Error != nil { -// return -// } -// // pluginMap is the map of plugins we can dispense. -// pluginMap := map[string]hplugin.Plugin{ -// p.binaryName: &InterfacePlugin{}, -// } - -// logger := hclog.New(&hclog.LoggerOptions{ -// Name: fmt.Sprintf("plugin"), -// Output: os.Stderr, -// }) - - -// // We're a host! Start by launching the plugin process. -// p.client = hplugin.NewClient(&hplugin.ClientConfig{ -// HandshakeConfig: handshakeConfig, -// Plugins: pluginMap, -// Logger: logger, -// Cmd: exec.Command(p.binaryPath()), -// SyncStderr: os.Stderr, -// SyncStdout: os.Stdout, -// }) - -// // :Connect via RPC -// rpcClient, err := p.client.Client() -// if err != nil { -// p.Error = errors.Wrapf(err, "connecting") -// return -// } - -// // Request the plugin -// raw, err := rpcClient.Dispense(p.binaryName) -// if err != nil { -// p.Error = errors.Wrapf(err, "dispensing") -// return -// } - -// // We should have an Interface now! This feels like a normal interface -// // implementation but is in fact over an RPC connection. -// p.Interface = raw.(Interface) - -// m, err := p.Interface.Manifest() -// if err != nil { -// p.Error = errors.Wrapf(err, "manifest load") -// } - -// p.manifest = m -// } - - -// // outdatedBinary returns true if the plugin binary is older than the other -// // files in p.srcPath. -// // Also returns true if the plugin binary is absent. -// func (p *Plugin) outdatedBinary() bool { -// var ( -// binaryTime time.Time -// mostRecent time.Time -// ) -// err := filepath.Walk(p.srcPath, func(path string, info fs.FileInfo, err error) error { -// if err != nil { -// return err -// } -// if info.IsDir() { -// return nil -// } -// if path == p.binaryPath() { -// binaryTime = info.ModTime() -// return nil -// } -// t := info.ModTime() -// if mostRecent.IsZero() || t.After(mostRecent) { -// mostRecent = t -// } -// return nil -// }) -// if err != nil { -// fmt.Printf("error while walking plugin source path %q\n", p.srcPath) -// return false -// } -// return mostRecent.After(binaryTime) -// } +func (p Plugin) binaryPath() string { + return path.Join(p.srcPath, p.binaryName()) +} + +// load tries to fill p.Interface, ensuring the plugin is usable. +func (p *Plugin) load(ctx context.Context) { + if p.Error != nil { + return + } + _, err := os.Stat(p.srcPath) + if err != nil { + // srcPath found, need to fetch the plugin + p.fetch() + if p.Error != nil { + return + } + } + + if p.IsLocalPath() { + // trigger rebuild for local plugin if binary is outdated + if p.outdatedBinary() { + p.build(ctx) + } + } else { + // Check if binary is already build + _, err = os.Stat(p.binaryPath()) + if err != nil { + // binary not found, need to build it + p.build(ctx) + } + } + if p.Error != nil { + return + } + // pluginMap is the map of plugins we can dispense. + pluginMap := map[string]hplugin.Plugin{ + p.name: &InterfacePlugin{}, + } + // Create an hclog.Logger + logLevel := hclog.Error + if env.DebugEnabled() { + logLevel = hclog.Trace + } + logger := hclog.New(&hclog.LoggerOptions{ + Name: fmt.Sprintf("app %s", p.Path), + Output: os.Stderr, + Level: logLevel, + }) + + if checkConfCache(p.Path) { + rconf, err := readConfigCache(p.Path) + if err != nil { + p.Error = err + return + } + + // We're attaching to an existing server, supply attachment configuration + p.client = hplugin.NewClient(&hplugin.ClientConfig{ + HandshakeConfig: handshakeConfig, + Plugins: pluginMap, + Logger: logger, + Reattach: &rconf, + SyncStderr: os.Stderr, + SyncStdout: os.Stdout, + }) + + } else { + // We're a host! Start by launching the plugin process. + p.client = hplugin.NewClient(&hplugin.ClientConfig{ + HandshakeConfig: handshakeConfig, + Plugins: pluginMap, + Logger: logger, + Cmd: exec.Command(p.binaryPath()), + SyncStderr: os.Stderr, + SyncStdout: os.Stdout, + }) + } + + // :Connect via RPC + rpcClient, err := p.client.Client() + if err != nil { + p.Error = errors.Wrapf(err, "connecting") + return + } + + // Request the plugin + raw, err := rpcClient.Dispense(p.name) + if err != nil { + p.Error = errors.Wrapf(err, "dispensing") + return + } + + // We should have an Interface now! This feels like a normal interface + // implementation but is in fact over an RPC connection. + p.Interface = raw.(Interface) + + m, err := p.Interface.Manifest() + if err != nil { + p.Error = errors.Wrapf(err, "manifest load") + } + + p.manifest = m + + // write the rpc context to cache if the plugin is declared as host. + // writing it to cache as lost operation within load to assure rpc client's reattach config + // is hydrated. + if m.SharedHost && !checkConfCache(p.Path) { + err := writeConfigCache(p.Path, *p.client.ReattachConfig()) + if err != nil { + p.Error = err + return + } + + // set the plugin's rpc server as host so other plugin clients may share + p.isHost = true + } +} + +// fetch clones the plugin repository at the expected reference. +func (p *Plugin) fetch() { + if p.IsLocalPath() { + return + } + if p.Error != nil { + return + } + p.ev.Send(fmt.Sprintf("Fetching app %q", p.cloneURL), events.ProgressStart()) + defer p.ev.Send(fmt.Sprintf("%s App fetched ✅", p.cloneURL), events.ProgressFinish()) + + urlref := strings.Join([]string{p.cloneURL, p.reference}, "@") + err := xgit.Clone(context.Background(), urlref, p.cloneDir) + if err != nil { + p.Error = errors.Wrapf(err, "cloning %q", p.repoPath) + } +} + +// build compiles the plugin binary. +func (p *Plugin) build(ctx context.Context) { + if p.Error != nil { + return + } + p.ev.Send(fmt.Sprintf("Building app %q", p.Path), events.ProgressStart()) + defer p.ev.Send(fmt.Sprintf("%s App built ✅", p.Path), events.ProgressFinish()) + + if err := gocmd.ModTidy(ctx, p.srcPath); err != nil { + p.Error = errors.Wrapf(err, "go mod tidy") + return + } + if err := gocmd.Build(ctx, p.binaryName(), p.srcPath, nil); err != nil { + p.Error = errors.Wrapf(err, "go build") + return + } +} + +// clean removes the plugin cache (only for remote plugins). +func (p *Plugin) clean() error { + if p.Error != nil { + // Dont try to clean plugins with error + return nil + } + if p.IsLocalPath() { + // Not a remote plugin, nothing to clean + return nil + } + // Clean the cloneDir, next time the ignite command will be invoked, the + // plugin will be fetched again. + err := os.RemoveAll(p.cloneDir) + return errors.WithStack(err) +} + +// outdatedBinary returns true if the plugin binary is older than the other +// files in p.srcPath. +// Also returns true if the plugin binary is absent. +func (p *Plugin) outdatedBinary() bool { + var ( + binaryTime time.Time + mostRecent time.Time + ) + err := filepath.Walk(p.srcPath, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if path == p.binaryPath() { + binaryTime = info.ModTime() + return nil + } + t := info.ModTime() + if mostRecent.IsZero() || t.After(mostRecent) { + mostRecent = t + } + return nil + }) + if err != nil { + fmt.Printf("error while walking app source path %q\n", p.srcPath) + return false + } + return mostRecent.After(binaryTime) +}