diff --git a/changelog.md b/changelog.md index 19d10daacf..400ef45de3 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,7 @@ ### Features - [#2955](https://github.com/ignite/cli/pull/2955/) Add `ignite network request add-account` command. +- [#2877](https://github.com/ignite/cli/pull/2877) Plugin system - [#2995](https://github.com/ignite/cli/pull/2995/) Add `ignite network request remove-validator` command. - [#2999](https://github.com/ignite/cli/pull/2999/) Add `ignite network request remove-account` command. diff --git a/go.mod b/go.mod index 35b3c221a8..22d9b58af2 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,8 @@ require ( github.com/gookit/color v1.5.2 github.com/gorilla/mux v1.8.0 github.com/gorilla/rpc v1.2.0 + github.com/hashicorp/go-hclog v1.2.0 + github.com/hashicorp/go-plugin v1.4.4 github.com/iancoleman/strcase v0.2.0 github.com/ignite/web v0.3.10 github.com/imdario/mergo v0.3.13 @@ -216,6 +218,7 @@ require ( github.com/hashicorp/go-version v1.6.0 // indirect github.com/hashicorp/golang-lru v0.5.5-0.20210104140557-80c98217689d // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb // indirect github.com/hdevalence/ed25519consensus v0.0.0-20220222234857-c00d1f31bab3 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect github.com/improbable-eng/grpc-web v0.15.0 // indirect @@ -263,6 +266,7 @@ require ( 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 + github.com/mitchellh/go-testing-interface v1.0.0 // indirect github.com/moby/sys/mount v0.3.1 // indirect github.com/moby/sys/mountinfo v0.6.0 // indirect github.com/moricho/tparallel v0.2.1 // indirect @@ -276,6 +280,7 @@ require ( github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354 // indirect github.com/nishanths/exhaustive v0.8.3 // indirect github.com/nishanths/predeclared v0.2.2 // indirect + github.com/oklog/run v1.0.0 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799 // indirect diff --git a/go.sum b/go.sum index b60b2de5b6..0f1ceacb58 100644 --- a/go.sum +++ b/go.sum @@ -984,6 +984,8 @@ github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtng github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-getter v1.6.1 h1:NASsgP4q6tL94WH6nJxKWj8As2H/2kop/bB1d8JMyRY= +github.com/hashicorp/go-hclog v1.2.0 h1:La19f8d7WIlm4ogzNHB0JGqs5AUDAZ2UfCY4sJXcJdM= +github.com/hashicorp/go-hclog v1.2.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= @@ -992,6 +994,8 @@ github.com/hashicorp/go-multierror v0.0.0-20161216184304-ed905158d874/go.mod h1: github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-plugin v1.4.4 h1:NVdrSdFRt3SkZtNckJ6tog7gbpRrcbOjQi/rgF7JYWQ= +github.com/hashicorp/go-plugin v1.4.4/go.mod h1:viDMjcLJuDui6pXb8U4HVfb8AamCWhHGUjr2IrTF67s= github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= @@ -1015,6 +1019,8 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb h1:b5rjCoWHc7eqmAS4/qyk21ZsHyb6Mxv/jykxvNTkU4M= +github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= github.com/hdevalence/ed25519consensus v0.0.0-20220222234857-c00d1f31bab3 h1:aSVUgRRRtOrZOC1fYmY9gV0e9z/Iu+xNVSASWjsuyGU= github.com/hdevalence/ed25519consensus v0.0.0-20220222234857-c00d1f31bab3/go.mod h1:5PC6ZNPde8bBqU/ewGZig35+UIZtw9Ytxez8/q5ZyFE= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -1211,6 +1217,7 @@ github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwM github.com/matryer/moq v0.0.0-20190312154309-6cfb0558e1bd/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ= 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.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -1222,6 +1229,7 @@ github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNx github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= @@ -1352,6 +1360,7 @@ github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3L github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs= +github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= @@ -2077,6 +2086,7 @@ golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191115151921-52ab43148777/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/ignite/chainconfig/chainconfig.go b/ignite/chainconfig/chainconfig.go index 60bd12838c..e3352c31d4 100644 --- a/ignite/chainconfig/chainconfig.go +++ b/ignite/chainconfig/chainconfig.go @@ -37,6 +37,9 @@ var ( // Config defines the latest config. type Config = v1.Config +// Plugin defines the latest plugin config +type Plugin = v1.Plugin + // DefaultConfig returns a config for the latest version initialized with default values. func DefaultConfig() *Config { return v1.DefaultConfig() diff --git a/ignite/chainconfig/convert_test.go b/ignite/chainconfig/convert_test.go index dbe4063cc4..313864784c 100644 --- a/ignite/chainconfig/convert_test.go +++ b/ignite/chainconfig/convert_test.go @@ -21,6 +21,7 @@ func TestConvertLatest(t *testing.T) { // Assert require.NoError(t, err) require.Equal(t, chainconfig.LatestVersion, cfgLatest.GetVersion()) + require.Equal(t, testdata.GetLatestConfig(t), cfgLatest) } func TestMigrateLatest(t *testing.T) { diff --git a/ignite/chainconfig/v1/config.go b/ignite/chainconfig/v1/config.go index 8fc3f296a5..76baa4df21 100644 --- a/ignite/chainconfig/v1/config.go +++ b/ignite/chainconfig/v1/config.go @@ -22,6 +22,30 @@ type Config struct { config.BaseConfig `yaml:",inline"` Validators []Validator `yaml:"validators"` + Plugins []Plugin `yaml:"plugins,omitempty"` +} + +// 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"` } func (c *Config) SetDefaults() error { diff --git a/ignite/chainconfig/v1/config_test.go b/ignite/chainconfig/v1/config_test.go index b9c1346d10..0bf3e20ec3 100644 --- a/ignite/chainconfig/v1/config_test.go +++ b/ignite/chainconfig/v1/config_test.go @@ -1,14 +1,91 @@ package v1_test import ( + "os" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/ignite/cli/ignite/chainconfig/config" v1 "github.com/ignite/cli/ignite/chainconfig/v1" "github.com/ignite/cli/ignite/pkg/xnet" ) +func TestConfigDecode(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f, err := os.Open("testdata/config2.yaml") + require.NoError(err) + defer f.Close() + var cfg v1.Config + + err = cfg.Decode(f) + + require.NoError(err) + expected := v1.Config{ + BaseConfig: config.BaseConfig{ + Version: 1, + Build: config.Build{ + Binary: "evmosd", + Proto: config.Proto{ + Path: "proto", + ThirdPartyPaths: []string{"third_party/proto", "proto_vendor"}, + }, + }, + Accounts: []config.Account{ + { + Name: "alice", + Coins: []string{"100000000uatom", "100000000000000000000aevmos"}, + Mnemonic: "ozone unfold device pave lemon potato omit insect column wise cover hint narrow large provide kidney episode clay notable milk mention dizzy muffin crazy", + }, + { + Name: "bob", + Coins: []string{"5000000000000aevmos"}, + Address: "cosmos1adn9gxjmrc3hrsdx5zpc9sj2ra7kgqkmphf8yw", + }, + }, + Faucet: config.Faucet{ + Name: &[]string{"bob"}[0], + Coins: []string{"10aevmos"}, + Host: "0.0.0.0:4600", + Port: 4600, + }, + Genesis: map[string]any{ + "app_state": map[string]any{ + "crisis": map[string]any{ + "constant_fee": map[string]any{ + "denom": "aevmos", + }, + }, + }, + "chain_id": "evmosd_9000-1", + }, + }, + Validators: []v1.Validator{{ + Name: "alice", + Bonded: "100000000000000000000aevmos", + Home: "$HOME/.evmosd", + App: map[string]any{ + "evm-rpc": map[string]any{ + "address": "0.0.0.0:8545", + "ws-address": "0.0.0.0:8546", + }, + }, + }}, + Plugins: []v1.Plugin{ + { + Path: "/path/to/plugin1", + }, + { + Path: "/path/to/plugin2", + With: map[string]string{"foo": "bar", "bar": "baz"}, + }, + }, + } + assert.Equal(expected, cfg) +} + func TestConfigValidatorDefaultServers(t *testing.T) { // Arrange c := v1.Config{ diff --git a/ignite/chainconfig/v1/testdata/config2.yaml b/ignite/chainconfig/v1/testdata/config2.yaml new file mode 100644 index 0000000000..ea1b8d2822 --- /dev/null +++ b/ignite/chainconfig/v1/testdata/config2.yaml @@ -0,0 +1,47 @@ +version: 1 +build: + binary: evmosd + proto: + path: proto + third_party_paths: + - third_party/proto + - proto_vendor +accounts: +- name: alice + coins: + - 100000000uatom + - 100000000000000000000aevmos + mnemonic: ozone unfold device pave lemon potato omit insect column wise cover hint + narrow large provide kidney episode clay notable milk mention dizzy muffin crazy +- name: bob + coins: + - 5000000000000aevmos + address: cosmos1adn9gxjmrc3hrsdx5zpc9sj2ra7kgqkmphf8yw +faucet: + name: bob + coins: + - 10aevmos + host: 0.0.0.0:4600 + port: 4600 +genesis: + app_state: + crisis: + constant_fee: + denom: aevmos + chain_id: evmosd_9000-1 +validators: +- name: alice + bonded: 100000000000000000000aevmos + app: + evm-rpc: + address: 0.0.0.0:8545 + ws-address: 0.0.0.0:8546 + home: $HOME/.evmosd +plugins: +- name: plugin1 + path: /path/to/plugin1 +- name: plugin2 + path: /path/to/plugin2 + with: + foo: bar + bar: baz diff --git a/ignite/cmd/chain_build.go b/ignite/cmd/chain_build.go index fffddc57eb..6a0d46e4dd 100644 --- a/ignite/cmd/chain_build.go +++ b/ignite/cmd/chain_build.go @@ -123,7 +123,7 @@ func chainBuildHandler(cmd *cobra.Command, _ []string) error { chainOption = append(chainOption, chain.CheckDependencies()) } - c, err := newChainWithHomeFlags(cmd, chainOption...) + c, err := NewChainWithHomeFlags(cmd, chainOption...) if err != nil { return err } diff --git a/ignite/cmd/chain_faucet.go b/ignite/cmd/chain_faucet.go index 8964861c53..043b3bc70f 100644 --- a/ignite/cmd/chain_faucet.go +++ b/ignite/cmd/chain_faucet.go @@ -43,7 +43,7 @@ func chainFaucetHandler(cmd *cobra.Command, args []string) error { chain.CollectEvents(session.EventBus()), } - c, err := newChainWithHomeFlags(cmd, chainOption...) + c, err := NewChainWithHomeFlags(cmd, chainOption...) if err != nil { return err } diff --git a/ignite/cmd/chain_init.go b/ignite/cmd/chain_init.go index 3c5a4377e6..c542093816 100644 --- a/ignite/cmd/chain_init.go +++ b/ignite/cmd/chain_init.go @@ -105,7 +105,7 @@ func chainInitHandler(cmd *cobra.Command, _ []string) error { chainOption = append(chainOption, chain.CheckDependencies()) } - c, err := newChainWithHomeFlags(cmd, chainOption...) + c, err := NewChainWithHomeFlags(cmd, chainOption...) if err != nil { return err } diff --git a/ignite/cmd/chain_serve.go b/ignite/cmd/chain_serve.go index c3134147c8..dc6be8e3e6 100644 --- a/ignite/cmd/chain_serve.go +++ b/ignite/cmd/chain_serve.go @@ -100,7 +100,7 @@ func chainServeHandler(cmd *cobra.Command, args []string) error { } // create the chain - c, err := newChainWithHomeFlags(cmd, chainOption...) + c, err := NewChainWithHomeFlags(cmd, chainOption...) if err != nil { return err } diff --git a/ignite/cmd/cmd.go b/ignite/cmd/cmd.go index 0de9eebe74..3423a5270b 100644 --- a/ignite/cmd/cmd.go +++ b/ignite/cmd/cmd.go @@ -79,6 +79,7 @@ ignite scaffold chain github.com/username/mars`, c.AddCommand(NewTools()) c.AddCommand(NewDocs()) c.AddCommand(NewVersion()) + c.AddCommand(NewPlugin()) c.AddCommand(deprecated()...) return c @@ -177,7 +178,7 @@ func flagGetClearCache(cmd *cobra.Command) bool { return clearCache } -func newChainWithHomeFlags(cmd *cobra.Command, chainOption ...chain.Option) (*chain.Chain, error) { +func NewChainWithHomeFlags(cmd *cobra.Command, chainOption ...chain.Option) (*chain.Chain, error) { // Check if custom home is provided if home := getHome(cmd); home != "" { chainOption = append(chainOption, chain.HomePath(home)) diff --git a/ignite/cmd/generate_dart.go b/ignite/cmd/generate_dart.go index 3f2e07d94d..186021351f 100644 --- a/ignite/cmd/generate_dart.go +++ b/ignite/cmd/generate_dart.go @@ -25,7 +25,7 @@ func generateDartHandler(cmd *cobra.Command, args []string) error { session := cliui.New(cliui.StartSpinnerWithText(statusGenerating)) defer session.End() - c, err := newChainWithHomeFlags( + c, err := NewChainWithHomeFlags( cmd, chain.EnableThirdPartyModuleCodegen(), chain.WithOutputer(session), diff --git a/ignite/cmd/generate_go.go b/ignite/cmd/generate_go.go index 097ca45b4f..7aae84f9eb 100644 --- a/ignite/cmd/generate_go.go +++ b/ignite/cmd/generate_go.go @@ -25,7 +25,7 @@ func generateGoHandler(cmd *cobra.Command, args []string) error { session := cliui.New(cliui.StartSpinnerWithText(statusGenerating)) defer session.End() - c, err := newChainWithHomeFlags( + c, err := NewChainWithHomeFlags( cmd, chain.WithOutputer(session), chain.CollectEvents(session.EventBus()), diff --git a/ignite/cmd/generate_openapi.go b/ignite/cmd/generate_openapi.go index ecfa3e677e..225b50318b 100644 --- a/ignite/cmd/generate_openapi.go +++ b/ignite/cmd/generate_openapi.go @@ -25,7 +25,7 @@ func generateOpenAPIHandler(cmd *cobra.Command, args []string) error { session := cliui.New(cliui.StartSpinnerWithText(statusGenerating)) defer session.End() - c, err := newChainWithHomeFlags( + c, err := NewChainWithHomeFlags( cmd, chain.WithOutputer(session), chain.CollectEvents(session.EventBus()), diff --git a/ignite/cmd/generate_typescript_client.go b/ignite/cmd/generate_typescript_client.go index 371beabecc..7f3206e5ee 100644 --- a/ignite/cmd/generate_typescript_client.go +++ b/ignite/cmd/generate_typescript_client.go @@ -26,7 +26,7 @@ func generateTSClientHandler(cmd *cobra.Command, args []string) error { session := cliui.New(cliui.StartSpinnerWithText(statusGenerating)) defer session.End() - c, err := newChainWithHomeFlags( + c, err := NewChainWithHomeFlags( cmd, chain.EnableThirdPartyModuleCodegen(), chain.WithOutputer(session), diff --git a/ignite/cmd/generate_vuex.go b/ignite/cmd/generate_vuex.go index 9804a90b18..ec1bec259b 100644 --- a/ignite/cmd/generate_vuex.go +++ b/ignite/cmd/generate_vuex.go @@ -26,7 +26,7 @@ func generateVuexHandler(cmd *cobra.Command, args []string) error { session := cliui.New(cliui.StartSpinnerWithText(statusGenerating)) defer session.End() - c, err := newChainWithHomeFlags( + c, err := NewChainWithHomeFlags( cmd, chain.EnableThirdPartyModuleCodegen(), chain.WithOutputer(session), diff --git a/ignite/cmd/ignite/main.go b/ignite/cmd/ignite/main.go index 2e1df4e361..99c5312e21 100644 --- a/ignite/cmd/ignite/main.go +++ b/ignite/cmd/ignite/main.go @@ -12,13 +12,30 @@ import ( ) func main() { + os.Exit(run()) +} + +func run() int { + const ( + exitCodeOK = 0 + exitCodeError = 1 + ) ctx := clictx.From(context.Background()) - err := ignitecmd.New().ExecuteContext(ctx) + cmd := ignitecmd.New() + + // Load plugins if any + if err := ignitecmd.LoadPlugins(ctx, cmd); err != nil { + fmt.Printf("Error while loading chain's plugins: %v\n", err) + return exitCodeError + } + defer ignitecmd.UnloadPlugins() + + err := cmd.ExecuteContext(ctx) if ctx.Err() == context.Canceled || err == context.Canceled { fmt.Println("aborted") - return + return exitCodeOK } if err != nil { @@ -29,7 +46,7 @@ func main() { } else { fmt.Println(err) } - - os.Exit(1) + return exitCodeError } + return exitCodeOK } diff --git a/ignite/cmd/plugin.go b/ignite/cmd/plugin.go new file mode 100644 index 0000000000..81103c0bc6 --- /dev/null +++ b/ignite/cmd/plugin.go @@ -0,0 +1,248 @@ +package ignitecmd + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/ignite/cli/ignite/pkg/cliui/entrywriter" + "github.com/ignite/cli/ignite/pkg/xgit" + "github.com/ignite/cli/ignite/services/plugin" +) + +// 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 configuration. +// If no configuration found, it returns w/o error. +func LoadPlugins(ctx context.Context, rootCmd *cobra.Command) error { + // NOTE(tb) Not sure if it's the right place to load this. + chain, err := NewChainWithHomeFlags(rootCmd) + if err != nil { + // Binary is run outside of an chain app, plugins can't be loaded + return nil + } + plugins, err = plugin.Load(ctx, chain) + if err != nil { + return err + } + // Link plugins to related commands + var loadErrors []string + for _, p := range plugins { + linkPluginCmds(rootCmd, p) + if p.Error != nil { + loadErrors = append(loadErrors, p.Path) + } + } + if len(loadErrors) > 0 { + // unload any plugin that could have been loaded + UnloadPlugins() + printPlugins() + return errors.Errorf("fail to load: %v", strings.Join(loadErrors, ",")) + } + return nil +} + +// UnloadPlugins releases any loaded plugins, which is basically killing the +// plugin server instance. +func UnloadPlugins() { + for _, p := range plugins { + p.KillClient() + } +} + +// linkPluginCmds tries to add the plugin commands to the legacy ignite +// commands. +func linkPluginCmds(rootCmd *cobra.Command, p *plugin.Plugin) { + if p.Error != nil { + return + } + for _, pluginCmd := range p.Interface.Commands() { + linkPluginCmd(rootCmd, p, pluginCmd) + if p.Error != nil { + return + } + } +} + +func linkPluginCmd(rootCmd *cobra.Command, p *plugin.Plugin, pluginCmd plugin.Command) { + cmdPath := pluginCmd.PlaceCommandUnder + if !strings.HasPrefix(cmdPath, "ignite") { + // cmdPath must start with `ignite ` before comparison with + // cmd.CommandPath() + cmdPath = "ignite " + cmdPath + } + cmdPath = strings.TrimSpace(cmdPath) + + 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 + } + for _, cmd := range cmd.Commands() { + if cmd.Name() == pluginCmd.Use { + p.Error = errors.Errorf("plugin command %q already exists in ignite's commands", pluginCmd.Use) + return + } + } + newCmd := &cobra.Command{ + Use: pluginCmd.Use, + Short: pluginCmd.Short, + Long: pluginCmd.Long, + } + 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 { + // Pass config parameters + pluginCmd.With = p.With + // Pass cobra cmd + pluginCmd.CobraCmd = cmd + // Call the plugin Execute + err := p.Interface.Execute(pluginCmd, args) + // 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(NewPluginScaffold()) + return c +} + +func NewPluginList() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List declared plugins and status", + RunE: func(cmd *cobra.Command, args []string) error { + printPlugins() + return nil + }, + } +} + +func NewPluginUpdate() *cobra.Command { + return &cobra.Command{ + Use: "update [path]", + Short: "Update plugins", + 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 NewPluginScaffold() *cobra.Command { + return &cobra.Command{ + Use: "scaffold [github.com/org/repo]", + Short: "Scaffold a new plugin", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + wd, err := os.Getwd() + if err != nil { + return err + } + moduleName := args[0] + path, err := plugin.Scaffold(wd, moduleName) + if err != nil { + return err + } + if err := xgit.InitAndCommit(path); err != nil { + return err + } + + message := ` +⭐️ Successfully created a new plugin '%[1]s'. +👉 update plugin code at '%[2]s/main.go' + +👉 test plugin integration by adding the following lines in a chain config.yaml: +plugins: +- path: %[2]s + +👉 once the plugin is pushed to a repository, the config becomes: +plugins: +- path: %[1]s +` + fmt.Printf(message, moduleName, path) + return nil + }, + } +} + +func printPlugins() { + if len(plugins) == 0 { + fmt.Println("No plugin found") + return + } + var entries [][]string + for _, p := range plugins { + status := "✅ Loaded" + if p.Error != nil { + status = fmt.Sprintf("❌ Error: %v", p.Error) + } + entries = append(entries, []string{p.Path, status}) + } + entrywriter.MustWrite(os.Stdout, []string{"path", "status"}, entries...) +} diff --git a/ignite/cmd/plugin_test.go b/ignite/cmd/plugin_test.go new file mode 100644 index 0000000000..be069e8111 --- /dev/null +++ b/ignite/cmd/plugin_test.go @@ -0,0 +1,257 @@ +package ignitecmd + +import ( + "fmt" + "io" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ignite/cli/ignite/chainconfig" + "github.com/ignite/cli/ignite/services/plugin" +) + +// pluginInterface implements plugin.Interface for testing purpose. +type pluginInterface struct { + commands []plugin.Command +} + +func (p pluginInterface) Commands() []plugin.Command { + return p.commands +} + +func (pluginInterface) Execute(plugin.Command, []string) error { + return nil +} + +func TestLinkPluginCmds(t *testing.T) { + buildRootCmd := func() *cobra.Command { + var ( + rootCmd = &cobra.Command{ + Use: "ignite", + } + scaffoldCmd = &cobra.Command{ + Use: "scaffold", + } + scaffoldChainCmd = &cobra.Command{ + Use: "chain", + Run: func(*cobra.Command, []string) {}, + } + ) + scaffoldCmd.AddCommand(scaffoldChainCmd) + rootCmd.AddCommand(scaffoldCmd) + return rootCmd + } + tests := []struct { + name string + pluginInterface pluginInterface + expectedDumpCmd string + expectedError string + }{ + { + name: "ok: link foo at root", + pluginInterface: pluginInterface{ + commands: []plugin.Command{ + { + Use: "foo", + }, + }, + }, + expectedDumpCmd: ` +ignite + foo* + scaffold + chain* +`, + }, + { + name: "ok: link foo at subcommand", + pluginInterface: pluginInterface{ + commands: []plugin.Command{ + { + Use: "foo", + PlaceCommandUnder: "ignite scaffold", + }, + }, + }, + expectedDumpCmd: ` +ignite + scaffold + chain* + foo* +`, + }, + { + name: "ok: link foo at subcommand with incomplete PlaceCommandUnder", + pluginInterface: pluginInterface{ + commands: []plugin.Command{ + { + Use: "foo", + PlaceCommandUnder: "scaffold", + }, + }, + }, + expectedDumpCmd: ` +ignite + scaffold + chain* + foo* +`, + }, + { + name: "fail: link to runnable command", + pluginInterface: pluginInterface{ + commands: []plugin.Command{ + { + Use: "foo", + PlaceCommandUnder: "ignite scaffold chain", + }, + }, + }, + expectedError: `can't attach plugin command "foo" to runnable command "ignite scaffold chain"`, + }, + { + name: "fail: link to unknown command", + pluginInterface: pluginInterface{ + commands: []plugin.Command{ + { + Use: "foo", + PlaceCommandUnder: "ignite unknown", + }, + }, + }, + expectedError: `unable to find commandPath "ignite unknown" for plugin "foo"`, + }, + { + name: "fail: plugin name exists in legacy commands", + pluginInterface: pluginInterface{ + commands: []plugin.Command{ + { + Use: "scaffold", + }, + }, + }, + expectedError: `plugin command "scaffold" already exists in ignite's commands`, + }, + { + name: "fail: plugin name exists in legacy sub commands", + pluginInterface: pluginInterface{ + commands: []plugin.Command{ + { + Use: "chain", + PlaceCommandUnder: "scaffold", + }, + }, + }, + expectedError: `plugin command "chain" already exists in ignite's commands`, + }, + { + name: "ok: link foo and bar at root", + pluginInterface: pluginInterface{ + commands: []plugin.Command{ + { + Use: "foo", + }, + { + Use: "bar", + }, + }, + }, + expectedDumpCmd: ` +ignite + bar* + foo* + scaffold + chain* +`, + }, + { + name: "ok: link with subcommands", + pluginInterface: pluginInterface{ + commands: []plugin.Command{ + { + Use: "foo", + Commands: []plugin.Command{ + {Use: "bar"}, + {Use: "baz"}, + }, + }, + }, + }, + expectedDumpCmd: ` +ignite + foo + bar* + baz* + scaffold + chain* +`, + }, + { + name: "ok: link with multiple subcommands", + pluginInterface: pluginInterface{ + commands: []plugin.Command{ + { + Use: "foo", + Commands: []plugin.Command{ + {Use: "bar", Commands: []plugin.Command{{Use: "baz"}}}, + {Use: "qux", Commands: []plugin.Command{{Use: "quux"}, {Use: "corge"}}}, + }, + }, + }, + }, + expectedDumpCmd: ` +ignite + foo + bar + baz* + qux + corge* + quux* + scaffold + chain* +`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + p := &plugin.Plugin{ + Plugin: chainconfig.Plugin{Path: "foo"}, + Interface: tt.pluginInterface, + } + rootCmd := buildRootCmd() + + linkPluginCmds(rootCmd, p) + + if tt.expectedError != "" { + require.Error(p.Error) + require.EqualError(p.Error, tt.expectedError) + return + } + require.NoError(p.Error) + var s strings.Builder + s.WriteString("\n") + dumpCmd(rootCmd, &s, 0) + assert.Equal(tt.expectedDumpCmd, s.String()) + }) + } +} + +// dumpCmd helps in comparing cobra.Command by writing their Use and Commands. +// Runnable commands are marked with a *. +func dumpCmd(c *cobra.Command, w io.Writer, ntabs int) { + fmt.Fprintf(w, "%s%s", strings.Repeat(" ", ntabs), c.Use) + ntabs++ + if c.Runnable() { + fmt.Fprintf(w, "*") + } + fmt.Fprintf(w, "\n") + for _, cc := range c.Commands() { + dumpCmd(cc, w, ntabs) + } +} diff --git a/ignite/pkg/xgit/xgit.go b/ignite/pkg/xgit/xgit.go index b469b63a50..c9e549b00f 100644 --- a/ignite/pkg/xgit/xgit.go +++ b/ignite/pkg/xgit/xgit.go @@ -2,10 +2,41 @@ package xgit import ( "path/filepath" + "time" "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/pkg/errors" ) +var ( + commitMsg = "Initialized with Ignite CLI" + devXAuthor = &object.Signature{ + Name: "Developer Experience team at Tendermint", + Email: "hello@tendermint.com", + When: time.Now(), + } +) + +func InitAndCommit(path string) error { + repo, err := git.PlainInit(path, false) + if err != nil { + return errors.WithStack(err) + } + wt, err := repo.Worktree() + if err != nil { + return errors.WithStack(err) + } + if _, err := wt.Add("."); err != nil { + return errors.WithStack(err) + } + _, err = wt.Commit(commitMsg, &git.CommitOptions{ + All: true, + Author: devXAuthor, + }) + return errors.WithStack(err) +} + func AreChangesCommitted(appPath string) (bool, error) { appPath, err := filepath.Abs(appPath) if err != nil { diff --git a/ignite/services/plugin/interface.go b/ignite/services/plugin/interface.go new file mode 100644 index 0000000000..77232fdf1a --- /dev/null +++ b/ignite/services/plugin/interface.go @@ -0,0 +1,128 @@ +package plugin + +import ( + "encoding/gob" + "log" + "net/rpc" + + "github.com/hashicorp/go-plugin" + "github.com/spf13/cobra" +) + +func init() { + gob.Register(Command{}) +} + +// An ignite plugin must implements the Plugin interface. +type Interface interface { + // Commands returns one or multiple commands that will be added to the list + // of ignite commands. It's invoked each time ignite is executed, in + // order to display the list of available commands. + // Each commands are independent, for nested commands, use the field + // Command.Commands. + Commands() []Command + // Execute will be invoked by ignite when a plugin commands is executed. + // cmd is the executed command (one of the those returned by Commands method) + // args is the command line arguments passed behing the command. + Execute(cmd Command, args []string) error +} + +// Command represents a plugin command. +type Command struct { + // Same as cobra.Command.Use + Use string + // Same as cobra.Command.Short + Short string + // Same as cobra.Command.Long + Long string + // PlaceCommandUnder indicates where the command should be placed. + // For instance `ignite scaffold` will place the command at the + // `scaffold` command. + // An empty value is interpreted as `ignite` (==root). + PlaceCommandUnder string + // List of sub commands + Commands []Command + + // The following fields are populated at runtime + CobraCmd *cobra.Command + // Optionnal parameters populated by config at runtime via + // chainconfig.Plugin.With field. + With map[string]string +} + +// handshakeConfigs are used to just do a basic handshake between +// a plugin and host. If the handshake fails, a user friendly error is shown. +// This prevents users from executing bad plugins or executing a plugin +// directory. It is a UX feature, not a security feature. +var handshakeConfig = plugin.HandshakeConfig{ + ProtocolVersion: 1, + MagicCookieKey: "BASIC_PLUGIN", + MagicCookieValue: "hello", +} + +func HandshakeConfig() plugin.HandshakeConfig { + return handshakeConfig +} + +// Here is an implementation that talks over RPC +type InterfaceRPC struct{ client *rpc.Client } + +// Commands implements Interface.Commands +func (g *InterfaceRPC) Commands() []Command { + var resp []Command + err := g.client.Call("Plugin.Commands", new(interface{}), &resp) + if err != nil { + // You usually want your interfaces to return errors. If they don't, + // there isn't much other choice here. + log.Fatalf("error while calling plugin %v", err) + } + return resp +} + +// Execute implements Interface.Commands +func (g *InterfaceRPC) Execute(c Command, args []string) error { + var resp interface{} + return g.client.Call("Plugin.Execute", map[string]interface{}{ + "command": c, + "args": args, + }, &resp) +} + +// Here is the RPC server that InterfaceRPC talks to, conforming to +// the requirements of net/rpc +type InterfaceRPCServer struct { + // This is the real implementation + Impl Interface +} + +func (s *InterfaceRPCServer) Commands(args interface{}, resp *[]Command) error { + *resp = s.Impl.Commands() + return nil +} + +func (s *InterfaceRPCServer) Execute(args map[string]interface{}, resp *interface{}) error { + return s.Impl.Execute(args["command"].(Command), args["args"].([]string)) +} + +// This is the implementation of plugin.Interface so we can serve/consume this +// +// This has two methods: Server must return an RPC server for this plugin +// type. We construct a InterfaceRPCServer for this. +// +// Client must return an implementation of our interface that communicates +// over an RPC client. We return InterfaceRPC for this. +// +// Ignore MuxBroker. That is used to create more multiplexed streams on our +// plugin connection and is a more advanced use case. +type InterfacePlugin struct { + // Impl Injection + Impl Interface +} + +func (p *InterfacePlugin) Server(*plugin.MuxBroker) (interface{}, error) { + return &InterfaceRPCServer{Impl: p.Impl}, nil +} + +func (InterfacePlugin) Client(b *plugin.MuxBroker, c *rpc.Client) (interface{}, error) { + return &InterfaceRPC{client: c}, nil +} diff --git a/ignite/services/plugin/plugin.go b/ignite/services/plugin/plugin.go new file mode 100644 index 0000000000..65deb8ff90 --- /dev/null +++ b/ignite/services/plugin/plugin.go @@ -0,0 +1,328 @@ +// Package plugin implements ignite plugin management. +// A ignite plugin is a binary which communicates with the ignite binary +// via RPC thanks to the github.com/hashicorp/go-plugin library. +package plugin + +import ( + "context" + "fmt" + "io/fs" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/hashicorp/go-hclog" + hplugin "github.com/hashicorp/go-plugin" + "github.com/pkg/errors" + + "github.com/ignite/cli/ignite/chainconfig" + "github.com/ignite/cli/ignite/pkg/cliui/clispinner" + "github.com/ignite/cli/ignite/pkg/gocmd" + "github.com/ignite/cli/ignite/pkg/xfilepath" + "github.com/ignite/cli/ignite/services/chain" +) + +// pluginsPath holds the plugin cache directory. +var pluginsPath = xfilepath.Join( + chainconfig.ConfigDirPath, + xfilepath.Path("plugins"), +) + +// Plugin represents a ignite plugin. +type Plugin struct { + // Embed the plugin configuration + chainconfig.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 + + client *hplugin.Client +} + +// 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, c *chain.Chain) ([]*Plugin, error) { + conf, err := c.Config() + if err != nil { + return nil, errors.WithStack(err) + } + pluginsDir, err := pluginsPath() + if err != nil { + return nil, errors.WithStack(err) + } + var plugins []*Plugin + for _, cp := range conf.Plugins { + p := newPlugin(pluginsDir, cp) + p.load(ctx) + plugins = append(plugins, p) + } + return plugins, 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 chainconfig.Plugin) *Plugin { + var ( + p = &Plugin{Plugin: cp} + pluginPath = cp.Path + ) + if pluginPath == "" { + p.Error = errors.Errorf(`missing plugin property "path"`) + return 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]...) + p.cloneURL = "https://" + p.repoPath + if len(p.reference) > 0 { + p.repoPath += "@" + p.reference + } + p.cloneDir = path.Join(pluginsDir, p.repoPath) + p.srcPath = path.Join(pluginsDir, p.repoPath, path.Join(parts[3:]...)) + p.binaryName = path.Base(pluginPath) + return p +} + +func (p *Plugin) KillClient() { + if p.client != nil { + p.client.Kill() + } +} + +func (p *Plugin) isLocal() bool { + return p.cloneURL == "" +} + +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.isLocal() { + // 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.binaryName: &InterfacePlugin{}, + } + // Create an hclog.Logger + logger := hclog.New(&hclog.LoggerOptions{ + Name: fmt.Sprintf("plugin %s", p.Path), + Output: os.Stderr, + Level: hclog.Error, + }) + // 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) +} + +// fetch clones the plugin repository at the expected reference. +func (p *Plugin) fetch() { + if p.isLocal() { + return + } + if p.Error != nil { + return + } + defer clispinner.New().SetText(fmt.Sprintf("Fetching plugin %q...", p.cloneURL)).Stop() + + var err error + if p.reference == "" { + // No reference provided, just clone + _, err = git.PlainClone(p.cloneDir, false, &git.CloneOptions{ + URL: p.cloneURL, + }) + } else { + // Reference provided, clone using tag or branch reference, one of the two + // should work. SHA-1 aren't supported. + for _, ref := range []plumbing.ReferenceName{ + plumbing.NewTagReferenceName(p.reference), + plumbing.NewBranchReferenceName(p.reference), + } { + _, err = git.PlainClone(p.cloneDir, false, &git.CloneOptions{ + URL: p.cloneURL, + ReferenceName: ref, + // Try to limit number of commits but this option doesn't seem to work well + Depth: 1, + }) + if err == nil { + break + } + } + } + if err != nil { + p.Error = errors.Wrapf(err, "cloning %q", p.cloneURL) + } +} + +// build compiles the plugin binary. +func (p *Plugin) build(ctx context.Context) { + if p.Error != nil { + return + } + defer clispinner.New().SetText(fmt.Sprintf("Building plugin %q...", p.Path)).Stop() + + if err := gocmd.ModTidy(ctx, p.srcPath); err != nil { + p.Error = errors.Wrapf(err, "go mod tidy") + return + } + if err := gocmd.BuildAll(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.isLocal() { + // 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 plugin source path %q\n", p.srcPath) + return false + } + return mostRecent.After(binaryTime) +} diff --git a/ignite/services/plugin/plugin_test.go b/ignite/services/plugin/plugin_test.go new file mode 100644 index 0000000000..bc1da6b1de --- /dev/null +++ b/ignite/services/plugin/plugin_test.go @@ -0,0 +1,370 @@ +package plugin + +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/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ignite/cli/ignite/chainconfig" +) + +func TestNewPlugin(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + + tests := []struct { + name string + pluginCfg chainconfig.Plugin + expectedPlugin Plugin + }{ + { + name: "fail: empty path", + expectedPlugin: Plugin{ + Error: errors.Errorf(`missing plugin property "path"`), + }, + }, + { + name: "fail: local plugin doesnt exists", + pluginCfg: chainconfig.Plugin{Path: "/xxx/yyy/plugin"}, + expectedPlugin: Plugin{ + Error: errors.Errorf(`local plugin path "/xxx/yyy/plugin" not found`), + }, + }, + { + name: "fail: local plugin is not a dir", + pluginCfg: chainconfig.Plugin{Path: path.Join(wd, "testdata/fakebin")}, + expectedPlugin: Plugin{ + Error: errors.Errorf(fmt.Sprintf("local plugin path %q is not a dir", path.Join(wd, "testdata/fakebin"))), + }, + }, + { + name: "ok: local plugin", + pluginCfg: chainconfig.Plugin{Path: path.Join(wd, "testdata")}, + expectedPlugin: Plugin{ + srcPath: path.Join(wd, "testdata"), + binaryName: "testdata", + }, + }, + { + name: "fail: remote plugin with only domain", + pluginCfg: chainconfig.Plugin{Path: "github.com"}, + expectedPlugin: Plugin{ + Error: errors.Errorf(`plugin path "github.com" is not a valid repository URL`), + }, + }, + { + name: "fail: remote plugin with incomplete URL", + pluginCfg: chainconfig.Plugin{Path: "github.com/ignite"}, + expectedPlugin: Plugin{ + Error: errors.Errorf(`plugin path "github.com/ignite" is not a valid repository URL`), + }, + }, + { + name: "ok: remote plugin", + pluginCfg: chainconfig.Plugin{Path: "github.com/ignite/plugin"}, + expectedPlugin: Plugin{ + repoPath: "github.com/ignite/plugin", + cloneURL: "https://github.com/ignite/plugin", + cloneDir: ".ignite/plugins/github.com/ignite/plugin", + reference: "", + srcPath: ".ignite/plugins/github.com/ignite/plugin", + binaryName: "plugin", + }, + }, + { + name: "ok: remote plugin with @ref", + pluginCfg: chainconfig.Plugin{Path: "github.com/ignite/plugin@develop"}, + expectedPlugin: Plugin{ + repoPath: "github.com/ignite/plugin@develop", + cloneURL: "https://github.com/ignite/plugin", + cloneDir: ".ignite/plugins/github.com/ignite/plugin@develop", + reference: "develop", + srcPath: ".ignite/plugins/github.com/ignite/plugin@develop", + binaryName: "plugin", + }, + }, + { + name: "ok: remote plugin with subpath", + pluginCfg: chainconfig.Plugin{Path: "github.com/ignite/plugin/plugin1"}, + expectedPlugin: Plugin{ + repoPath: "github.com/ignite/plugin", + cloneURL: "https://github.com/ignite/plugin", + cloneDir: ".ignite/plugins/github.com/ignite/plugin", + reference: "", + srcPath: ".ignite/plugins/github.com/ignite/plugin/plugin1", + binaryName: "plugin1", + }, + }, + { + name: "ok: remote plugin with subpath and @ref", + pluginCfg: chainconfig.Plugin{Path: "github.com/ignite/plugin/plugin1@develop"}, + expectedPlugin: Plugin{ + repoPath: "github.com/ignite/plugin@develop", + cloneURL: "https://github.com/ignite/plugin", + cloneDir: ".ignite/plugins/github.com/ignite/plugin@develop", + reference: "develop", + srcPath: ".ignite/plugins/github.com/ignite/plugin@develop/plugin1", + binaryName: "plugin1", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.expectedPlugin.Plugin = tt.pluginCfg + + p := newPlugin(".ignite/plugins", tt.pluginCfg) + + assertPlugin(t, tt.expectedPlugin, *p) + }) + } +} + +func TestPluginLoad(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + + // Use a common temp dir for all the cases to facilitate cleaning. + tmpDir := path.Join(os.TempDir(), "ignite_"+t.Name()) + err = os.MkdirAll(tmpDir, 0o700) + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + mkdirTmp := func(t *testing.T, dir string) string { + tmp, err := os.MkdirTemp(tmpDir, dir) + require.NoError(t, err) + return tmp + } + + // Helper to make a local git repository with gofile committed. + // Returns the repo directory and the git.Repository + makeGitRepo := func(t *testing.T, name string) (string, *git.Repository) { + require := require.New(t) + repoDir := mkdirTmp(t, "plugin_repo") + path, err := Scaffold(repoDir, "github.com/ignite/"+name) + require.NoError(err) + require.DirExists(path) + repo, err := git.PlainInit(repoDir, false) + require.NoError(err) + w, err := repo.Worktree() + require.NoError(err) + _, err = w.Add(".") + require.NoError(err) + _, err = w.Commit("msg", &git.CommitOptions{ + Author: &object.Signature{ + Name: "bob", + Email: "bob@example.com", + When: time.Now(), + }, + }) + require.NoError(err) + return repoDir, repo + } + + tests := []struct { + name string + buildPlugin func(t *testing.T) Plugin + expectedError string + }{ + { + name: "fail: plugin is already in error", + buildPlugin: func(t *testing.T) Plugin { + return Plugin{ + Error: errors.New("oups"), + } + }, + expectedError: `oups`, + }, + { + name: "fail: no go files in srcPath", + buildPlugin: func(t *testing.T) Plugin { + return Plugin{ + srcPath: path.Join(wd, "testdata"), + binaryName: "testdata", + } + }, + expectedError: `no packages to build`, + }, + { + name: "ok: from local", + buildPlugin: func(t *testing.T) Plugin { + repoDir := mkdirTmp(t, "plugin_local") + path, err := Scaffold(repoDir, "github.com/foo/bar") + require.NoError(t, err) + require.DirExists(t, path) + return Plugin{ + srcPath: path, + binaryName: "bar", + } + }, + }, + { + name: "ok: from git repo", + buildPlugin: func(t *testing.T) Plugin { + repoDir, _ := makeGitRepo(t, "remote") + cloneDir := mkdirTmp(t, "clone_dir") + + return Plugin{ + cloneURL: repoDir, + cloneDir: cloneDir, + srcPath: path.Join(cloneDir, "remote"), + binaryName: "remote", + } + }, + }, + { + name: "fail: git repo doesnt exists", + buildPlugin: func(t *testing.T) Plugin { + cloneDir := mkdirTmp(t, "clone_dir") + + return Plugin{ + cloneURL: "/xxxx/yyyy", + cloneDir: cloneDir, + srcPath: path.Join(cloneDir, "plugin"), + } + }, + expectedError: `cloning "/xxxx/yyyy": repository not found`, + }, + { + name: "ok: from git repo with tag", + buildPlugin: func(t *testing.T) Plugin { + repoDir, repo := makeGitRepo(t, "remote-tag") + h, err := repo.Head() + require.NoError(t, err) + _, err = repo.CreateTag("v1", h.Hash(), &git.CreateTagOptions{ + Tagger: &object.Signature{Name: "me"}, + Message: "v1", + }) + require.NoError(t, err) + + cloneDir := mkdirTmp(t, "clone_dir") + + return Plugin{ + cloneURL: repoDir, + reference: "v1", + cloneDir: cloneDir, + srcPath: path.Join(cloneDir, "remote-tag"), + binaryName: "remote-tag", + } + }, + }, + { + name: "ok: from git repo with branch", + buildPlugin: func(t *testing.T) Plugin { + repoDir, repo := makeGitRepo(t, "remote-branch") + w, err := repo.Worktree() + require.NoError(t, err) + err = w.Checkout(&git.CheckoutOptions{ + Branch: plumbing.NewBranchReferenceName("branch1"), + Create: true, + }) + require.NoError(t, err) + + cloneDir := mkdirTmp(t, "clone_dir") + + return Plugin{ + cloneURL: repoDir, + reference: "branch1", + cloneDir: cloneDir, + srcPath: path.Join(cloneDir, "remote-branch"), + binaryName: "remote-branch", + } + }, + }, + { + name: "fail: git ref not found", + buildPlugin: func(t *testing.T) Plugin { + repoDir, _ := makeGitRepo(t, "remote-no-ref") + + cloneDir := mkdirTmp(t, "clone_dir") + + return Plugin{ + cloneURL: repoDir, + reference: "doesnt_exists", + cloneDir: cloneDir, + srcPath: path.Join(cloneDir, "remote-no-ref"), + binaryName: "remote-no-ref", + } + }, + expectedError: `cloning ".*": reference not found`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := tt.buildPlugin(t) + + p.load(context.Background()) + + if tt.expectedError != "" { + require.Error(t, p.Error, "expected error %q", tt.expectedError) + require.Regexp(t, tt.expectedError, p.Error.Error()) + return + } + require.NoError(t, p.Error) + require.NotNil(t, p.Interface) + assert.Equal(t, p.binaryName, p.Interface.Commands()[0].Use) + }) + } +} + +func TestPluginClean(t *testing.T) { + tests := []struct { + name string + plugin *Plugin + expectRemove bool + }{ + { + name: "dont clean local plugin", + plugin: &Plugin{ + Plugin: chainconfig.Plugin{Path: "/local"}, + }, + }, + { + name: "dont clean plugin with errors", + plugin: &Plugin{Error: errors.New("oups")}, + }, + { + name: "ok", + plugin: &Plugin{ + cloneURL: "https://github.com/ignite/plugin", + }, + expectRemove: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmp, err := os.MkdirTemp("", "cloneDir") + require.NoError(t, err) + tt.plugin.cloneDir = tmp + + err = tt.plugin.clean() + + require.NoError(t, err) + if tt.expectRemove { + _, err := os.Stat(tmp) + assert.True(t, os.IsNotExist(err), "cloneDir not removed") + } + }) + } +} + +func assertPlugin(t *testing.T, want, have Plugin) { + if want.Error != nil { + require.Error(t, have.Error) + assert.Regexp(t, want.Error.Error(), have.Error.Error()) + } else { + require.NoError(t, have.Error) + } + // Errors aren't comparable with assert.Equal, because of the different stacks + want.Error = nil + have.Error = nil + assert.Equal(t, want, have) +} diff --git a/ignite/services/plugin/scaffold.go b/ignite/services/plugin/scaffold.go new file mode 100644 index 0000000000..dada9509d1 --- /dev/null +++ b/ignite/services/plugin/scaffold.go @@ -0,0 +1,62 @@ +package plugin + +import ( + "context" + "embed" + "os" + "path" + "path/filepath" + + "github.com/gobuffalo/genny" + "github.com/gobuffalo/plush/v4" + "github.com/pkg/errors" + + "github.com/ignite/cli/ignite/pkg/cmdrunner/exec" + "github.com/ignite/cli/ignite/pkg/cmdrunner/step" + "github.com/ignite/cli/ignite/pkg/gocmd" + "github.com/ignite/cli/ignite/pkg/xgenny" +) + +//go:embed template/* +var fsPluginSource embed.FS + +// Scaffold generates a plugin structure under dir/path.Base(moduleName). +func Scaffold(dir, moduleName string) (string, error) { + var ( + name = filepath.Base(moduleName) + finalDir = path.Join(dir, name) + g = genny.New() + template = xgenny.NewEmbedWalker( + fsPluginSource, + "template", + finalDir, + ) + ) + if _, err := os.Stat(finalDir); err == nil { + // finalDir already exists, don't overwrite stuff + return "", errors.Errorf("dir %q already exists, abort scaffolding", finalDir) + } + if err := g.Box(template); err != nil { + return "", errors.WithStack(err) + } + ctx := plush.NewContext() + ctx.Set("ModuleName", moduleName) + ctx.Set("Name", name) + g.Transformer(xgenny.Transformer(ctx)) + r := genny.WetRunner(ctx) + err := r.With(g) + if err != nil { + return "", errors.WithStack(err) + } + if err := r.Run(); err != nil { + return "", errors.WithStack(err) + } + // FIXME(tb) we need to disable sumdb to get the branch version of CLI + // Don't understand why... + // Related discussion: https://allinbits.slack.com/archives/C02NJAQN82Y/p1665403790764919 + opt := exec.StepOption(step.Env("GOSUMDB=off")) + if err := gocmd.ModTidy(context.TODO(), finalDir, opt); err != nil { + return "", errors.WithStack(err) + } + return finalDir, nil +} diff --git a/ignite/services/plugin/scaffold_test.go b/ignite/services/plugin/scaffold_test.go new file mode 100644 index 0000000000..b7a6668c14 --- /dev/null +++ b/ignite/services/plugin/scaffold_test.go @@ -0,0 +1,16 @@ +package plugin + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestScaffold(t *testing.T) { + tmp := t.TempDir() + + path, err := Scaffold(tmp, "github.com/foo/bar") + + require.NoError(t, err) + require.DirExists(t, path) +} diff --git a/ignite/services/plugin/template/.gitignore.plush b/ignite/services/plugin/template/.gitignore.plush new file mode 100644 index 0000000000..3c921bddc4 --- /dev/null +++ b/ignite/services/plugin/template/.gitignore.plush @@ -0,0 +1 @@ +<%= Name %> diff --git a/ignite/services/plugin/template/go.mod.plush b/ignite/services/plugin/template/go.mod.plush new file mode 100644 index 0000000000..bfc70e9880 --- /dev/null +++ b/ignite/services/plugin/template/go.mod.plush @@ -0,0 +1,10 @@ +module <%= ModuleName %> + +go 1.18 + +require ( + github.com/hashicorp/go-plugin v1.4.4 + github.com/ignite/cli v0.24.1-0.20221003163225-8612da9c458a +) + +replace github.com/gogo/protobuf => github.com/regen-network/protobuf v1.3.3-alpha.regen.1 diff --git a/ignite/services/plugin/template/main.go.plush b/ignite/services/plugin/template/main.go.plush new file mode 100644 index 0000000000..9cd5d9e0fa --- /dev/null +++ b/ignite/services/plugin/template/main.go.plush @@ -0,0 +1,69 @@ +package main + +import ( + "encoding/gob" + "fmt" + + hplugin "github.com/hashicorp/go-plugin" + + ignitecmd "github.com/ignite/cli/ignite/cmd" + "github.com/ignite/cli/ignite/services/plugin" +) + +func init() { + gob.Register(plugin.Command{}) +} + +type p struct{} + +func (p) Commands() []plugin.Command { + // TODO: write your command list here + return []plugin.Command{ + { + Use: "<%= Name %>", + Short: "Explain what the command is doing...", + Long: "Long description goes here...", + PlaceCommandUnder: "ignite", + // Examples of subcommands: + Commands: []plugin.Command{ + {Use: "add"}, + {Use: "list"}, + {Use: "delete"}, + }, + }, + } +} + +func (p) Execute(cmd plugin.Command, args []string) error { + // TODO: write command execution here + fmt.Printf("Hello I'm the <%= Name %> plugin!\nargs=%v, with=%v\n", args, cmd.With) + + // This is how the plugin can access the chain: + c, err := ignitecmd.NewChainWithHomeFlags(cmd.CobraCmd) + if err != nil { + return err + } + _ = c + + // According to the number of declared commands, you may need a switch: + switch cmd.Use { + case "add": + fmt.Println("Adding stuff...") + case "list": + fmt.Println("Listing stuff...") + case "delete": + fmt.Println("Deleting stuff...") + } + return nil +} + +func main() { + var pluginMap = map[string]hplugin.Plugin{ + "<%= Name %>": &plugin.InterfacePlugin{Impl: &p{}}, + } + + hplugin.Serve(&hplugin.ServeConfig{ + HandshakeConfig: plugin.HandshakeConfig(), + Plugins: pluginMap, + }) +} diff --git a/ignite/services/plugin/testdata/fakebin b/ignite/services/plugin/testdata/fakebin new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ignite/services/scaffolder/init.go b/ignite/services/scaffolder/init.go index d2ada585ce..6cabad2c56 100644 --- a/ignite/services/scaffolder/init.go +++ b/ignite/services/scaffolder/init.go @@ -5,10 +5,7 @@ import ( "fmt" "path/filepath" "strings" - "time" - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing/object" "github.com/gobuffalo/genny" vue "github.com/ignite/web" @@ -19,19 +16,11 @@ import ( "github.com/ignite/cli/ignite/pkg/gomodulepath" "github.com/ignite/cli/ignite/pkg/localfs" "github.com/ignite/cli/ignite/pkg/placeholder" + "github.com/ignite/cli/ignite/pkg/xgit" "github.com/ignite/cli/ignite/templates/app" modulecreate "github.com/ignite/cli/ignite/templates/module/create" ) -var ( - commitMessage = "Initialized with Ignite CLI" - devXAuthor = &object.Signature{ - Name: "Developer Experience team at Tendermint", - Email: "hello@tendermint.com", - When: time.Now(), - } -) - // Init initializes a new app with name and given options. func Init(ctx context.Context, cacheStorage cache.Storage, tracer *placeholder.Tracer, root, name, addressPrefix string, noDefaultModule bool) (path string, err error) { if root, err = filepath.Abs(root); err != nil { @@ -55,7 +44,7 @@ func Init(ctx context.Context, cacheStorage cache.Storage, tracer *placeholder.T } // initialize git repository and perform the first commit - if err := initGit(path); err != nil { + if err := xgit.InitAndCommit(path); err != nil { return "", err } @@ -138,22 +127,3 @@ func generate( func Vue(path string) error { return localfs.Save(vue.Boilerplate(), path) } - -func initGit(path string) error { - repo, err := git.PlainInit(path, false) - if err != nil { - return err - } - wt, err := repo.Worktree() - if err != nil { - return err - } - if _, err := wt.Add("."); err != nil { - return err - } - _, err = wt.Commit(commitMessage, &git.CommitOptions{ - All: true, - Author: devXAuthor, - }) - return err -} diff --git a/readme.md b/readme.md index ad02c4a2a8..1fc48a3189 100644 --- a/readme.md +++ b/readme.md @@ -48,6 +48,65 @@ Blockchains created with Ignite CLI use the [Cosmos SDK](https://github.com/cosm To upgrade your blockchain to the newer version of Cosmos SDK, see the [Migration guide](https://docs.ignite.com/migration/). +## Plugin system + +Ignite CLI commands can be extended using plugins. A plugin is a program that +uses github.com/hashicorp/go-plugin to communicate with the ignite binary. + +#### Use a plugin + +Plugins must be declared in the `config.yml` file, using the following syntax: + +```yaml +plugins: + // path can be a repository or a local path + // the directory must contain go code under a main package. + // For repositories you can specify a suffix @branch or @tag to target a + // specific git reference. +- path: github.com/org/repo/my-plugin + // Additional parameters can be passed to the plugin + with: + key: value +``` + +Once declared, the next time the ignite binary will be executed under this +configuration, it will fetch, build and run the plugin. As a result, more +commands should be available in the list of the ignite commands. + +`ignite plugin` command allows to list the plugins and their status, and to +update a plugin if you need to get the latest version. + +### Make a plugin + +A plugin must implement `plugin.Interface`. + +The easiest way to make a plugin is to use the `ignite plugin scaffold` +command. For example: + +``` +$ cd /home/user/src +$ ignite plugin scaffold github.com/foo/bar +``` + +It will create a folder `bar` under `/home/user/src` and generate predefined +`go.mod` and `main.go`. The code contains everything required to connect to the +ignite binary via `hashicorp/go-plugin`. What need to be adapted is the +implementation of the `plugin.Interface` (`Commands` and `Execute` methods). + +To test your plugin, you only need to declare it under a chain config, for +instance: + +```yaml +plugins: +- path: /home/user/src/bar +``` + +Then run `ignite`, the plugin will compile and should be listed among the +ignite commands. Each time `ignite` is executed, the plugin is recompiled +if the files have changed since the last compilation. This allows fast and easy +plugin development, you only care about code and `ignite` handles the +compilation. + ## Contributing We welcome contributions from everyone. The `develop` branch contains the development version of the code. You can create a branch from `develop` and create a pull request, or maintain your own fork and submit a cross-repository pull request.