mukan-ignite/ignite/cmd/plugin.go
Mukan Erkin Törük c32551b6f7
Some checks failed
Docs Deploy / build_and_deploy (push) Has been cancelled
Generate Docs / cli (push) Has been cancelled
Generate Config Doc / cli (push) Has been cancelled
Go formatting / go-formatting (push) Has been cancelled
Check links / markdown-link-check (push) Has been cancelled
Integration / pre-test (push) Has been cancelled
Integration / test on (push) Has been cancelled
Integration / status (push) Has been cancelled
Lint / Lint Go code (push) Has been cancelled
Test / test (ubuntu-latest) (push) Has been cancelled
refactor: replace all github.com upstream refs with git.cw.tr/mukan-network
2026-05-11 03:36:24 +03:00

763 lines
19 KiB
Go

package ignitecmd
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/spf13/cobra"
flag "github.com/spf13/pflag"
pluginsconfig "git.cw.tr/mukan-network/mukan-ignite/ignite/config/plugins"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/clictx"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/cliui"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/cliui/icons"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/cosmosanalysis"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/errors"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/gomodule"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/xfilepath"
"git.cw.tr/mukan-network/mukan-ignite/ignite/pkg/xgit"
"git.cw.tr/mukan-network/mukan-ignite/ignite/services/chain"
"git.cw.tr/mukan-network/mukan-ignite/ignite/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, session *cliui.Session) error {
var pluginsConfigs []pluginsconfig.Plugin
localCfg, err := parseLocalPlugins()
if err != nil && !errors.As(err, &cosmosanalysis.ErrPathNotChain{}) {
return err
} else if err == nil {
pluginsConfigs = append(pluginsConfigs, localCfg.Apps...)
}
globalCfg, err := parseGlobalPlugins()
if err == nil {
pluginsConfigs = append(pluginsConfigs, globalCfg.Apps...)
}
ensureDefaultPlugins(cmd, globalCfg)
if len(pluginsConfigs) == 0 {
return nil
}
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(ctx, cmd.Root(), plugins)
}
func parseLocalPlugins() (*pluginsconfig.Config, error) {
wd, err := os.Getwd()
if err != nil {
return nil, errors.Errorf("parse local apps: %w", err)
}
if err := cosmosanalysis.IsChainPath(wd); err != nil {
return nil, 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.Apps {
cfg.Apps[i].Global = true
}
return
}
func linkPlugins(ctx context.Context, 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(ctx)
if err != nil {
p.Error = err
linkErrors = append(linkErrors, p)
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(ctx, 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.CommandPath()
cmd := findCommandByPath(rootCmd, cmdPath)
if cmd == nil {
p.Error = errors.Errorf("unable to find command path %q for app hook %q", cmdPath, hook.Name)
return
}
if !cmd.Runnable() {
p.Error = errors.Errorf("can't attach app hook %q to non executable command %q", hook.Name, hook.PlaceHookOn)
return
}
newExecutedHook := func(hook *plugin.Hook, cmd *cobra.Command, args []string) *plugin.ExecutedHook {
hook.ImportFlags(cmd)
execHook := &plugin.ExecutedHook{
Hook: hook,
ExecutedCommand: &plugin.ExecutedCommand{
Use: cmd.Use,
Path: cmd.CommandPath(),
Args: args,
OsArgs: os.Args,
With: p.With,
Flags: hook.Flags,
},
}
execHook.ExecutedCommand.ImportFlags(cmd)
return execHook
}
for _, f := range hook.Flags {
var fs *flag.FlagSet
if f.Persistent {
fs = cmd.PersistentFlags()
} else {
fs = cmd.Flags()
}
if err := f.ExportToFlagSet(fs); err != nil {
p.Error = errors.Errorf("can't attach hook flags %q to command %q", hook.Flags, hook.PlaceHookOn)
return
}
}
preRun := cmd.PreRunE
cmd.PreRunE = func(cmd *cobra.Command, args []string) error {
if preRun != nil {
err := preRun(cmd, args)
if err != nil {
return err
}
}
api, err := newAppClientAPI(cmd)
if err != nil {
return err
}
ctx := cmd.Context()
execHook := newExecutedHook(hook, cmd, args)
err = p.Interface.ExecuteHookPre(ctx, execHook, api)
if err != nil {
return errors.Errorf("app %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 returning.
if err != nil {
api, err := newAppClientAPI(cmd)
if err != nil {
return err
}
ctx := cmd.Context()
execHook := newExecutedHook(hook, cmd, args)
err = p.Interface.ExecuteHookCleanUp(ctx, execHook, api)
if err != nil {
cmd.Printf("app %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 {
api, err := newAppClientAPI(cmd)
if err != nil {
return err
}
ctx := cmd.Context()
execHook := newExecutedHook(hook, cmd, args)
defer func() {
err := p.Interface.ExecuteHookCleanUp(ctx, execHook, api)
if err != nil {
cmd.Printf("app %q ExecuteHookCleanUp() error: %v", p.Path, err)
}
}()
if postCmd != 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(ctx, execHook, api)
if err != nil {
return errors.Errorf("app %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.Path()
cmd := findCommandByPath(rootCmd, cmdPath)
if cmd == nil {
p.Error = errors.Errorf("unable to find command path %q for app %q", cmdPath, p.Path)
return
}
if cmd.Runnable() {
p.Error = errors.Errorf("can't attach app 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("app command %q already exists in Ignite's commands", pluginCmdName)
return
}
}
newCmd, err := pluginCmd.ToCobraCommand()
if err != nil {
p.Error = err
return
}
cmd.AddCommand(newCmd)
if len(pluginCmd.Commands) == 0 {
// pluginCmd has no sub commands, so it's runnable
newCmd.RunE = func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
return clictx.Do(ctx, func() error {
api, err := newAppClientAPI(cmd)
if err != nil {
return err
}
// Call the plugin Execute
execCmd := &plugin.ExecutedCommand{
Use: cmd.Use,
Path: cmd.CommandPath(),
Args: args,
OsArgs: os.Args,
With: p.With,
}
execCmd.ImportFlags(cmd)
err = p.Interface.Execute(ctx, execCmd, api)
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
}
// NewApp returns a command that groups Ignite App related sub commands.
func NewApp() *cobra.Command {
c := &cobra.Command{
Use: "app [command]",
Short: "Create and manage Ignite Apps",
}
c.AddCommand(
NewAppList(),
NewAppUpdate(),
NewAppScaffold(),
NewAppDescribe(),
NewAppInstall(),
NewAppUninstall(),
)
return c
}
func NewAppList() *cobra.Command {
lstCmd := &cobra.Command{
Use: "list",
Short: "List installed apps",
Long: "Prints status and information of all installed Ignite Apps.",
RunE: func(cmd *cobra.Command, _ []string) error {
s := cliui.New(cliui.WithStdout(os.Stdout))
return printPlugins(cmd.Context(), s)
},
}
return lstCmd
}
func NewAppUpdate() *cobra.Command {
return &cobra.Command{
Use: "update [path]",
Short: "Update app",
Long: `Updates an Ignite App specified by path.
If no path is specified all declared apps are updated.`,
Example: "ignite app update github.com/org/my-app/",
Args: cobra.MaximumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
if len(args) == 0 {
// update all plugins
return plugin.Update(plugins...)
}
pluginPath, err := getAppPath(args[0])
if err != nil {
return err
}
// find the plugin to update
for _, p := range plugins {
if p.HasPath(pluginPath) {
return plugin.Update(p)
}
}
return errors.Errorf("App %q not found", pluginPath)
},
}
}
func NewAppInstall() *cobra.Command {
cmdPluginAdd := &cobra.Command{
Use: "install [path] [key=value]...",
Short: "Install app",
Long: `Installs an Ignite App.
Respects key value pairs declared after the app path to be added to the generated configuration definition.`,
Example: "ignite app install github.com/org/my-app/ foo=bar baz=qux",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
session := cliui.New(
cliui.WithStdout(os.Stdout),
cliui.WithoutUserInteraction(getYes(cmd)),
)
defer session.End()
var (
conf *pluginsconfig.Config
err error
)
global := flagGetPluginsGlobal(cmd)
if global {
conf, err = parseGlobalPlugins()
} else {
conf, err = parseLocalPlugins()
}
if err != nil {
return err
}
pluginPath, err := getAppPath(args[0])
if err != nil {
return err
}
for _, p := range conf.Apps {
if p.HasPath(pluginPath) {
return errors.Errorf("app %s is already installed", pluginPath)
}
}
p := pluginsconfig.Plugin{
Path: pluginPath,
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 errors.Errorf("malformed key=value arg: %s", pa)
}
p.With[kv[0]] = kv[1]
}
plugins, err := plugin.Load(cmd.Context(), []pluginsconfig.Plugin{p}, pluginsOptions...)
if err != nil {
return err
}
defer plugins[0].KillClient()
if err := plugins[0].Error; err != nil {
if strings.Contains(err.Error(), "go.mod file not found in current directory") {
return errors.Errorf("unable to find an App at the root of this repository (%s). Please ensure your repository URL is correct. If you're trying to install an App under a subfolder, include the path at the end of your repository URL, e.g., github.com/ignite/apps/appregistry", pluginPath)
}
return errors.Errorf("error while loading app %q: %w", pluginPath, plugins[0].Error)
}
session.Println(icons.OK, "Done loading apps")
conf.Apps = append(conf.Apps, p)
if err := conf.Save(); err != nil {
return err
}
session.Printf("%s Installed %s\n", icons.Tada, pluginPath)
return nil
},
}
cmdPluginAdd.Flags().AddFlagSet(flagSetPluginsGlobal())
return cmdPluginAdd
}
func NewAppUninstall() *cobra.Command {
cmdPluginRemove := &cobra.Command{
Use: "uninstall [path]",
Aliases: []string{"rm"},
Short: "Uninstall app",
Long: "Uninstalls an Ignite App specified by path.",
Example: "ignite app uninstall github.com/org/my-app/",
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()
}
if err != nil {
return err
}
pluginPath, err := getAppPath(args[0])
if err != nil {
return err
}
removed := false
for i, cp := range conf.Apps {
if cp.HasPath(pluginPath) {
conf.Apps = append(conf.Apps[:i], conf.Apps[i+1:]...)
removed = true
break
}
}
if !removed {
// return if no matching plugin path found
return errors.Errorf("app %s not found", pluginPath)
}
if err := conf.Save(); err != nil {
return err
}
s.Printf("%s %s uninstalled\n", icons.OK, pluginPath)
s.Printf("\t%s updated\n", conf.Path())
return nil
},
}
cmdPluginRemove.Flags().AddFlagSet(flagSetPluginsGlobal())
return cmdPluginRemove
}
func NewAppScaffold() *cobra.Command {
return &cobra.Command{
Use: "scaffold [name]",
Short: "Scaffold a new Ignite App",
Long: `Scaffolds a new Ignite App in the current directory.
A git repository will be created with the given module name, unless the current directory is already a git repository.`,
Example: "ignite app scaffold github.com/org/my-app/",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
session := cliui.New(
cliui.StartSpinnerWithText(statusScaffolding),
cliui.WithoutUserInteraction(getYes(cmd)),
)
defer session.End()
wd, err := os.Getwd()
if err != nil {
return err
}
moduleName := args[0]
path, err := plugin.Scaffold(cmd.Context(), session, wd, moduleName, false)
if err != nil {
return err
}
if err := xgit.InitAndCommit(path); err != nil {
return err
}
message := `⭐️ Successfully created a new Ignite App '%[1]s'.
👉 Update app code at '%[2]s/main.go'
👉 Test Ignite App integration by installing the app within the chain directory:
ignite app install %[2]s
Or globally:
ignite app install -g %[2]s
👉 Once the app is pushed to a repository, replace the local path by the repository path.
`
session.Printf(message, moduleName, path)
return nil
},
}
}
func NewAppDescribe() *cobra.Command {
return &cobra.Command{
Use: "describe [path]",
Short: "Print information about installed apps",
Long: "Print information about an installed Ignite App commands and hooks.",
Example: "ignite app describe github.com/org/my-app/",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var (
s = cliui.New(cliui.WithStdout(os.Stdout))
ctx = cmd.Context()
)
pluginPath, err := getAppPath(args[0])
if err != nil {
return err
}
for _, p := range plugins {
if p.HasPath(pluginPath) {
manifest, err := p.Interface.Manifest(ctx)
if err != nil {
return errors.Errorf("error while loading app manifest: %w", err)
}
if len(manifest.Commands) > 0 {
s.Println("Commands:")
for i, c := range manifest.Commands {
cmdPath := fmt.Sprintf("%s %s", c.Path(), c.Use)
s.Printf(" %d) %s\n", i+1, cmdPath)
}
}
if len(manifest.Hooks) > 0 {
s.Println("Hooks:")
for i, h := range manifest.Hooks {
s.Printf(" %d) '%s' on command '%s'\n", i+1, h.Name, h.CommandPath())
}
}
break
}
}
return nil
},
}
}
func getPluginLocationName(p *plugin.Plugin) string {
if p.IsGlobal() {
return "global"
}
return "local"
}
func getPluginStatus(ctx context.Context, p *plugin.Plugin) string {
if p.Error != nil {
return fmt.Sprintf("%s Error: %v", icons.NotOK, p.Error)
}
_, err := p.Interface.Manifest(ctx)
if err != nil {
return fmt.Sprintf("%s Error: Manifest() returned %v", icons.NotOK, err)
}
return fmt.Sprintf("%s Loaded", icons.OK)
}
func printPlugins(ctx context.Context, session *cliui.Session) error {
var entries [][]string
for _, p := range plugins {
entries = append(entries, []string{p.Path, getPluginLocationName(p), getPluginStatus(ctx, p)})
}
if err := session.PrintTable([]string{"Path", "Config", "Status"}, entries...); err != nil {
return errors.Errorf("error while printing apps: %w", err)
}
return nil
}
func newAppClientAPI(cmd *cobra.Command) (plugin.ClientAPI, error) {
// Get chain when the plugin runs inside an blockchain app
c, err := chain.NewWithHomeFlags(cmd)
if err != nil && !errors.Is(err, gomodule.ErrGoModNotFound) {
return nil, err
}
var options []plugin.APIOption
if c != nil {
options = append(options, plugin.WithChain(c))
}
return plugin.NewClientAPI(options...), nil
}
func flagSetPluginsGlobal() *flag.FlagSet {
fs := flag.NewFlagSet("", flag.ContinueOnError)
fs.BoolP(flagPluginsGlobal, "g", false, "use global plugins configuration ($HOME/.ignite/apps/igniteapps.yml)")
return fs
}
func flagGetPluginsGlobal(cmd *cobra.Command) bool {
global, _ := cmd.Flags().GetBool(flagPluginsGlobal)
return global
}
func getAppPath(path string) (string, error) {
if xfilepath.IsDir(path) {
// if directory is relative, make it absolute
pluginPathAbs, err := xfilepath.MustAbs(path)
if err != nil {
return "", errors.Wrapf(err, "failed to get absolute path of %s", path)
}
path = pluginPathAbs
}
return path, nil
}