From 563fa4c44ca75ed5e537302d2d840da57bb92f3b Mon Sep 17 00:00:00 2001 From: Geoffrey Ragot Date: Thu, 21 May 2026 16:28:36 +0200 Subject: [PATCH] feat: add plugin architecture for fctl (Phases 1-8) Implements the plugin infrastructure for fctl as described in RFC 0002. Ledger v2 built-in commands are untouched. The plugin system activates only when a stack runs a service version not covered by built-in commands. Plugin SDK (pkg/pluginsdk/): - gRPC protocol with GetManifest + Execute RPCs - FctlPlugin interface and Serve() entry point for plugin binaries - DisplaySchema for centralized rendering Plugin infrastructure (pkg/plugin/): - Multi-version config with semver compatibleWith ranges - YAML registry with derived binary URLs from convention - Manifest caching to avoid spawning plugins at startup - Lazy plugin loading (process spawned on first Execute, not GetManifest) - Service version detection via stack /versions endpoint - Resolution: plugin first, built-in fallback, auto-discovery - Auto-discovery with interactive install prompt - Centralized rendering from JSON + DisplaySchema - Debug output with [plugin:{name}] prefixing on stderr - Plugin crash attribution with captured stderr CLI (cmd/plugin/, cmd/root.go): - fctl plugin install (from registry or --path for local binary/Go module) - fctl plugin list (multi-version display) - fctl plugin remove - --plugin-binary flag for ephemeral one-shot plugin loading - PersistentPreRunE interceptor on service commands (ledger for POC) Phase 9 (ledger plugin in product repo + registry) is separate. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + cmd/plugin/install.go | 164 +++ cmd/plugin/list.go | 46 + cmd/plugin/remove.go | 32 + cmd/plugin/root.go | 18 + cmd/root.go | 149 +++ go.mod | 14 +- go.sum | 28 + pkg/plugin/autodiscovery.go | 73 ++ pkg/plugin/cache.go | 86 ++ pkg/plugin/cache_test.go | 87 ++ pkg/plugin/cobra.go | 333 ++++++ pkg/plugin/config.go | 153 +++ pkg/plugin/config_test.go | 108 ++ pkg/plugin/debug.go | 135 +++ pkg/plugin/loader.go | 175 +++ pkg/plugin/manager.go | 187 ++++ pkg/plugin/registry.go | 148 +++ pkg/plugin/registry_test.go | 66 ++ pkg/plugin/render.go | 189 ++++ pkg/plugin/render_test.go | 134 +++ pkg/plugin/resolver.go | 77 ++ pkg/plugin/resolver_test.go | 94 ++ pkg/plugin/versions.go | 52 + pkg/pluginsdk/go.mod | 23 + pkg/pluginsdk/go.sum | 74 ++ pkg/pluginsdk/plugin.proto | 163 +++ pkg/pluginsdk/pluginpb/plugin.pb.go | 1305 ++++++++++++++++++++++ pkg/pluginsdk/pluginpb/plugin_grpc.pb.go | 167 +++ pkg/pluginsdk/sdk.go | 101 ++ 30 files changed, 4381 insertions(+), 1 deletion(-) create mode 100644 cmd/plugin/install.go create mode 100644 cmd/plugin/list.go create mode 100644 cmd/plugin/remove.go create mode 100644 cmd/plugin/root.go create mode 100644 pkg/plugin/autodiscovery.go create mode 100644 pkg/plugin/cache.go create mode 100644 pkg/plugin/cache_test.go create mode 100644 pkg/plugin/cobra.go create mode 100644 pkg/plugin/config.go create mode 100644 pkg/plugin/config_test.go create mode 100644 pkg/plugin/debug.go create mode 100644 pkg/plugin/loader.go create mode 100644 pkg/plugin/manager.go create mode 100644 pkg/plugin/registry.go create mode 100644 pkg/plugin/registry_test.go create mode 100644 pkg/plugin/render.go create mode 100644 pkg/plugin/render_test.go create mode 100644 pkg/plugin/resolver.go create mode 100644 pkg/plugin/resolver_test.go create mode 100644 pkg/plugin/versions.go create mode 100644 pkg/pluginsdk/go.mod create mode 100644 pkg/pluginsdk/go.sum create mode 100644 pkg/pluginsdk/plugin.proto create mode 100644 pkg/pluginsdk/pluginpb/plugin.pb.go create mode 100644 pkg/pluginsdk/pluginpb/plugin_grpc.pb.go create mode 100644 pkg/pluginsdk/sdk.go diff --git a/.gitignore b/.gitignore index fbb217c7..197c84c1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ autocomplete.* fctl coverage.* +ledger-v3-poc diff --git a/cmd/plugin/install.go b/cmd/plugin/install.go new file mode 100644 index 00000000..23504193 --- /dev/null +++ b/cmd/plugin/install.go @@ -0,0 +1,164 @@ +package plugin + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + fctl "github.com/formancehq/fctl/v3/pkg" + pluginpkg "github.com/formancehq/fctl/v3/pkg/plugin" +) + +const ( + versionFlag = "version" + pathFlag = "path" +) + +func NewInstallCommand() *cobra.Command { + return fctl.NewCommand("install", + fctl.WithShortDescription("Install a plugin from the registry or local path"), + fctl.WithArgs(cobra.ExactArgs(1)), + fctl.WithStringFlag(versionFlag, "", "Specific version to install"), + fctl.WithStringFlag(pathFlag, "", "Install from a local path (binary or Go module directory)"), + fctl.WithRunE(runInstall), + ) +} + +func runInstall(cmd *cobra.Command, args []string) error { + name := args[0] + localPath := fctl.GetString(cmd, pathFlag) + + if localPath != "" { + return installFromPath(cmd, name, localPath) + } + + return installFromRegistry(cmd, name) +} + +func installFromPath(cmd *cobra.Command, name, localPath string) error { + configDir := fctl.GetString(cmd, fctl.ConfigDir) + + absPath, err := filepath.Abs(localPath) + if err != nil { + return fmt.Errorf("invalid path: %w", err) + } + + info, err := os.Stat(absPath) + if err != nil { + return fmt.Errorf("path %q not found: %w", absPath, err) + } + + destPath := pluginpkg.PluginBinaryPath(configDir, name, "dev") + + if info.IsDir() { + // Check if it's a Go module directory + goMod := filepath.Join(absPath, "go.mod") + if _, err := os.Stat(goMod); os.IsNotExist(err) { + return fmt.Errorf("directory %q has no go.mod — expected a Go module or a binary", absPath) + } + + pterm.Info.Printfln("Detected Go module. Building...") + + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { + return fmt.Errorf("failed to create plugin directory: %w", err) + } + + buildCmd := exec.CommandContext(cmd.Context(), "go", "build", "-o", destPath, ".") + buildCmd.Dir = absPath + buildCmd.Stdout = os.Stdout + buildCmd.Stderr = os.Stderr + + if err := buildCmd.Run(); err != nil { + return fmt.Errorf("go build failed: %w", err) + } + } else { + // It's a binary — copy it + pterm.Info.Printfln("Installing from binary...") + + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { + return fmt.Errorf("failed to create plugin directory: %w", err) + } + + data, err := os.ReadFile(absPath) + if err != nil { + return fmt.Errorf("failed to read binary: %w", err) + } + + if err := os.WriteFile(destPath, data, 0o755); err != nil { + return fmt.Errorf("failed to write plugin binary: %w", err) + } + } + + // Fetch and cache manifest + loaded, err := pluginpkg.LoadPlugin(name, destPath) + if err != nil { + return fmt.Errorf("failed to load plugin: %w", err) + } + defer loaded.Kill() + + manifest, err := loaded.Client.GetManifest(context.Background()) + if err != nil { + return fmt.Errorf("failed to get manifest: %w", err) + } + + if err := pluginpkg.SaveCachedManifest(configDir, name, "dev", manifest); err != nil { + return fmt.Errorf("failed to cache manifest: %w", err) + } + + // Save config + cfg, err := pluginpkg.LoadPluginsConfig(configDir) + if err != nil { + return err + } + + cfg.AddPluginVersion(name, "dev", pluginpkg.InstalledPluginVersion{ + CompatibleWith: ">= 0.0.0", + Path: destPath, + }) + + if err := pluginpkg.SavePluginsConfig(configDir, cfg); err != nil { + return err + } + + pterm.Success.Printfln("Plugin %q installed (version: dev)", name) + return nil +} + +func installFromRegistry(cmd *cobra.Command, name string) error { + version := fctl.GetString(cmd, versionFlag) + configDir := fctl.GetString(cmd, fctl.ConfigDir) + + registry := pluginpkg.NewRegistryClient(fctl.GetHttpClient(cmd)) + pm := pluginpkg.NewPluginManager(configDir, false) + + reg, err := registry.FetchRegistry() + if err != nil { + return fmt.Errorf("failed to fetch registry: %w", err) + } + + pluginInfo, ok := reg.Plugins[name] + if !ok { + return fmt.Errorf("plugin %q not found in registry", name) + } + + if version == "" { + version, _, err = pluginInfo.FindBestVersion("999.999.999") + if err != nil { + return fmt.Errorf("no versions available for plugin %q", name) + } + } + + pterm.Info.Printfln("Installing plugin %s v%s...", name, version) + + if err := pm.InstallFromRegistry(name, version, pluginInfo, registry); err != nil { + return fmt.Errorf("failed to install plugin %s: %w", name, err) + } + + pterm.Success.Printfln("Plugin %s v%s installed successfully", name, version) + return nil +} diff --git a/cmd/plugin/list.go b/cmd/plugin/list.go new file mode 100644 index 00000000..e8188358 --- /dev/null +++ b/cmd/plugin/list.go @@ -0,0 +1,46 @@ +package plugin + +import ( + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + fctl "github.com/formancehq/fctl/v3/pkg" + pluginpkg "github.com/formancehq/fctl/v3/pkg/plugin" +) + +func NewListCommand() *cobra.Command { + return fctl.NewCommand("list", + fctl.WithAliases("ls"), + fctl.WithShortDescription("List installed plugins"), + fctl.WithArgs(cobra.ExactArgs(0)), + fctl.WithRunE(runList), + ) +} + +func runList(cmd *cobra.Command, args []string) error { + configDir := fctl.GetString(cmd, fctl.ConfigDir) + + cfg, err := pluginpkg.LoadPluginsConfig(configDir) + if err != nil { + return err + } + + if len(cfg.Plugins) == 0 { + pterm.Info.Println("No plugins installed") + pterm.Info.Println("Use 'fctl plugin install ' to install a plugin") + return nil + } + + tableData := [][]string{{"Name", "Version", "Compatible With"}} + for _, p := range cfg.Plugins { + for version, entry := range p.Versions { + tableData = append(tableData, []string{p.Name, version, entry.CompatibleWith}) + } + } + + return pterm.DefaultTable. + WithHasHeader(). + WithWriter(cmd.OutOrStdout()). + WithData(tableData). + Render() +} diff --git a/cmd/plugin/remove.go b/cmd/plugin/remove.go new file mode 100644 index 00000000..3f0c85d0 --- /dev/null +++ b/cmd/plugin/remove.go @@ -0,0 +1,32 @@ +package plugin + +import ( + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + fctl "github.com/formancehq/fctl/v3/pkg" + pluginpkg "github.com/formancehq/fctl/v3/pkg/plugin" +) + +func NewRemoveCommand() *cobra.Command { + return fctl.NewCommand("remove", + fctl.WithAliases("rm", "uninstall"), + fctl.WithShortDescription("Remove an installed plugin"), + fctl.WithArgs(cobra.ExactArgs(1)), + fctl.WithRunE(runRemove), + ) +} + +func runRemove(cmd *cobra.Command, args []string) error { + name := args[0] + configDir := fctl.GetString(cmd, fctl.ConfigDir) + + pm := pluginpkg.NewPluginManager(configDir, false) + + if err := pm.RemovePlugin(name); err != nil { + return err + } + + pterm.Success.Printfln("Plugin %s removed", name) + return nil +} diff --git a/cmd/plugin/root.go b/cmd/plugin/root.go new file mode 100644 index 00000000..75631408 --- /dev/null +++ b/cmd/plugin/root.go @@ -0,0 +1,18 @@ +package plugin + +import ( + "github.com/spf13/cobra" + + fctl "github.com/formancehq/fctl/v3/pkg" +) + +func NewCommand() *cobra.Command { + return fctl.NewCommand("plugin", + fctl.WithShortDescription("Manage fctl plugins"), + fctl.WithChildCommands( + NewInstallCommand(), + NewListCommand(), + NewRemoveCommand(), + ), + ) +} diff --git a/cmd/root.go b/cmd/root.go index 8ab935eb..f809b380 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,7 +8,9 @@ import ( "os" "os/signal" "runtime/debug" + "strings" + "github.com/Masterminds/semver/v3" "github.com/pterm/pterm" "github.com/spf13/cobra" @@ -23,6 +25,7 @@ import ( "github.com/formancehq/fctl/v3/cmd/login" "github.com/formancehq/fctl/v3/cmd/orchestration" "github.com/formancehq/fctl/v3/cmd/payments" + plugincmd "github.com/formancehq/fctl/v3/cmd/plugin" "github.com/formancehq/fctl/v3/cmd/profiles" "github.com/formancehq/fctl/v3/cmd/reconciliation" "github.com/formancehq/fctl/v3/cmd/search" @@ -32,6 +35,7 @@ import ( "github.com/formancehq/fctl/v3/cmd/wallets" "github.com/formancehq/fctl/v3/cmd/webhooks" fctl "github.com/formancehq/fctl/v3/pkg" + pluginpkg "github.com/formancehq/fctl/v3/pkg/plugin" ) func init() { @@ -63,6 +67,7 @@ func NewRootCommand() *cobra.Command { webhooks.NewCommand(), wallets.NewCommand(), orchestration.NewCommand(), + plugincmd.NewCommand(), ), fctl.WithPersistentStringPFlag(fctl.ProfileFlag, "p", "", "Configuration profile to use"), fctl.WithPersistentStringPFlag(fctl.ConfigDir, "c", fmt.Sprintf("%s/.config/formance/fctl", homedir), "Path to configuration dir"), @@ -70,6 +75,7 @@ func NewRootCommand() *cobra.Command { fctl.WithPersistentStringPFlag(fctl.OutputFlag, "o", "plain", "Output format (plain, json)"), fctl.WithPersistentBoolFlag(fctl.InsecureTlsFlag, false, "Allow insecure TLS connections"), fctl.WithPersistentBoolFlag(fctl.TelemetryFlag, false, "Enable telemetry"), + fctl.WithPersistentStringFlag("plugin-binary", "", "Load a plugin for one-shot use (name=/path/to/binary)"), fctl.WithPersistentPreRunE(func(cmd *cobra.Command, args []string) error { logger := logging.NewDefaultLogger(cmd.OutOrStdout(), fctl.GetBool(cmd, fctl.DebugFlag), false, false) ctx := logging.ContextWithLogger(cmd.Context(), logger) @@ -93,6 +99,106 @@ func NewRootCommand() *cobra.Command { return cmd } +// parsePluginBinaryArg scans os.Args for --plugin-binary=name=/path or +// --plugin-binary name=/path and returns the value, or empty string if absent. +func parsePluginBinaryArg() string { + for i, arg := range os.Args { + if arg == "--plugin-binary" && i+1 < len(os.Args) { + return os.Args[i+1] + } + if strings.HasPrefix(arg, "--plugin-binary=") { + return strings.TrimPrefix(arg, "--plugin-binary=") + } + } + return "" +} + +func removeChildCommand(parent *cobra.Command, name string) { + for _, child := range parent.Commands() { + if child.Name() == name { + parent.RemoveCommand(child) + return + } + } +} + +// serviceCommands maps command names to their service name for plugin resolution. +// The value indicates whether the built-in command covers the service (true = v2 built-in exists). +var serviceCommands = map[string]struct { + serviceName string + builtInCovers func(version string) bool +}{ + "ledger": { + serviceName: "ledger", + builtInCovers: func(version string) bool { + // Built-in ledger commands support v1/v2 (< 3.0.0) + v, err := semver.NewVersion(version) + if err != nil { + return true // can't parse, fall back to built-in + } + return v.Major() < 3 + }, + }, +} + +// wrapWithPluginResolution wraps a service command's PersistentPreRunE to +// detect the service version at runtime and resolve between plugin and built-in. +func wrapWithPluginResolution( + cmd *cobra.Command, + serviceName string, + builtInCovers func(string) bool, + pm *pluginpkg.PluginManager, + registry *pluginpkg.RegistryClient, +) { + originalPreRunE := cmd.PersistentPreRunE + + cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + // Run the original PersistentPreRunE first (logger setup, etc.) + if originalPreRunE != nil { + if err := originalPreRunE(cmd, args); err != nil { + return err + } + } + + // Try to detect service version — this requires a stack client. + // If we can't get one (no profile, no auth), fall through to built-in. + _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) + if err != nil { + return nil // can't auth, let built-in handle (it will show its own error) + } + + stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) + if err != nil { + return nil // can't get stack client, let built-in handle + } + + serviceVersion, err := pluginpkg.DetectServiceVersion(cmd.Context(), stackClient, serviceName) + if err != nil { + return nil // can't detect version, let built-in handle + } + + resolution, err := pluginpkg.Resolve(serviceName, serviceVersion, pm, registry, builtInCovers(serviceVersion)) + if err != nil { + return nil + } + + switch r := resolution.(type) { + case pluginpkg.UsePlugin: + return pluginpkg.ReplaceCommandTree(cmd, r.Plugin) + case pluginpkg.UseBuiltIn: + return nil + case pluginpkg.NeedInstall: + loaded, err := pluginpkg.AutoDiscover(cmd.Context(), r, pm, registry) + if err != nil { + return err + } + return pluginpkg.ReplaceCommandTree(cmd, loaded) + } + + return nil + } +} + func Execute() { defer func() { if e := recover(); e != nil { @@ -102,6 +208,49 @@ func Execute() { }() ctx, _ := signal.NotifyContext(context.TODO(), os.Interrupt) cmd := NewRootCommand() + + // Initialize plugin infrastructure + configDir := fmt.Sprintf("%s/.config/formance/fctl", os.Getenv("HOME")) + if dir := os.Getenv("FCTL_CONFIG_DIR"); dir != "" { + configDir = dir + } + + debug := fctl.GetBool(cmd, fctl.DebugFlag) + pm := pluginpkg.NewPluginManager(configDir, debug) + pm.DiscoverAndLoad(ctx) + defer pm.Shutdown() + + // Handle --plugin-binary for ephemeral one-shot plugin loading. + // Parsed from os.Args because cobra flags aren't parsed yet at this point. + if pluginBinary := parsePluginBinaryArg(); pluginBinary != "" { + if name, binaryPath, ok := strings.Cut(pluginBinary, "="); ok && name != "" && binaryPath != "" { + var opts []pluginpkg.LoadPluginOption + if debug { + opts = append(opts, pluginpkg.WithDebug()) + } + loaded, err := pluginpkg.LoadPlugin(name, binaryPath, opts...) + if err != nil { + pterm.Error.WithWriter(os.Stderr).Printfln("Failed to load plugin %s: %v", name, err) + } else { + loaded.Version = "ephemeral" + loaded.CompatibleWith = ">= 0.0.0" + pluginCmd := pluginpkg.BuildCobraCommand(loaded) + removeChildCommand(cmd, name) + cmd.AddCommand(pluginCmd) + defer loaded.Kill() + } + } + } + + registry := pluginpkg.NewRegistryClient(fctl.GetHttpClient(cmd)) + + // Wrap service commands with plugin resolution + for _, child := range cmd.Commands() { + if svc, ok := serviceCommands[child.Name()]; ok { + wrapWithPluginResolution(child, svc.serviceName, svc.builtInCovers, pm, registry) + } + } + if err := cmd.ExecuteContext(ctx); err != nil { switch { case errors.Is(err, fctl.ErrMissingApproval): diff --git a/go.mod b/go.mod index 79ded667..a7f829cc 100644 --- a/go.mod +++ b/go.mod @@ -9,15 +9,19 @@ require ( github.com/c-bata/go-prompt v0.2.6 github.com/formancehq/fctl/internal/deployserverclient/v3 v3.1.1 github.com/formancehq/fctl/internal/membershipclient/v3 v3.1.1 + github.com/formancehq/fctl/v3/pkg/pluginsdk v0.0.0 github.com/formancehq/formance-sdk-go/v3 v3.8.1 + github.com/formancehq/go-libs/v3 v3.6.2 github.com/formancehq/go-libs/v4 v4.1.1 github.com/go-jose/go-jose/v4 v4.1.4 + github.com/hashicorp/go-plugin v1.7.0 github.com/iancoleman/strcase v0.3.0 github.com/mattn/go-shellwords v1.0.12 github.com/pkg/errors v0.9.1 github.com/pterm/pterm v0.12.83 github.com/segmentio/ksuid v1.0.4 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 github.com/zitadel/oidc/v2 v2.12.2 golang.org/x/mod v0.34.0 golang.org/x/oauth2 v0.36.0 @@ -27,6 +31,7 @@ require ( replace ( github.com/formancehq/fctl/internal/deployserverclient/v3 => ./internal/deployserverclient github.com/formancehq/fctl/internal/membershipclient/v3 => ./internal/membershipclient + github.com/formancehq/fctl/v3/pkg/pluginsdk => ./pkg/pluginsdk github.com/spf13/cobra => github.com/formancehq/cobra v1.10.3-0.20260226173701-7f22399f993b github.com/zitadel/oidc/v2 v2.6.1 => github.com/formancehq/oidc/v2 v2.6.2-0.20230526075055-93dc5ecb0149 gopkg.in/go-jose/go-jose.v4 => github.com/go-jose/go-jose/v4 v4.1.4 @@ -37,6 +42,7 @@ require ( atomicgo.dev/keyboard v0.2.9 // indirect atomicgo.dev/schedule v0.1.0 // indirect dario.cat/mergo v1.0.2 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/ThreeDotsLabs/watermill v1.5.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect @@ -48,11 +54,13 @@ require ( github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gookit/color v1.6.0 // indirect github.com/gorilla/schema v1.4.1 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect @@ -65,13 +73,13 @@ require ( github.com/mattn/go-runewidth v0.0.21 // indirect github.com/mattn/go-tty v0.0.7 // indirect github.com/muhlemmer/gu v0.3.1 // indirect + github.com/oklog/run v1.1.0 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/term v1.2.0-beta.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect - github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect github.com/uptrace/bun v1.2.18 // indirect @@ -93,9 +101,13 @@ require ( go.uber.org/zap v1.27.1 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect + golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect ) diff --git a/go.sum b/go.sum index a21a7df3..b8ed2174 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= @@ -62,6 +64,8 @@ github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/c-bata/go-prompt v0.2.6 h1:POP+nrHE+DfLYx370bedwNhsqmpCUynWPxuHi0C5vZI= @@ -103,6 +107,8 @@ github.com/formancehq/cobra v1.10.3-0.20260226173701-7f22399f993b h1:8sPsvBsI0CJ github.com/formancehq/cobra v1.10.3-0.20260226173701-7f22399f993b/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/formancehq/formance-sdk-go/v3 v3.8.1 h1:nKWcpZT70vegEUV+/Xl/wekiI1PsCFVe7mvGgwaA9fk= github.com/formancehq/formance-sdk-go/v3 v3.8.1/go.mod h1:2Kb2Z4bN8/I4MQQnuSilvThqeaUtuLaTdmMGIb3nMJY= +github.com/formancehq/go-libs/v3 v3.6.2 h1:xFg93JENE5TVZA3UM5JVqaWLW7v7S1me69zG4u7VnUw= +github.com/formancehq/go-libs/v3 v3.6.2/go.mod h1:zWgFos2lhuunwk6YqXscMEokOKvEeH6Et5VjTyaF9mM= github.com/formancehq/go-libs/v4 v4.1.1 h1:D4LRPyssaq/2rPyNlmvAfypY5CsjLSQLLX2N4I/PsR4= github.com/formancehq/go-libs/v4 v4.1.1/go.mod h1:iVE2pTLa4KNhqXkCpBAGzpGO8TdHTEjdJMAiCkkeb0A= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= @@ -120,6 +126,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= @@ -143,6 +151,10 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= +github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8= github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= @@ -159,6 +171,8 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -213,6 +227,8 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y= @@ -314,6 +330,8 @@ go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUl go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= @@ -344,6 +362,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -397,6 +417,14 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/pkg/plugin/autodiscovery.go b/pkg/plugin/autodiscovery.go new file mode 100644 index 00000000..fc09ac71 --- /dev/null +++ b/pkg/plugin/autodiscovery.go @@ -0,0 +1,73 @@ +package plugin + +import ( + "context" + "fmt" + "os" + + "github.com/pterm/pterm" + "golang.org/x/term" +) + +// AutoDiscover handles the NeedInstall resolution by prompting the user to +// install the required plugin. In non-interactive mode, it returns an error +// with an actionable message. +// +// On success, it returns the newly loaded plugin ready for use. +func AutoDiscover( + ctx context.Context, + need NeedInstall, + manager *PluginManager, + registry *RegistryClient, +) (*LoadedPlugin, error) { + if !isInteractive() { + return nil, fmt.Errorf( + "stack runs %s v%s which requires the %q plugin (v%s)\n\n Run: fctl plugin install %s --version %s", + need.ServiceName, need.ServiceVersion, + need.ServiceName, need.PluginVersion, + need.ServiceName, need.PluginVersion, + ) + } + + // Interactive prompt + pterm.Println() + pterm.Info.Printfln( + "Stack runs %s v%s. Compatible plugin: fctl-plugin-%s v%s", + need.ServiceName, need.ServiceVersion, + need.ServiceName, need.PluginVersion, + ) + + result, _ := pterm.DefaultInteractiveConfirm. + WithDefaultValue(true). + Show("Install it now?") + + if !result { + return nil, fmt.Errorf("plugin installation declined") + } + + pterm.Info.Printfln("Downloading fctl-plugin-%s v%s...", need.ServiceName, need.PluginVersion) + + if err := manager.InstallFromRegistry(need.ServiceName, need.PluginVersion, *need.RegistryPlugin, registry); err != nil { + return nil, fmt.Errorf("failed to install plugin: %w", err) + } + + pterm.Success.Printfln("Plugin installed.") + pterm.Println() + + // Load the freshly installed plugin + binaryPath := PluginBinaryPath(manager.configDir, need.ServiceName, need.PluginVersion) + loaded, err := LoadPlugin(need.ServiceName, binaryPath) + if err != nil { + return nil, fmt.Errorf("failed to load installed plugin: %w", err) + } + loaded.Version = need.PluginVersion + if entry, ok := need.RegistryPlugin.Versions[need.PluginVersion]; ok { + loaded.CompatibleWith = entry.CompatibleWith + } + + return loaded, nil +} + +func isInteractive() bool { + return term.IsTerminal(int(os.Stdin.Fd())) +} diff --git a/pkg/plugin/cache.go b/pkg/plugin/cache.go new file mode 100644 index 00000000..933064fd --- /dev/null +++ b/pkg/plugin/cache.go @@ -0,0 +1,86 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/formancehq/fctl/v3/pkg/pluginsdk/pluginpb" + "google.golang.org/protobuf/encoding/protojson" +) + +// CachedManifest wraps a plugin manifest with cache metadata. +type CachedManifest struct { + BinaryModTime int64 `json:"binaryModTime"` + ManifestJSON []byte `json:"manifest"` +} + +// ManifestCachePath returns the path to the cached manifest for a plugin version. +func ManifestCachePath(configDir, name, version string) string { + return filepath.Join(PluginsDir(configDir), name, version, "manifest.cache.json") +} + +// LoadCachedManifest loads a cached manifest from disk. Returns nil if the cache +// is missing, corrupt, or stale (binary has been modified since caching). +func LoadCachedManifest(configDir, name, version string) (*pluginpb.PluginManifest, error) { + cachePath := ManifestCachePath(configDir, name, version) + data, err := os.ReadFile(cachePath) + if err != nil { + return nil, err + } + + var cached CachedManifest + if err := json.Unmarshal(data, &cached); err != nil { + return nil, fmt.Errorf("corrupt manifest cache: %w", err) + } + + // Check if binary has been modified since caching + binaryPath := PluginBinaryPath(configDir, name, version) + info, err := os.Stat(binaryPath) + if err != nil { + return nil, fmt.Errorf("cannot stat plugin binary: %w", err) + } + if info.ModTime().UnixNano() != cached.BinaryModTime { + return nil, fmt.Errorf("manifest cache is stale") + } + + var manifest pluginpb.PluginManifest + if err := protojson.Unmarshal(cached.ManifestJSON, &manifest); err != nil { + return nil, fmt.Errorf("invalid cached manifest: %w", err) + } + + return &manifest, nil +} + +// SaveCachedManifest writes a manifest to the cache, recording the current +// binary modification time for staleness detection. +func SaveCachedManifest(configDir, name, version string, manifest *pluginpb.PluginManifest) error { + binaryPath := PluginBinaryPath(configDir, name, version) + info, err := os.Stat(binaryPath) + if err != nil { + return fmt.Errorf("cannot stat plugin binary: %w", err) + } + + manifestJSON, err := protojson.Marshal(manifest) + if err != nil { + return fmt.Errorf("failed to marshal manifest: %w", err) + } + + cached := CachedManifest{ + BinaryModTime: info.ModTime().UnixNano(), + ManifestJSON: manifestJSON, + } + + data, err := json.MarshalIndent(cached, "", " ") + if err != nil { + return err + } + + cachePath := ManifestCachePath(configDir, name, version) + if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { + return err + } + + return os.WriteFile(cachePath, data, 0o600) +} diff --git a/pkg/plugin/cache_test.go b/pkg/plugin/cache_test.go new file mode 100644 index 00000000..8b9dba17 --- /dev/null +++ b/pkg/plugin/cache_test.go @@ -0,0 +1,87 @@ +package plugin + +import ( + "os" + "path/filepath" + "testing" + + "github.com/formancehq/fctl/v3/pkg/pluginsdk/pluginpb" +) + +func TestCacheRoundTrip(t *testing.T) { + tmpDir := t.TempDir() + name := "ledger" + version := "3.2.0" + + // Create a fake binary so stat works + binaryDir := filepath.Join(PluginsDir(tmpDir), name, version) + if err := os.MkdirAll(binaryDir, 0o755); err != nil { + t.Fatal(err) + } + binaryPath := PluginBinaryPath(tmpDir, name, version) + if err := os.WriteFile(binaryPath, []byte("fake"), 0o755); err != nil { + t.Fatal(err) + } + + manifest := &pluginpb.PluginManifest{ + Name: "ledger", + Version: "3.2.0", + Description: "Ledger v3 commands", + RootCommand: &pluginpb.CommandSpec{ + Use: "ledger", + Short: "Ledger management", + }, + } + + // Save + if err := SaveCachedManifest(tmpDir, name, version, manifest); err != nil { + t.Fatalf("SaveCachedManifest: %v", err) + } + + // Load + loaded, err := LoadCachedManifest(tmpDir, name, version) + if err != nil { + t.Fatalf("LoadCachedManifest: %v", err) + } + + if loaded.Name != manifest.Name { + t.Fatalf("Name mismatch: %q != %q", loaded.Name, manifest.Name) + } + if loaded.Version != manifest.Version { + t.Fatalf("Version mismatch: %q != %q", loaded.Version, manifest.Version) + } + if loaded.RootCommand == nil || loaded.RootCommand.Use != "ledger" { + t.Fatal("RootCommand not preserved") + } +} + +func TestCacheStaleAfterBinaryChange(t *testing.T) { + tmpDir := t.TempDir() + name := "ledger" + version := "3.2.0" + + binaryDir := filepath.Join(PluginsDir(tmpDir), name, version) + if err := os.MkdirAll(binaryDir, 0o755); err != nil { + t.Fatal(err) + } + binaryPath := PluginBinaryPath(tmpDir, name, version) + if err := os.WriteFile(binaryPath, []byte("v1"), 0o755); err != nil { + t.Fatal(err) + } + + manifest := &pluginpb.PluginManifest{Name: "ledger", Version: "3.2.0"} + if err := SaveCachedManifest(tmpDir, name, version, manifest); err != nil { + t.Fatal(err) + } + + // Modify the binary + if err := os.WriteFile(binaryPath, []byte("v2-modified"), 0o755); err != nil { + t.Fatal(err) + } + + // Cache should be stale + _, err := LoadCachedManifest(tmpDir, name, version) + if err == nil { + t.Fatal("expected stale cache error after binary modification") + } +} diff --git a/pkg/plugin/cobra.go b/pkg/plugin/cobra.go new file mode 100644 index 00000000..35603c0e --- /dev/null +++ b/pkg/plugin/cobra.go @@ -0,0 +1,333 @@ +package plugin + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/TylerBrock/colorjson" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + fctl "github.com/formancehq/fctl/v3/pkg" + "github.com/formancehq/fctl/v3/pkg/pluginsdk/pluginpb" +) + +// BuildCobraCommand translates a plugin's manifest into a cobra command tree. +func BuildCobraCommand(loaded *LoadedPlugin) *cobra.Command { + manifest, err := loaded.Client.GetManifest(context.Background()) + if err != nil { + cmd := &cobra.Command{ + Use: loaded.Name, + Short: fmt.Sprintf("Plugin %s (failed to load manifest: %v)", loaded.Name, err), + } + return cmd + } + + return buildCommandFromSpec(manifest.RootCommand, loaded) +} + +// buildCommandFromSpec recursively converts a CommandSpec into a cobra.Command. +func buildCommandFromSpec(spec *pluginpb.CommandSpec, loaded *LoadedPlugin) *cobra.Command { + if spec == nil { + return &cobra.Command{Use: loaded.Name} + } + + opts := make([]fctl.CommandOption, 0) + + if spec.Short != "" { + opts = append(opts, fctl.WithShortDescription(spec.Short)) + } + if spec.Long != "" { + opts = append(opts, fctl.WithDescription(spec.Long)) + } + if len(spec.Aliases) > 0 { + opts = append(opts, fctl.WithAliases(spec.Aliases...)) + } + if spec.Hidden { + opts = append(opts, fctl.WithHidden()) + } + if spec.Deprecated != "" { + opts = append(opts, fctl.WithDeprecated(spec.Deprecated)) + } + if spec.Confirm { + opts = append(opts, fctl.WithConfirmFlag()) + } + + for _, flag := range spec.Flags { + opts = append(opts, flagSpecToOption(flag, false)) + } + for _, flag := range spec.PersistentFlags { + opts = append(opts, flagSpecToOption(flag, true)) + } + + if spec.ArgsConstraint != "" { + if argsOpt := parseArgsConstraint(spec.ArgsConstraint); argsOpt != nil { + opts = append(opts, argsOpt) + } + } + + if spec.Runnable { + commandPath := spec.Use + opts = append(opts, fctl.WithRunE(makePluginRunE(loaded, commandPath, spec.CommandType, spec.Display))) + } + + var children []*cobra.Command + for _, sub := range spec.Subcommands { + children = append(children, buildCommandFromSpec(sub, loaded)) + } + if len(children) > 0 { + opts = append(opts, fctl.WithChildCommands(children...)) + } + + switch spec.CommandType { + case pluginpb.CommandType_COMMAND_TYPE_STACK: + return fctl.NewStackCommand(spec.Use, opts...) + case pluginpb.CommandType_COMMAND_TYPE_MEMBERSHIP: + return fctl.NewMembershipCommand(spec.Use, opts...) + default: + return fctl.NewCommand(spec.Use, opts...) + } +} + +// makePluginRunE creates the RunE function that bridges cobra to gRPC plugin execution. +func makePluginRunE(loaded *LoadedPlugin, commandPath string, cmdType pluginpb.CommandType, display *pluginpb.DisplaySchema) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + authCtx, err := buildAuthContext(cmd, cmdType) + if err != nil { + return err + } + + flags := collectFlags(cmd) + outputFormat := fctl.GetString(cmd, fctl.OutputFlag) + + req := &pluginpb.ExecuteRequest{ + CommandPath: commandPath, + Args: args, + Flags: flags, + AuthContext: authCtx, + OutputFormat: outputFormat, + } + + resp, err := loaded.Client.Execute(cmd.Context(), req) + if err != nil { + // Plugin process may have crashed — include captured stderr + return FormatPluginCrash(loaded.Name, loaded.Version, err, loaded.CapturedStderr()) + } + + switch r := resp.Result.(type) { + case *pluginpb.ExecuteResponse_Success: + return handleSuccess(cmd, r.Success, outputFormat, display) + case *pluginpb.ExecuteResponse_Error: + return fmt.Errorf("%s", r.Error.Message) + default: + return fmt.Errorf("unexpected response from plugin") + } + } +} + +// buildAuthContext constructs the AuthContext from the fctl core auth system. +func buildAuthContext(cmd *cobra.Command, cmdType pluginpb.CommandType) (*pluginpb.AuthContext, error) { + authCtx := &pluginpb.AuthContext{ + InsecureTls: fctl.GetBool(cmd, fctl.InsecureTlsFlag), + Debug: fctl.GetBool(cmd, fctl.DebugFlag), + } + + if cmdType == pluginpb.CommandType_COMMAND_TYPE_BASIC { + return authCtx, nil + } + + _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) + if err != nil { + return nil, err + } + + authCtx.MembershipUrl = profile.GetMembershipURI() + + if cmdType == pluginpb.CommandType_COMMAND_TYPE_MEMBERSHIP { + membershipToken, err := fctl.EnsureMembershipAccess( + cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile, + ) + if err != nil { + return nil, err + } + authCtx.MembershipToken = membershipToken.Token + + orgID, err := fctl.ResolveOrganizationID(cmd, *profile) + if err == nil { + authCtx.OrganizationId = orgID + } + return authCtx, nil + } + + // STACK type + orgID, stackID, err := fctl.ResolveStackID(cmd, *profile) + if err != nil { + return nil, err + } + + stackToken, stackAccess, err := fctl.EnsureStackAccess( + cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile, orgID, stackID, + ) + if err != nil { + return nil, err + } + + authCtx.OrganizationId = orgID + authCtx.StackId = stackID + authCtx.StackUrl = stackAccess.URI + authCtx.AccessToken = stackToken.Token + + membershipToken, err := fctl.EnsureMembershipAccess( + cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile, + ) + if err == nil { + authCtx.MembershipToken = membershipToken.Token + } + + return authCtx, nil +} + +// handleSuccess outputs the plugin response to the user. +// If a display schema is provided and json_data is available, fctl renders centrally. +// Otherwise, falls back to rendered_text from the plugin. +func handleSuccess(cmd *cobra.Command, success *pluginpb.ExecuteSuccess, outputFormat string, display *pluginpb.DisplaySchema) error { + // Centralized rendering: display schema + json_data available + if display != nil && success.JsonData != "" { + return RenderFromSchema(cmd.OutOrStdout(), success.JsonData, display, outputFormat) + } + + // Fallback: plugin-rendered output + if outputFormat == "json" && success.JsonData != "" { + raw := make(map[string]any) + if err := json.Unmarshal([]byte(success.JsonData), &raw); err == nil { + f := colorjson.NewFormatter() + f.Indent = 2 + colorized, err := f.Marshal(raw) + if err == nil { + _, err = cmd.OutOrStdout().Write(colorized) + return err + } + } + _, err := fmt.Fprintln(cmd.OutOrStdout(), success.JsonData) + return err + } + + if success.RenderedText != "" { + _, err := fmt.Fprint(cmd.OutOrStdout(), success.RenderedText) + return err + } + return nil +} + +// collectFlags gathers all resolved flag values from the cobra command. +func collectFlags(cmd *cobra.Command) map[string]string { + flags := make(map[string]string) + cmd.Flags().VisitAll(func(f *pflag.Flag) { + flags[f.Name] = f.Value.String() + }) + return flags +} + +// flagSpecToOption converts a protobuf FlagSpec to a cobra CommandOption. +func flagSpecToOption(spec *pluginpb.FlagSpec, persistent bool) fctl.CommandOption { + if persistent { + switch spec.Type { + case pluginpb.FlagType_FLAG_TYPE_BOOL: + defVal := spec.DefaultValue == "true" + if spec.Shorthand != "" { + return fctl.WithPersistentBoolPFlag(spec.Name, spec.Shorthand, defVal, spec.Description) + } + return fctl.WithPersistentBoolFlag(spec.Name, defVal, spec.Description) + default: + if spec.Shorthand != "" { + return fctl.WithPersistentStringPFlag(spec.Name, spec.Shorthand, spec.DefaultValue, spec.Description) + } + return fctl.WithPersistentStringFlag(spec.Name, spec.DefaultValue, spec.Description) + } + } + + switch spec.Type { + case pluginpb.FlagType_FLAG_TYPE_BOOL: + defVal := spec.DefaultValue == "true" + return fctl.WithBoolFlag(spec.Name, defVal, spec.Description) + case pluginpb.FlagType_FLAG_TYPE_INT: + defVal, _ := strconv.Atoi(spec.DefaultValue) + return fctl.WithIntFlag(spec.Name, defVal, spec.Description) + case pluginpb.FlagType_FLAG_TYPE_STRING_SLICE: + var defVal []string + if spec.DefaultValue != "" { + defVal = strings.Split(spec.DefaultValue, ",") + } + return fctl.WithStringSliceFlag(spec.Name, defVal, spec.Description) + default: + return fctl.WithStringFlag(spec.Name, spec.DefaultValue, spec.Description) + } +} + +// parseArgsConstraint converts an args constraint string to a cobra PositionalArgs option. +func parseArgsConstraint(constraint string) fctl.CommandOption { + parts := strings.SplitN(constraint, ":", 3) + switch parts[0] { + case "exact": + if len(parts) >= 2 { + n, _ := strconv.Atoi(parts[1]) + return fctl.WithArgs(cobra.ExactArgs(n)) + } + case "min": + if len(parts) >= 2 { + n, _ := strconv.Atoi(parts[1]) + return fctl.WithArgs(cobra.MinimumNArgs(n)) + } + case "max": + if len(parts) >= 2 { + n, _ := strconv.Atoi(parts[1]) + return fctl.WithArgs(cobra.MaximumNArgs(n)) + } + case "range": + if len(parts) >= 3 { + min, _ := strconv.Atoi(parts[1]) + max, _ := strconv.Atoi(parts[2]) + return fctl.WithArgs(cobra.RangeArgs(min, max)) + } + case "none": + return fctl.WithArgs(cobra.NoArgs) + case "any": + return fctl.WithArgs(cobra.ArbitraryArgs) + } + return nil +} + +// ReplaceCommandTree replaces a parent command's children with plugin-generated +// commands from the plugin's manifest. The parent command itself is preserved +// (its Use, Short, PersistentFlags, PersistentPreRunE stay intact). +func ReplaceCommandTree(parent *cobra.Command, loaded *LoadedPlugin) error { + manifest, err := loaded.Client.GetManifest(context.Background()) + if err != nil { + return fmt.Errorf("failed to get manifest from plugin %s: %w", loaded.Name, err) + } + + root := manifest.RootCommand + if root == nil { + return fmt.Errorf("plugin %s has no root command", loaded.Name) + } + + // Remove existing children + for _, child := range parent.Commands() { + parent.RemoveCommand(child) + } + + // Add plugin's subcommands + for _, sub := range root.Subcommands { + parent.AddCommand(buildCommandFromSpec(sub, loaded)) + } + + // If the plugin root is runnable, set RunE on the parent + if root.Runnable { + parent.RunE = makePluginRunE(loaded, root.Use, root.CommandType, root.Display) + } + + return nil +} diff --git a/pkg/plugin/config.go b/pkg/plugin/config.go new file mode 100644 index 00000000..e8306100 --- /dev/null +++ b/pkg/plugin/config.go @@ -0,0 +1,153 @@ +package plugin + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/Masterminds/semver/v3" +) + +// InstalledPluginVersion describes a single installed version of a plugin. +type InstalledPluginVersion struct { + CompatibleWith string `json:"compatibleWith"` + Path string `json:"path,omitempty"` +} + +// InstalledPlugin describes a plugin with potentially multiple installed versions. +type InstalledPlugin struct { + Name string `json:"name"` + Versions map[string]InstalledPluginVersion `json:"versions"` +} + +// FindCompatibleVersion returns the highest installed version whose compatibleWith +// range satisfies the given service version. Returns empty string if none match. +func (p *InstalledPlugin) FindCompatibleVersion(serviceVersion string) (string, bool) { + sv, err := semver.NewVersion(serviceVersion) + if err != nil { + return "", false + } + + var bestVersion string + var bestSemver *semver.Version + + for version, entry := range p.Versions { + constraint, err := semver.NewConstraint(entry.CompatibleWith) + if err != nil { + continue + } + if !constraint.Check(sv) { + continue + } + v, err := semver.NewVersion(version) + if err != nil { + continue + } + if bestSemver == nil || v.GreaterThan(bestSemver) { + bestVersion = version + bestSemver = v + } + } + + return bestVersion, bestVersion != "" +} + +// PluginsConfig is the top-level structure for ~/.config/formance/fctl/plugins.json. +type PluginsConfig struct { + Plugins []InstalledPlugin `json:"plugins"` +} + +// PluginsDir returns the base directory for plugin binaries. +func PluginsDir(configDir string) string { + return filepath.Join(configDir, "plugins") +} + +// PluginBinaryPath returns the full path to a plugin binary. +func PluginBinaryPath(configDir, name, version string) string { + return filepath.Join(PluginsDir(configDir), name, version, "fctl-plugin-"+name) +} + +// PluginsConfigPath returns the path to plugins.json. +func PluginsConfigPath(configDir string) string { + return filepath.Join(configDir, "plugins.json") +} + +// LoadPluginsConfig reads the plugins.json configuration file. +func LoadPluginsConfig(configDir string) (*PluginsConfig, error) { + path := PluginsConfigPath(configDir) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &PluginsConfig{}, nil + } + return nil, err + } + + cfg := &PluginsConfig{} + if err := json.Unmarshal(data, cfg); err != nil { + return nil, err + } + return cfg, nil +} + +// SavePluginsConfig writes the plugins.json configuration file. +func SavePluginsConfig(configDir string, cfg *PluginsConfig) error { + path := PluginsConfigPath(configDir) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +// FindInstalledPlugin returns the installed plugin entry by name, or nil. +func (c *PluginsConfig) FindInstalledPlugin(name string) *InstalledPlugin { + for i := range c.Plugins { + if c.Plugins[i].Name == name { + return &c.Plugins[i] + } + } + return nil +} + +// AddPluginVersion adds or updates a specific version of a plugin. +func (c *PluginsConfig) AddPluginVersion(name, version string, entry InstalledPluginVersion) { + p := c.FindInstalledPlugin(name) + if p == nil { + c.Plugins = append(c.Plugins, InstalledPlugin{ + Name: name, + Versions: map[string]InstalledPluginVersion{version: entry}, + }) + return + } + if p.Versions == nil { + p.Versions = make(map[string]InstalledPluginVersion) + } + p.Versions[version] = entry +} + +// RemovePlugin removes a plugin entry entirely (all versions). +func (c *PluginsConfig) RemovePlugin(name string) { + for i := range c.Plugins { + if c.Plugins[i].Name == name { + c.Plugins = append(c.Plugins[:i], c.Plugins[i+1:]...) + return + } + } +} + +// RemovePluginVersion removes a specific version of a plugin. +func (c *PluginsConfig) RemovePluginVersion(name, version string) { + p := c.FindInstalledPlugin(name) + if p == nil { + return + } + delete(p.Versions, version) + if len(p.Versions) == 0 { + c.RemovePlugin(name) + } +} diff --git a/pkg/plugin/config_test.go b/pkg/plugin/config_test.go new file mode 100644 index 00000000..c8816125 --- /dev/null +++ b/pkg/plugin/config_test.go @@ -0,0 +1,108 @@ +package plugin + +import ( + "testing" +) + +func TestFindCompatibleVersion(t *testing.T) { + p := &InstalledPlugin{ + Name: "ledger", + Versions: map[string]InstalledPluginVersion{ + "3.0.0": {CompatibleWith: ">= 3.0.0, < 3.3.0"}, + "3.3.0": {CompatibleWith: ">= 3.3.0, < 4.0.0"}, + "4.0.0": {CompatibleWith: ">= 4.0.0"}, + }, + } + + tests := []struct { + serviceVersion string + wantVersion string + wantFound bool + }{ + {"3.1.0", "3.0.0", true}, + {"3.0.0", "3.0.0", true}, + {"3.3.0", "3.3.0", true}, + {"3.5.2", "3.3.0", true}, + {"4.0.0", "4.0.0", true}, + {"4.2.1", "4.0.0", true}, + {"2.9.0", "", false}, + } + + for _, tt := range tests { + t.Run(tt.serviceVersion, func(t *testing.T) { + got, found := p.FindCompatibleVersion(tt.serviceVersion) + if found != tt.wantFound { + t.Fatalf("FindCompatibleVersion(%q): found=%v, want %v", tt.serviceVersion, found, tt.wantFound) + } + if got != tt.wantVersion { + t.Fatalf("FindCompatibleVersion(%q): got=%q, want %q", tt.serviceVersion, got, tt.wantVersion) + } + }) + } +} + +func TestFindCompatibleVersionPicksHighest(t *testing.T) { + p := &InstalledPlugin{ + Name: "ledger", + Versions: map[string]InstalledPluginVersion{ + "3.0.0": {CompatibleWith: ">= 3.0.0"}, + "3.2.0": {CompatibleWith: ">= 3.0.0"}, + "3.5.0": {CompatibleWith: ">= 3.0.0"}, + }, + } + + got, found := p.FindCompatibleVersion("3.1.0") + if !found { + t.Fatal("expected to find a version") + } + if got != "3.5.0" { + t.Fatalf("expected 3.5.0, got %q", got) + } +} + +func TestAddPluginVersion(t *testing.T) { + cfg := &PluginsConfig{} + + cfg.AddPluginVersion("ledger", "3.0.0", InstalledPluginVersion{ + CompatibleWith: ">= 3.0.0", + }) + + p := cfg.FindInstalledPlugin("ledger") + if p == nil { + t.Fatal("expected plugin to be added") + } + if len(p.Versions) != 1 { + t.Fatalf("expected 1 version, got %d", len(p.Versions)) + } + + cfg.AddPluginVersion("ledger", "3.3.0", InstalledPluginVersion{ + CompatibleWith: ">= 3.3.0", + }) + + if len(p.Versions) != 2 { + t.Fatalf("expected 2 versions, got %d", len(p.Versions)) + } +} + +func TestRemovePluginVersion(t *testing.T) { + cfg := &PluginsConfig{} + cfg.AddPluginVersion("ledger", "3.0.0", InstalledPluginVersion{CompatibleWith: ">= 3.0.0"}) + cfg.AddPluginVersion("ledger", "3.3.0", InstalledPluginVersion{CompatibleWith: ">= 3.3.0"}) + + cfg.RemovePluginVersion("ledger", "3.0.0") + + p := cfg.FindInstalledPlugin("ledger") + if p == nil { + t.Fatal("plugin should still exist") + } + if len(p.Versions) != 1 { + t.Fatalf("expected 1 version, got %d", len(p.Versions)) + } + + cfg.RemovePluginVersion("ledger", "3.3.0") + + p = cfg.FindInstalledPlugin("ledger") + if p != nil { + t.Fatal("plugin should be removed when no versions left") + } +} diff --git a/pkg/plugin/debug.go b/pkg/plugin/debug.go new file mode 100644 index 00000000..e3f6beff --- /dev/null +++ b/pkg/plugin/debug.go @@ -0,0 +1,135 @@ +package plugin + +import ( + "bufio" + "bytes" + "fmt" + "io" + "os" + "sync" +) + +// PrefixedWriter wraps a writer and prefixes each line with a label. +type PrefixedWriter struct { + prefix string + out io.Writer + mu sync.Mutex + buf bytes.Buffer +} + +// NewPrefixedWriter creates a writer that prefixes each line with the given prefix. +func NewPrefixedWriter(prefix string, out io.Writer) *PrefixedWriter { + return &PrefixedWriter{ + prefix: prefix, + out: out, + } +} + +func (w *PrefixedWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + + w.buf.Write(p) + + for { + line, err := w.buf.ReadBytes('\n') + if err != nil { + // Incomplete line, put it back + w.buf.Write(line) + break + } + fmt.Fprintf(w.out, " %s %s", w.prefix, string(line)) + } + + return len(p), nil +} + +// Flush writes any remaining buffered content. +func (w *PrefixedWriter) Flush() { + w.mu.Lock() + defer w.mu.Unlock() + + if w.buf.Len() > 0 { + fmt.Fprintf(w.out, " %s %s\n", w.prefix, w.buf.String()) + w.buf.Reset() + } +} + +// CapturedWriter captures all written content for later retrieval, +// while optionally forwarding to another writer. +type CapturedWriter struct { + mu sync.Mutex + buf bytes.Buffer + forward io.Writer +} + +// NewCapturedWriter creates a writer that captures output and optionally forwards it. +func NewCapturedWriter(forward io.Writer) *CapturedWriter { + return &CapturedWriter{forward: forward} +} + +func (w *CapturedWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + + w.buf.Write(p) + if w.forward != nil { + return w.forward.Write(p) + } + return len(p), nil +} + +// String returns all captured content. +func (w *CapturedWriter) String() string { + w.mu.Lock() + defer w.mu.Unlock() + return w.buf.String() +} + +// PluginStderrConfig configures how plugin stderr is handled. +type PluginStderrConfig struct { + Debug bool + Captured *CapturedWriter +} + +// NewPluginStderr creates the stderr writer for a plugin process. +// In debug mode, stderr is prefixed with [plugin:{name}] and forwarded to os.Stderr. +// In all cases, stderr is captured for crash reporting. +func NewPluginStderr(name string, debug bool) (*CapturedWriter, io.Writer) { + var forward io.Writer + if debug { + forward = NewPrefixedWriter(fmt.Sprintf("[plugin:%s]", name), os.Stderr) + } + captured := NewCapturedWriter(forward) + return captured, captured +} + +// FormatPluginCrash formats a plugin crash error with captured stderr. +func FormatPluginCrash(name, version string, exitErr error, stderr string) error { + msg := fmt.Sprintf("plugin %q crashed during execution", name) + if exitErr != nil { + msg += fmt.Sprintf("\n\n %v", exitErr) + } + if stderr != "" { + // Limit stderr to last 20 lines + lines := splitLines(stderr) + if len(lines) > 20 { + lines = lines[len(lines)-20:] + } + msg += "\n\n Stderr:\n" + for _, line := range lines { + msg += fmt.Sprintf(" %s\n", line) + } + } + msg += fmt.Sprintf("\n This is a bug in the %s plugin (v%s), not in fctl.", name, version) + return fmt.Errorf("%s", msg) +} + +func splitLines(s string) []string { + var lines []string + scanner := bufio.NewScanner(bytes.NewBufferString(s)) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + return lines +} diff --git a/pkg/plugin/loader.go b/pkg/plugin/loader.go new file mode 100644 index 00000000..4f5f27f1 --- /dev/null +++ b/pkg/plugin/loader.go @@ -0,0 +1,175 @@ +package plugin + +import ( + "context" + "fmt" + "os/exec" + "sync" + + "github.com/formancehq/fctl/v3/pkg/pluginsdk" + "github.com/formancehq/fctl/v3/pkg/pluginsdk/pluginpb" + goplugin "github.com/hashicorp/go-plugin" +) + +// LoadedPlugin represents a plugin that has been loaded and is ready to use. +type LoadedPlugin struct { + Name string + Version string + CompatibleWith string + Client pluginsdk.FctlPlugin + client *goplugin.Client + stderr *CapturedWriter +} + +// CapturedStderr returns the captured stderr content from the plugin process. +func (l *LoadedPlugin) CapturedStderr() string { + if l.stderr != nil { + return l.stderr.String() + } + return "" +} + +// Killable is implemented by plugin clients that manage a process. +type Killable interface { + Kill() +} + +// Kill terminates the plugin process. +func (l *LoadedPlugin) Kill() { + if k, ok := l.Client.(Killable); ok { + k.Kill() + } + if l.client != nil { + l.client.Kill() + } +} + +// LoadPlugin starts a plugin binary and returns a LoadedPlugin. +// If debug is true, plugin stderr is prefixed and forwarded to os.Stderr. +func LoadPlugin(name, binaryPath string, opts ...LoadPluginOption) (*LoadedPlugin, error) { + var cfg loadPluginConfig + for _, opt := range opts { + opt(&cfg) + } + + captured, stderrWriter := NewPluginStderr(name, cfg.debug) + + client := goplugin.NewClient(&goplugin.ClientConfig{ + HandshakeConfig: pluginsdk.HandshakeConfig, + Plugins: pluginsdk.PluginMap, + Cmd: exec.Command(binaryPath), + Stderr: stderrWriter, + AllowedProtocols: []goplugin.Protocol{ + goplugin.ProtocolGRPC, + }, + }) + + rpcClient, err := client.Client() + if err != nil { + client.Kill() + return nil, fmt.Errorf("failed to connect to plugin %s: %w", name, err) + } + + raw, err := rpcClient.Dispense("fctl-plugin") + if err != nil { + client.Kill() + return nil, fmt.Errorf("failed to dispense plugin %s: %w", name, err) + } + + fctlPlugin, ok := raw.(pluginsdk.FctlPlugin) + if !ok { + client.Kill() + return nil, fmt.Errorf("plugin %s does not implement FctlPlugin interface", name) + } + + return &LoadedPlugin{ + Name: name, + Client: fctlPlugin, + client: client, + stderr: captured, + }, nil +} + +type loadPluginConfig struct { + debug bool +} + +// LoadPluginOption configures LoadPlugin behavior. +type LoadPluginOption func(*loadPluginConfig) + +// WithDebug enables debug mode for the plugin (stderr prefixing). +func WithDebug() LoadPluginOption { + return func(c *loadPluginConfig) { + c.debug = true + } +} + +// LazyPluginClient wraps a cached manifest and defers process spawn to first Execute call. +// GetManifest returns the cached manifest without spawning a process. +type LazyPluginClient struct { + manifest *pluginpb.PluginManifest + name string + binaryPath string + debug bool + + mu sync.Mutex + inner pluginsdk.FctlPlugin + client *goplugin.Client +} + +// NewLazyPluginClient creates a lazy client that serves GetManifest from cache +// and only spawns the plugin process on Execute. +func NewLazyPluginClient(name, binaryPath string, manifest *pluginpb.PluginManifest, debug bool) *LazyPluginClient { + return &LazyPluginClient{ + manifest: manifest, + name: name, + binaryPath: binaryPath, + debug: debug, + } +} + +func (l *LazyPluginClient) GetManifest(_ context.Context) (*pluginpb.PluginManifest, error) { + return l.manifest, nil +} + +func (l *LazyPluginClient) Execute(ctx context.Context, req *pluginpb.ExecuteRequest) (*pluginpb.ExecuteResponse, error) { + inner, err := l.ensureLoaded() + if err != nil { + return nil, err + } + return inner.Execute(ctx, req) +} + +func (l *LazyPluginClient) ensureLoaded() (pluginsdk.FctlPlugin, error) { + l.mu.Lock() + defer l.mu.Unlock() + + if l.inner != nil { + return l.inner, nil + } + + var opts []LoadPluginOption + if l.debug { + opts = append(opts, WithDebug()) + } + loaded, err := LoadPlugin(l.name, l.binaryPath, opts...) + if err != nil { + return nil, err + } + + l.inner = loaded.Client + l.client = loaded.client + return l.inner, nil +} + +// Kill terminates the underlying plugin process if it was spawned. +func (l *LazyPluginClient) Kill() { + l.mu.Lock() + defer l.mu.Unlock() + + if l.client != nil { + l.client.Kill() + l.client = nil + l.inner = nil + } +} diff --git a/pkg/plugin/manager.go b/pkg/plugin/manager.go new file mode 100644 index 00000000..1d05e350 --- /dev/null +++ b/pkg/plugin/manager.go @@ -0,0 +1,187 @@ +package plugin + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/formancehq/go-libs/v3/logging" +) + +// PluginManager discovers, loads, and manages the lifecycle of fctl plugins. +type PluginManager struct { + configDir string + debug bool + loaded []*LoadedPlugin +} + +// NewPluginManager creates a new PluginManager. +func NewPluginManager(configDir string, debug bool) *PluginManager { + return &PluginManager{ + configDir: configDir, + debug: debug, + } +} + +// DiscoverAndLoad finds all installed plugins and prepares them for use. +// Plugins with a cached manifest are loaded lazily (no process spawned). +// Plugins without a cache are spawned to fetch their manifest, then cached. +func (pm *PluginManager) DiscoverAndLoad(ctx context.Context) { + logger := logging.FromContext(ctx) + + cfg, err := LoadPluginsConfig(pm.configDir) + if err != nil { + logger.Debugf("Failed to load plugins config: %v", err) + return + } + + for _, p := range cfg.Plugins { + for version, entry := range p.Versions { + binaryPath := entry.Path + if binaryPath == "" { + binaryPath = PluginBinaryPath(pm.configDir, p.Name, version) + } + + if _, err := os.Stat(binaryPath); os.IsNotExist(err) { + logger.Debugf("Plugin binary not found for %s@%s at %s, skipping", p.Name, version, binaryPath) + continue + } + + // Try cached manifest first (no process spawn) + if manifest, err := LoadCachedManifest(pm.configDir, p.Name, version); err == nil { + lazy := NewLazyPluginClient(p.Name, binaryPath, manifest, pm.debug) + pm.loaded = append(pm.loaded, &LoadedPlugin{ + Name: p.Name, + Version: version, + CompatibleWith: entry.CompatibleWith, + Client: lazy, + }) + logger.Debugf("Plugin %s@%s loaded from cache", p.Name, version) + continue + } + + // Cache miss: spawn plugin, get manifest, cache it + loaded, err := LoadPlugin(p.Name, binaryPath) + if err != nil { + logger.Debugf("Failed to load plugin %s@%s: %v", p.Name, version, err) + continue + } + loaded.Version = version + loaded.CompatibleWith = entry.CompatibleWith + + manifest, err := loaded.Client.GetManifest(ctx) + if err != nil { + logger.Debugf("Failed to get manifest for %s@%s: %v", p.Name, version, err) + loaded.Kill() + continue + } + + if err := SaveCachedManifest(pm.configDir, p.Name, version, manifest); err != nil { + logger.Debugf("Failed to cache manifest for %s@%s: %v", p.Name, version, err) + } + + pm.loaded = append(pm.loaded, loaded) + logger.Debugf("Plugin %s@%s loaded and cached", p.Name, version) + } + } +} + +// GetLoadedPlugins returns all successfully loaded plugins. +func (pm *PluginManager) GetLoadedPlugins() []*LoadedPlugin { + return pm.loaded +} + +// FindPluginForService returns the best loaded plugin for a given service version. +func (pm *PluginManager) FindPluginForService(serviceName, serviceVersion string) *LoadedPlugin { + cfg, err := LoadPluginsConfig(pm.configDir) + if err != nil { + return nil + } + p := cfg.FindInstalledPlugin(serviceName) + if p == nil { + return nil + } + bestVersion, found := p.FindCompatibleVersion(serviceVersion) + if !found { + return nil + } + for _, loaded := range pm.loaded { + if loaded.Name == serviceName && loaded.Version == bestVersion { + return loaded + } + } + return nil +} + +// Shutdown kills all loaded plugin processes. +func (pm *PluginManager) Shutdown() { + for _, p := range pm.loaded { + p.Kill() + } + pm.loaded = nil +} + +// InstallFromRegistry downloads and installs a plugin version from the registry. +func (pm *PluginManager) InstallFromRegistry(name, version string, registryPlugin RegistryPlugin, registry *RegistryClient) error { + versionInfo, ok := registryPlugin.Versions[version] + if !ok { + return fmt.Errorf("version %s not found for plugin %q", version, name) + } + + binaryURL := registryPlugin.BinaryURL(name, version) + destPath := PluginBinaryPath(pm.configDir, name, version) + + if err := registry.DownloadBinary(binaryURL, destPath); err != nil { + return err + } + + // Spawn to fetch and cache the manifest + loaded, err := LoadPlugin(name, destPath) + if err != nil { + return fmt.Errorf("failed to load plugin after install: %w", err) + } + defer loaded.Kill() + + ctx := context.Background() + manifest, err := loaded.Client.GetManifest(ctx) + if err != nil { + return fmt.Errorf("failed to get manifest after install: %w", err) + } + + if err := SaveCachedManifest(pm.configDir, name, version, manifest); err != nil { + return fmt.Errorf("failed to cache manifest: %w", err) + } + + // Save config + cfg, err := LoadPluginsConfig(pm.configDir) + if err != nil { + return err + } + + cfg.AddPluginVersion(name, version, InstalledPluginVersion{ + CompatibleWith: versionInfo.CompatibleWith, + }) + + return SavePluginsConfig(pm.configDir, cfg) +} + +// RemovePlugin uninstalls a plugin (all versions). +func (pm *PluginManager) RemovePlugin(name string) error { + cfg, err := LoadPluginsConfig(pm.configDir) + if err != nil { + return err + } + + installed := cfg.FindInstalledPlugin(name) + if installed == nil { + return fmt.Errorf("plugin %q is not installed", name) + } + + // Remove the entire plugin directory (all versions) + pluginDir := filepath.Join(PluginsDir(pm.configDir), name) + _ = os.RemoveAll(pluginDir) + + cfg.RemovePlugin(name) + return SavePluginsConfig(pm.configDir, cfg) +} diff --git a/pkg/plugin/registry.go b/pkg/plugin/registry.go new file mode 100644 index 00000000..a26d45c4 --- /dev/null +++ b/pkg/plugin/registry.go @@ -0,0 +1,148 @@ +package plugin + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + + "github.com/Masterminds/semver/v3" + "gopkg.in/yaml.v3" +) + +const ( + DefaultRegistryURL = "https://raw.githubusercontent.com/formancehq/poc-fctl-plugin-registry/main/registry.yaml" +) + +// RegistrySchema is the top-level registry structure. +type RegistrySchema struct { + Plugins map[string]RegistryPlugin `yaml:"plugins"` +} + +// RegistryPlugin describes a single plugin in the registry. +type RegistryPlugin struct { + Repo string `yaml:"repo"` + Type string `yaml:"type"` + Distribution string `yaml:"distribution"` + Versions map[string]RegistryPluginVersion `yaml:"versions"` +} + +// RegistryPluginVersion describes a specific version of a plugin. +type RegistryPluginVersion struct { + CompatibleWith string `yaml:"compatibleWith"` + Deprecated string `yaml:"deprecated,omitempty"` +} + +// FindBestVersion returns the highest plugin version whose compatibleWith range +// satisfies the given service version. +func (p *RegistryPlugin) FindBestVersion(serviceVersion string) (string, *RegistryPluginVersion, error) { + sv, err := semver.NewVersion(serviceVersion) + if err != nil { + return "", nil, fmt.Errorf("invalid service version %q: %w", serviceVersion, err) + } + + var bestVersion string + var bestSemver *semver.Version + var bestEntry RegistryPluginVersion + + for version, entry := range p.Versions { + constraint, err := semver.NewConstraint(entry.CompatibleWith) + if err != nil { + continue + } + if !constraint.Check(sv) { + continue + } + v, err := semver.NewVersion(version) + if err != nil { + continue + } + if bestSemver == nil || v.GreaterThan(bestSemver) { + bestVersion = version + bestSemver = v + bestEntry = entry + } + } + + if bestVersion == "" { + return "", nil, fmt.Errorf("no compatible version found for service version %s", serviceVersion) + } + + return bestVersion, &bestEntry, nil +} + +// BinaryURL derives the download URL for a plugin binary from convention. +// Pattern: https://github.com/{repo}/releases/download/v{version}/fctl-plugin-{name}-{os}-{arch} +func (p *RegistryPlugin) BinaryURL(pluginName, version string) string { + return fmt.Sprintf( + "https://github.com/%s/releases/download/v%s/fctl-plugin-%s-%s-%s", + p.Repo, version, pluginName, runtime.GOOS, runtime.GOARCH, + ) +} + +// RegistryClient fetches plugin information from the remote registry. +type RegistryClient struct { + URL string + HTTPClient *http.Client +} + +// NewRegistryClient creates a new registry client with the default URL. +func NewRegistryClient(httpClient *http.Client) *RegistryClient { + if httpClient == nil { + httpClient = http.DefaultClient + } + return &RegistryClient{ + URL: DefaultRegistryURL, + HTTPClient: httpClient, + } +} + +// FetchRegistry downloads and parses the registry YAML. +func (r *RegistryClient) FetchRegistry() (*RegistrySchema, error) { + resp, err := r.HTTPClient.Get(r.URL) + if err != nil { + return nil, fmt.Errorf("failed to fetch registry: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("registry returned status %d", resp.StatusCode) + } + + var reg RegistrySchema + if err := yaml.NewDecoder(resp.Body).Decode(®); err != nil { + return nil, fmt.Errorf("failed to decode registry: %w", err) + } + return ®, nil +} + +// DownloadBinary downloads a plugin binary from the given URL and saves it to disk. +func (r *RegistryClient) DownloadBinary(binaryURL, destPath string) error { + resp, err := r.HTTPClient.Get(binaryURL) + if err != nil { + return fmt.Errorf("failed to download plugin: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download returned status %d", resp.StatusCode) + } + + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { + return fmt.Errorf("failed to create plugin directory: %w", err) + } + + out, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return fmt.Errorf("failed to create plugin file: %w", err) + } + defer out.Close() + + if _, err := io.Copy(out, resp.Body); err != nil { + return fmt.Errorf("failed to write plugin file: %w", err) + } + + return nil +} diff --git a/pkg/plugin/registry_test.go b/pkg/plugin/registry_test.go new file mode 100644 index 00000000..cc74e9ce --- /dev/null +++ b/pkg/plugin/registry_test.go @@ -0,0 +1,66 @@ +package plugin + +import ( + "testing" +) + +func TestFindBestVersion(t *testing.T) { + p := &RegistryPlugin{ + Repo: "formancehq/ledger", + Versions: map[string]RegistryPluginVersion{ + "3.0.0": {CompatibleWith: ">= 3.0.0, < 3.3.0"}, + "3.3.0": {CompatibleWith: ">= 3.3.0, < 4.0.0"}, + "4.0.0": {CompatibleWith: ">= 4.0.0"}, + }, + } + + tests := []struct { + serviceVersion string + wantVersion string + wantErr bool + }{ + {"3.1.0", "3.0.0", false}, + {"3.0.0", "3.0.0", false}, + {"3.3.0", "3.3.0", false}, + {"3.5.2", "3.3.0", false}, + {"4.0.0", "4.0.0", false}, + {"2.9.0", "", true}, + } + + for _, tt := range tests { + t.Run(tt.serviceVersion, func(t *testing.T) { + got, _, err := p.FindBestVersion(tt.serviceVersion) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error for %q", tt.serviceVersion) + } + return + } + if err != nil { + t.Fatalf("unexpected error for %q: %v", tt.serviceVersion, err) + } + if got != tt.wantVersion { + t.Fatalf("FindBestVersion(%q): got=%q, want %q", tt.serviceVersion, got, tt.wantVersion) + } + }) + } +} + +func TestBinaryURL(t *testing.T) { + p := &RegistryPlugin{ + Repo: "formancehq/ledger", + } + + url := p.BinaryURL("ledger", "3.2.0") + + // We can't test exact URL since it depends on runtime.GOOS/GOARCH + // but we can verify it contains the expected parts + if url == "" { + t.Fatal("expected non-empty URL") + } + + expected := "https://github.com/formancehq/ledger/releases/download/v3.2.0/fctl-plugin-ledger-" + if len(url) < len(expected) || url[:len(expected)] != expected { + t.Fatalf("unexpected URL prefix: %s", url) + } +} diff --git a/pkg/plugin/render.go b/pkg/plugin/render.go new file mode 100644 index 00000000..1e591d84 --- /dev/null +++ b/pkg/plugin/render.go @@ -0,0 +1,189 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "github.com/pterm/pterm" + "gopkg.in/yaml.v3" + + "github.com/formancehq/fctl/v3/pkg/pluginsdk/pluginpb" +) + +// RenderFromSchema renders structured JSON data using the display schema +// declared in the plugin's command manifest. +func RenderFromSchema(out io.Writer, jsonData string, schema *pluginpb.DisplaySchema, outputFormat string) error { + switch outputFormat { + case "json": + return renderJSON(out, jsonData) + case "yaml": + return renderYAML(out, jsonData) + default: + return renderPlain(out, jsonData, schema) + } +} + +func renderJSON(out io.Writer, jsonData string) error { + var raw any + if err := json.Unmarshal([]byte(jsonData), &raw); err != nil { + _, err := fmt.Fprintln(out, jsonData) + return err + } + formatted, err := json.MarshalIndent(raw, "", " ") + if err != nil { + _, err := fmt.Fprintln(out, jsonData) + return err + } + _, err = fmt.Fprintln(out, string(formatted)) + return err +} + +func renderYAML(out io.Writer, jsonData string) error { + var raw any + if err := json.Unmarshal([]byte(jsonData), &raw); err != nil { + _, err := fmt.Fprintln(out, jsonData) + return err + } + yamlBytes, err := yaml.Marshal(raw) + if err != nil { + _, err := fmt.Fprintln(out, jsonData) + return err + } + _, err = out.Write(yamlBytes) + return err +} + +func renderPlain(out io.Writer, jsonData string, schema *pluginpb.DisplaySchema) error { + if schema == nil { + _, err := fmt.Fprintln(out, jsonData) + return err + } + + if len(schema.Columns) > 0 { + return renderTable(out, jsonData, schema.Columns) + } + + if len(schema.Sections) > 0 { + return renderSections(out, jsonData, schema.Sections) + } + + _, err := fmt.Fprintln(out, jsonData) + return err +} + +func renderTable(out io.Writer, jsonData string, columns []*pluginpb.ColumnSpec) error { + var items []map[string]any + + // Try array first, then single object + if err := json.Unmarshal([]byte(jsonData), &items); err != nil { + var single map[string]any + if err := json.Unmarshal([]byte(jsonData), &single); err != nil { + _, err := fmt.Fprintln(out, jsonData) + return err + } + items = []map[string]any{single} + } + + if len(items) == 0 { + _, err := fmt.Fprintln(out, "No results.") + return err + } + + headers := make([]string, len(columns)) + for i, col := range columns { + headers[i] = col.Header + } + + tableData := pterm.TableData{headers} + for _, item := range items { + row := make([]string, len(columns)) + for i, col := range columns { + val := extractJSONPath(item, col.JsonPath) + row[i] = formatValue(val, col.Format) + } + tableData = append(tableData, row) + } + + return pterm.DefaultTable. + WithHasHeader(). + WithWriter(out). + WithData(tableData). + Render() +} + +func renderSections(out io.Writer, jsonData string, sections []*pluginpb.SectionSpec) error { + var data map[string]any + if err := json.Unmarshal([]byte(jsonData), &data); err != nil { + _, err := fmt.Fprintln(out, jsonData) + return err + } + + for _, section := range sections { + if section.Title != "" { + fmt.Fprintf(out, "%s\n", section.Title) + fmt.Fprintf(out, "%s\n", strings.Repeat("─", len(section.Title))) + } + for _, field := range section.Fields { + val := extractJSONPath(data, field.JsonPath) + fmt.Fprintf(out, " %-20s %s\n", field.Label+":", formatValue(val, field.Format)) + } + fmt.Fprintln(out) + } + + return nil +} + +// extractJSONPath extracts a value from a map using a dot-separated path. +// Supports simple paths like "id", "metadata.key", "postings.0.amount". +func extractJSONPath(data map[string]any, path string) any { + parts := strings.Split(path, ".") + var current any = data + + for _, part := range parts { + switch v := current.(type) { + case map[string]any: + current = v[part] + case []any: + idx := 0 + if _, err := fmt.Sscanf(part, "%d", &idx); err == nil && idx < len(v) { + current = v[idx] + } else { + return nil + } + default: + return nil + } + } + + return current +} + +func formatValue(val any, format string) string { + if val == nil { + return "-" + } + + switch format { + case "timestamp": + if s, ok := val.(string); ok { + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t.Format(time.RFC3339) + } + if t, err := time.Parse(time.RFC3339Nano, s); err == nil { + return t.Format(time.RFC3339) + } + } + case "number": + if f, ok := val.(float64); ok { + if f == float64(int64(f)) { + return fmt.Sprintf("%d", int64(f)) + } + return fmt.Sprintf("%.2f", f) + } + } + + return fmt.Sprintf("%v", val) +} diff --git a/pkg/plugin/render_test.go b/pkg/plugin/render_test.go new file mode 100644 index 00000000..f1ba316c --- /dev/null +++ b/pkg/plugin/render_test.go @@ -0,0 +1,134 @@ +package plugin + +import ( + "bytes" + "strings" + "testing" + + "github.com/formancehq/fctl/v3/pkg/pluginsdk/pluginpb" +) + +func TestRenderTable(t *testing.T) { + schema := &pluginpb.DisplaySchema{ + Columns: []*pluginpb.ColumnSpec{ + {Header: "ID", JsonPath: "id", Format: "number"}, + {Header: "Reference", JsonPath: "reference"}, + {Header: "Timestamp", JsonPath: "timestamp", Format: "timestamp"}, + }, + } + + jsonData := `[ + {"id": 42, "reference": "ref-001", "timestamp": "2026-05-14T10:00:00Z"}, + {"id": 41, "reference": null, "timestamp": "2026-05-14T09:45:00Z"} + ]` + + var buf bytes.Buffer + if err := RenderFromSchema(&buf, jsonData, schema, "plain"); err != nil { + t.Fatal(err) + } + + out := buf.String() + if !strings.Contains(out, "ID") { + t.Fatal("expected header ID in output") + } + if !strings.Contains(out, "42") { + t.Fatal("expected value 42 in output") + } + if !strings.Contains(out, "ref-001") { + t.Fatal("expected ref-001 in output") + } +} + +func TestRenderSections(t *testing.T) { + schema := &pluginpb.DisplaySchema{ + Sections: []*pluginpb.SectionSpec{ + { + Title: "Transaction", + Fields: []*pluginpb.FieldSpec{ + {Label: "ID", JsonPath: "id", Format: "number"}, + {Label: "Reference", JsonPath: "reference"}, + {Label: "Status", JsonPath: "status"}, + }, + }, + }, + } + + jsonData := `{"id": 42, "reference": "ref-001", "status": "committed"}` + + var buf bytes.Buffer + if err := RenderFromSchema(&buf, jsonData, schema, "plain"); err != nil { + t.Fatal(err) + } + + out := buf.String() + if !strings.Contains(out, "Transaction") { + t.Fatal("expected section title") + } + if !strings.Contains(out, "42") { + t.Fatal("expected ID value") + } + if !strings.Contains(out, "committed") { + t.Fatal("expected status value") + } +} + +func TestRenderJSON(t *testing.T) { + schema := &pluginpb.DisplaySchema{ + Columns: []*pluginpb.ColumnSpec{ + {Header: "ID", JsonPath: "id"}, + }, + } + + jsonData := `[{"id": 1}]` + + var buf bytes.Buffer + if err := RenderFromSchema(&buf, jsonData, schema, "json"); err != nil { + t.Fatal(err) + } + + out := buf.String() + if !strings.Contains(out, `"id"`) { + t.Fatal("expected json output") + } +} + +func TestRenderYAML(t *testing.T) { + schema := &pluginpb.DisplaySchema{ + Columns: []*pluginpb.ColumnSpec{ + {Header: "ID", JsonPath: "id"}, + }, + } + + jsonData := `[{"id": 1, "name": "test"}]` + + var buf bytes.Buffer + if err := RenderFromSchema(&buf, jsonData, schema, "yaml"); err != nil { + t.Fatal(err) + } + + out := buf.String() + if !strings.Contains(out, "name: test") { + t.Fatalf("expected yaml output, got: %s", out) + } +} + +func TestExtractJSONPathNested(t *testing.T) { + data := map[string]any{ + "metadata": map[string]any{ + "key": "value", + }, + } + + val := extractJSONPath(data, "metadata.key") + if val != "value" { + t.Fatalf("expected 'value', got %v", val) + } +} + +func TestExtractJSONPathMissing(t *testing.T) { + data := map[string]any{"id": 1} + val := extractJSONPath(data, "missing.path") + if val != nil { + t.Fatalf("expected nil, got %v", val) + } +} diff --git a/pkg/plugin/resolver.go b/pkg/plugin/resolver.go new file mode 100644 index 00000000..da3ff4c7 --- /dev/null +++ b/pkg/plugin/resolver.go @@ -0,0 +1,77 @@ +package plugin + +// Resolution represents the outcome of resolving how to handle a service command. +type Resolution interface { + resolution() +} + +// UsePlugin indicates that an installed plugin should handle the command. +type UsePlugin struct { + Plugin *LoadedPlugin +} + +func (UsePlugin) resolution() {} + +// UseBuiltIn indicates that the built-in command should handle it. +type UseBuiltIn struct{} + +func (UseBuiltIn) resolution() {} + +// NeedInstall indicates that a plugin is needed but not installed. +// The caller should trigger auto-discovery. +type NeedInstall struct { + ServiceName string + ServiceVersion string + PluginVersion string + RegistryPlugin *RegistryPlugin +} + +func (NeedInstall) resolution() {} + +// Resolve determines whether a service command should be handled by a plugin, +// the built-in implementation, or needs a plugin install. +// +// Resolution order: +// 1. If an installed plugin has a compatibleWith range matching the service version → UsePlugin +// 2. If no plugin matches but builtInCovers is true → UseBuiltIn +// 3. If neither, look up the registry for a compatible version → NeedInstall +// 4. If the registry has no match either → UseBuiltIn (fallback, let it fail naturally) +func Resolve( + serviceName string, + serviceVersion string, + manager *PluginManager, + registry *RegistryClient, + builtInCovers bool, +) (Resolution, error) { + // 1. Check installed plugins + plugin := manager.FindPluginForService(serviceName, serviceVersion) + if plugin != nil { + return UsePlugin{Plugin: plugin}, nil + } + + // 2. Built-in fallback + if builtInCovers { + return UseBuiltIn{}, nil + } + + // 3. Check registry for auto-discovery + if registry != nil { + reg, err := registry.FetchRegistry() + if err == nil { + if regPlugin, ok := reg.Plugins[serviceName]; ok { + bestVersion, _, err := regPlugin.FindBestVersion(serviceVersion) + if err == nil { + return NeedInstall{ + ServiceName: serviceName, + ServiceVersion: serviceVersion, + PluginVersion: bestVersion, + RegistryPlugin: ®Plugin, + }, nil + } + } + } + } + + // 4. Nothing works — fall back to built-in and let it fail naturally + return UseBuiltIn{}, nil +} diff --git a/pkg/plugin/resolver_test.go b/pkg/plugin/resolver_test.go new file mode 100644 index 00000000..22abd662 --- /dev/null +++ b/pkg/plugin/resolver_test.go @@ -0,0 +1,94 @@ +package plugin + +import ( + "testing" +) + +func TestResolveUsesInstalledPlugin(t *testing.T) { + tmpDir := t.TempDir() + pm := NewPluginManager(tmpDir, false) + + // Simulate a loaded plugin + pm.loaded = append(pm.loaded, &LoadedPlugin{ + Name: "ledger", + Version: "3.2.0", + CompatibleWith: ">= 3.0.0", + }) + + // Save matching config so FindPluginForService works + cfg := &PluginsConfig{} + cfg.AddPluginVersion("ledger", "3.2.0", InstalledPluginVersion{ + CompatibleWith: ">= 3.0.0", + }) + if err := SavePluginsConfig(tmpDir, cfg); err != nil { + t.Fatal(err) + } + + res, err := Resolve("ledger", "3.1.0", pm, nil, false) + if err != nil { + t.Fatal(err) + } + + if _, ok := res.(UsePlugin); !ok { + t.Fatalf("expected UsePlugin, got %T", res) + } +} + +func TestResolveBuiltInFallback(t *testing.T) { + tmpDir := t.TempDir() + pm := NewPluginManager(tmpDir, false) + + res, err := Resolve("ledger", "2.8.0", pm, nil, true) + if err != nil { + t.Fatal(err) + } + + if _, ok := res.(UseBuiltIn); !ok { + t.Fatalf("expected UseBuiltIn, got %T", res) + } +} + +func TestResolvePluginTakesPrecedenceOverBuiltIn(t *testing.T) { + tmpDir := t.TempDir() + pm := NewPluginManager(tmpDir, false) + + pm.loaded = append(pm.loaded, &LoadedPlugin{ + Name: "ledger", + Version: "3.2.0", + CompatibleWith: ">= 3.0.0", + }) + + cfg := &PluginsConfig{} + cfg.AddPluginVersion("ledger", "3.2.0", InstalledPluginVersion{ + CompatibleWith: ">= 3.0.0", + }) + if err := SavePluginsConfig(tmpDir, cfg); err != nil { + t.Fatal(err) + } + + // builtInCovers is true but plugin should still win + res, err := Resolve("ledger", "3.1.0", pm, nil, true) + if err != nil { + t.Fatal(err) + } + + if _, ok := res.(UsePlugin); !ok { + t.Fatalf("expected UsePlugin (takes precedence), got %T", res) + } +} + +func TestResolveFallsBackToBuiltInWhenNoPluginAndNoRegistry(t *testing.T) { + tmpDir := t.TempDir() + pm := NewPluginManager(tmpDir, false) + + // No plugin, no registry, builtInCovers = false + res, err := Resolve("ledger", "3.1.0", pm, nil, false) + if err != nil { + t.Fatal(err) + } + + // Should still return UseBuiltIn as final fallback + if _, ok := res.(UseBuiltIn); !ok { + t.Fatalf("expected UseBuiltIn as fallback, got %T", res) + } +} diff --git a/pkg/plugin/versions.go b/pkg/plugin/versions.go new file mode 100644 index 00000000..10372cb4 --- /dev/null +++ b/pkg/plugin/versions.go @@ -0,0 +1,52 @@ +package plugin + +import ( + "context" + "fmt" + "net/http" + + formance "github.com/formancehq/formance-sdk-go/v3" +) + +// ServiceVersions maps service names to their versions (e.g. "ledger" -> "3.1.0"). +type ServiceVersions map[string]string + +// DetectServiceVersions calls the stack's /versions endpoint and returns a map +// of service name to version string. Works for both Cloud and direct stack profiles +// since both expose the same endpoint. +func DetectServiceVersions(ctx context.Context, stackClient *formance.Formance) (ServiceVersions, error) { + resp, err := stackClient.GetVersions(ctx) + if err != nil { + return nil, fmt.Errorf("failed to call /versions: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d from /versions", resp.StatusCode) + } + + if resp.GetVersionsResponse == nil { + return nil, fmt.Errorf("/versions returned no data") + } + + versions := make(ServiceVersions) + for _, v := range resp.GetVersionsResponse.GetVersions() { + versions[v.GetName()] = v.GetVersion() + } + + return versions, nil +} + +// DetectServiceVersion returns the version of a specific service on the stack. +func DetectServiceVersion(ctx context.Context, stackClient *formance.Formance, serviceName string) (string, error) { + versions, err := DetectServiceVersions(ctx, stackClient) + if err != nil { + return "", err + } + + version, ok := versions[serviceName] + if !ok { + return "", fmt.Errorf("service %q not found in /versions response", serviceName) + } + + return version, nil +} diff --git a/pkg/pluginsdk/go.mod b/pkg/pluginsdk/go.mod new file mode 100644 index 00000000..3edd7164 --- /dev/null +++ b/pkg/pluginsdk/go.mod @@ -0,0 +1,23 @@ +module github.com/formancehq/fctl/v3/pkg/pluginsdk + +go 1.24 + +require ( + github.com/hashicorp/go-plugin v1.7.0 + google.golang.org/grpc v1.72.1 + google.golang.org/protobuf v1.36.6 +) + +require ( + github.com/fatih/color v1.18.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/oklog/run v1.1.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f // indirect +) diff --git a/pkg/pluginsdk/go.sum b/pkg/pluginsdk/go.sum new file mode 100644 index 00000000..ed228e74 --- /dev/null +++ b/pkg/pluginsdk/go.sum @@ -0,0 +1,74 @@ +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= +github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +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= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f h1:N/PrbTw4kdkqNRzVfWPrBekzLuarFREcbFOiOLkXon4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= +google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/pluginsdk/plugin.proto b/pkg/pluginsdk/plugin.proto new file mode 100644 index 00000000..d33a7066 --- /dev/null +++ b/pkg/pluginsdk/plugin.proto @@ -0,0 +1,163 @@ +syntax = "proto3"; + +package fctl.plugin.v1; + +option go_package = "github.com/formancehq/fctl/v3/pkg/pluginsdk/pluginpb"; + +// PluginService is the gRPC service that every fctl plugin must implement. +service PluginService { + // GetManifest returns the plugin's command tree and metadata. + rpc GetManifest(GetManifestRequest) returns (GetManifestResponse); + + // Execute runs a specific command within the plugin. + rpc Execute(ExecuteRequest) returns (ExecuteResponse); +} + +// GetManifestRequest is currently empty; reserved for future fields. +message GetManifestRequest {} + +// GetManifestResponse wraps the plugin manifest. +message GetManifestResponse { + PluginManifest manifest = 1; +} + +// PluginManifest describes a plugin and its command tree. +message PluginManifest { + string name = 1; + string version = 2; + string description = 3; + CommandSpec root_command = 4; +} + +// CommandType determines which auth context is required. +enum CommandType { + COMMAND_TYPE_BASIC = 0; + COMMAND_TYPE_MEMBERSHIP = 1; + COMMAND_TYPE_STACK = 2; +} + +// FlagType describes the data type of a CLI flag. +enum FlagType { + FLAG_TYPE_STRING = 0; + FLAG_TYPE_BOOL = 1; + FLAG_TYPE_INT = 2; + FLAG_TYPE_STRING_SLICE = 3; +} + +// CommandSpec describes a single CLI command and its children (recursive tree). +message CommandSpec { + string use = 1; + repeated string aliases = 2; + string short = 3; + string long = 4; + repeated FlagSpec flags = 5; + repeated FlagSpec persistent_flags = 6; + repeated CommandSpec subcommands = 7; + bool runnable = 8; + CommandType command_type = 9; + bool hidden = 10; + string deprecated = 11; + + // args_constraint defines the positional args constraint. + // Values: "exact:N", "min:N", "max:N", "range:N:M", "none", "any" + string args_constraint = 12; + + // confirm indicates the command has a --confirm flag for destructive actions. + bool confirm = 13; + + // display defines how fctl core should render the command's output. + // If set, fctl renders from json_data using this schema instead of using rendered_text. + DisplaySchema display = 14; +} + +// DisplaySchema tells fctl core how to render structured output from a plugin. +message DisplaySchema { + // columns defines table columns for list commands. + repeated ColumnSpec columns = 1; + + // sections defines labeled sections for show/inspect commands. + repeated SectionSpec sections = 2; +} + +// ColumnSpec describes a single column in a table output. +message ColumnSpec { + string header = 1; // Column header, e.g. "ID" + string json_path = 2; // JSON path to extract, e.g. "id" or "metadata.key" + string format = 3; // Optional format hint: "timestamp", "number", "string" +} + +// SectionSpec describes a labeled section for detail views. +message SectionSpec { + string title = 1; + repeated FieldSpec fields = 2; +} + +// FieldSpec describes a single field in a detail section. +message FieldSpec { + string label = 1; // Display label, e.g. "Created at" + string json_path = 2; // JSON path to extract + string format = 3; // Optional format hint +} + +// FlagSpec describes a single CLI flag. +message FlagSpec { + string name = 1; + string shorthand = 2; + string default_value = 3; + string description = 4; + FlagType type = 5; + bool persistent = 6; +} + +// ExecuteRequest contains everything a plugin needs to execute a command. +message ExecuteRequest { + // command_path is the slash-separated path, e.g. "ledger/transactions/list". + string command_path = 1; + + // args are the positional arguments. + repeated string args = 2; + + // flags are the resolved flag values keyed by flag name. + map flags = 3; + + // auth_context provides authentication details from the core. + AuthContext auth_context = 4; + + // output_format is "plain" or "json". + string output_format = 5; +} + +// AuthContext carries all authentication information from the fctl core. +message AuthContext { + string stack_url = 1; + string access_token = 2; + string organization_id = 3; + string stack_id = 4; + string membership_url = 5; + string membership_token = 6; + bool insecure_tls = 7; + bool debug = 8; +} + +// ExecuteResponse is the result of a command execution. +message ExecuteResponse { + oneof result { + ExecuteSuccess success = 1; + ExecuteError error = 2; + } +} + +// ExecuteSuccess carries the successful output. +message ExecuteSuccess { + // json_data is the structured data (for --output=json or Store equivalent). + string json_data = 1; + + // rendered_text is the human-readable output (pterm tables, etc.). + string rendered_text = 2; +} + +// ExecuteError describes a failed execution. +message ExecuteError { + string message = 1; + int32 code = 2; +} diff --git a/pkg/pluginsdk/pluginpb/plugin.pb.go b/pkg/pluginsdk/pluginpb/plugin.pb.go new file mode 100644 index 00000000..a80d90e7 --- /dev/null +++ b/pkg/pluginsdk/pluginpb/plugin.pb.go @@ -0,0 +1,1305 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v7.34.1 +// source: plugin.proto + +package pluginpb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// CommandType determines which auth context is required. +type CommandType int32 + +const ( + CommandType_COMMAND_TYPE_BASIC CommandType = 0 + CommandType_COMMAND_TYPE_MEMBERSHIP CommandType = 1 + CommandType_COMMAND_TYPE_STACK CommandType = 2 +) + +// Enum value maps for CommandType. +var ( + CommandType_name = map[int32]string{ + 0: "COMMAND_TYPE_BASIC", + 1: "COMMAND_TYPE_MEMBERSHIP", + 2: "COMMAND_TYPE_STACK", + } + CommandType_value = map[string]int32{ + "COMMAND_TYPE_BASIC": 0, + "COMMAND_TYPE_MEMBERSHIP": 1, + "COMMAND_TYPE_STACK": 2, + } +) + +func (x CommandType) Enum() *CommandType { + p := new(CommandType) + *p = x + return p +} + +func (x CommandType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (CommandType) Descriptor() protoreflect.EnumDescriptor { + return file_plugin_proto_enumTypes[0].Descriptor() +} + +func (CommandType) Type() protoreflect.EnumType { + return &file_plugin_proto_enumTypes[0] +} + +func (x CommandType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use CommandType.Descriptor instead. +func (CommandType) EnumDescriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{0} +} + +// FlagType describes the data type of a CLI flag. +type FlagType int32 + +const ( + FlagType_FLAG_TYPE_STRING FlagType = 0 + FlagType_FLAG_TYPE_BOOL FlagType = 1 + FlagType_FLAG_TYPE_INT FlagType = 2 + FlagType_FLAG_TYPE_STRING_SLICE FlagType = 3 +) + +// Enum value maps for FlagType. +var ( + FlagType_name = map[int32]string{ + 0: "FLAG_TYPE_STRING", + 1: "FLAG_TYPE_BOOL", + 2: "FLAG_TYPE_INT", + 3: "FLAG_TYPE_STRING_SLICE", + } + FlagType_value = map[string]int32{ + "FLAG_TYPE_STRING": 0, + "FLAG_TYPE_BOOL": 1, + "FLAG_TYPE_INT": 2, + "FLAG_TYPE_STRING_SLICE": 3, + } +) + +func (x FlagType) Enum() *FlagType { + p := new(FlagType) + *p = x + return p +} + +func (x FlagType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (FlagType) Descriptor() protoreflect.EnumDescriptor { + return file_plugin_proto_enumTypes[1].Descriptor() +} + +func (FlagType) Type() protoreflect.EnumType { + return &file_plugin_proto_enumTypes[1] +} + +func (x FlagType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use FlagType.Descriptor instead. +func (FlagType) EnumDescriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{1} +} + +// GetManifestRequest is currently empty; reserved for future fields. +type GetManifestRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetManifestRequest) Reset() { + *x = GetManifestRequest{} + mi := &file_plugin_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetManifestRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetManifestRequest) ProtoMessage() {} + +func (x *GetManifestRequest) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetManifestRequest.ProtoReflect.Descriptor instead. +func (*GetManifestRequest) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{0} +} + +// GetManifestResponse wraps the plugin manifest. +type GetManifestResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Manifest *PluginManifest `protobuf:"bytes,1,opt,name=manifest,proto3" json:"manifest,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetManifestResponse) Reset() { + *x = GetManifestResponse{} + mi := &file_plugin_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetManifestResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetManifestResponse) ProtoMessage() {} + +func (x *GetManifestResponse) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetManifestResponse.ProtoReflect.Descriptor instead. +func (*GetManifestResponse) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{1} +} + +func (x *GetManifestResponse) GetManifest() *PluginManifest { + if x != nil { + return x.Manifest + } + return nil +} + +// PluginManifest describes a plugin and its command tree. +type PluginManifest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + RootCommand *CommandSpec `protobuf:"bytes,4,opt,name=root_command,json=rootCommand,proto3" json:"root_command,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PluginManifest) Reset() { + *x = PluginManifest{} + mi := &file_plugin_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PluginManifest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PluginManifest) ProtoMessage() {} + +func (x *PluginManifest) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PluginManifest.ProtoReflect.Descriptor instead. +func (*PluginManifest) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{2} +} + +func (x *PluginManifest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *PluginManifest) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *PluginManifest) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *PluginManifest) GetRootCommand() *CommandSpec { + if x != nil { + return x.RootCommand + } + return nil +} + +// CommandSpec describes a single CLI command and its children (recursive tree). +type CommandSpec struct { + state protoimpl.MessageState `protogen:"open.v1"` + Use string `protobuf:"bytes,1,opt,name=use,proto3" json:"use,omitempty"` + Aliases []string `protobuf:"bytes,2,rep,name=aliases,proto3" json:"aliases,omitempty"` + Short string `protobuf:"bytes,3,opt,name=short,proto3" json:"short,omitempty"` + Long string `protobuf:"bytes,4,opt,name=long,proto3" json:"long,omitempty"` + Flags []*FlagSpec `protobuf:"bytes,5,rep,name=flags,proto3" json:"flags,omitempty"` + PersistentFlags []*FlagSpec `protobuf:"bytes,6,rep,name=persistent_flags,json=persistentFlags,proto3" json:"persistent_flags,omitempty"` + Subcommands []*CommandSpec `protobuf:"bytes,7,rep,name=subcommands,proto3" json:"subcommands,omitempty"` + Runnable bool `protobuf:"varint,8,opt,name=runnable,proto3" json:"runnable,omitempty"` + CommandType CommandType `protobuf:"varint,9,opt,name=command_type,json=commandType,proto3,enum=fctl.plugin.v1.CommandType" json:"command_type,omitempty"` + Hidden bool `protobuf:"varint,10,opt,name=hidden,proto3" json:"hidden,omitempty"` + Deprecated string `protobuf:"bytes,11,opt,name=deprecated,proto3" json:"deprecated,omitempty"` + // args_constraint defines the positional args constraint. + // Values: "exact:N", "min:N", "max:N", "range:N:M", "none", "any" + ArgsConstraint string `protobuf:"bytes,12,opt,name=args_constraint,json=argsConstraint,proto3" json:"args_constraint,omitempty"` + // confirm indicates the command has a --confirm flag for destructive actions. + Confirm bool `protobuf:"varint,13,opt,name=confirm,proto3" json:"confirm,omitempty"` + // display defines how fctl core should render the command's output. + // If set, fctl renders from json_data using this schema instead of using rendered_text. + Display *DisplaySchema `protobuf:"bytes,14,opt,name=display,proto3" json:"display,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CommandSpec) Reset() { + *x = CommandSpec{} + mi := &file_plugin_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CommandSpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CommandSpec) ProtoMessage() {} + +func (x *CommandSpec) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CommandSpec.ProtoReflect.Descriptor instead. +func (*CommandSpec) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{3} +} + +func (x *CommandSpec) GetUse() string { + if x != nil { + return x.Use + } + return "" +} + +func (x *CommandSpec) GetAliases() []string { + if x != nil { + return x.Aliases + } + return nil +} + +func (x *CommandSpec) GetShort() string { + if x != nil { + return x.Short + } + return "" +} + +func (x *CommandSpec) GetLong() string { + if x != nil { + return x.Long + } + return "" +} + +func (x *CommandSpec) GetFlags() []*FlagSpec { + if x != nil { + return x.Flags + } + return nil +} + +func (x *CommandSpec) GetPersistentFlags() []*FlagSpec { + if x != nil { + return x.PersistentFlags + } + return nil +} + +func (x *CommandSpec) GetSubcommands() []*CommandSpec { + if x != nil { + return x.Subcommands + } + return nil +} + +func (x *CommandSpec) GetRunnable() bool { + if x != nil { + return x.Runnable + } + return false +} + +func (x *CommandSpec) GetCommandType() CommandType { + if x != nil { + return x.CommandType + } + return CommandType_COMMAND_TYPE_BASIC +} + +func (x *CommandSpec) GetHidden() bool { + if x != nil { + return x.Hidden + } + return false +} + +func (x *CommandSpec) GetDeprecated() string { + if x != nil { + return x.Deprecated + } + return "" +} + +func (x *CommandSpec) GetArgsConstraint() string { + if x != nil { + return x.ArgsConstraint + } + return "" +} + +func (x *CommandSpec) GetConfirm() bool { + if x != nil { + return x.Confirm + } + return false +} + +func (x *CommandSpec) GetDisplay() *DisplaySchema { + if x != nil { + return x.Display + } + return nil +} + +// DisplaySchema tells fctl core how to render structured output from a plugin. +type DisplaySchema struct { + state protoimpl.MessageState `protogen:"open.v1"` + // columns defines table columns for list commands. + Columns []*ColumnSpec `protobuf:"bytes,1,rep,name=columns,proto3" json:"columns,omitempty"` + // sections defines labeled sections for show/inspect commands. + Sections []*SectionSpec `protobuf:"bytes,2,rep,name=sections,proto3" json:"sections,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DisplaySchema) Reset() { + *x = DisplaySchema{} + mi := &file_plugin_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DisplaySchema) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DisplaySchema) ProtoMessage() {} + +func (x *DisplaySchema) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DisplaySchema.ProtoReflect.Descriptor instead. +func (*DisplaySchema) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{4} +} + +func (x *DisplaySchema) GetColumns() []*ColumnSpec { + if x != nil { + return x.Columns + } + return nil +} + +func (x *DisplaySchema) GetSections() []*SectionSpec { + if x != nil { + return x.Sections + } + return nil +} + +// ColumnSpec describes a single column in a table output. +type ColumnSpec struct { + state protoimpl.MessageState `protogen:"open.v1"` + Header string `protobuf:"bytes,1,opt,name=header,proto3" json:"header,omitempty"` // Column header, e.g. "ID" + JsonPath string `protobuf:"bytes,2,opt,name=json_path,json=jsonPath,proto3" json:"json_path,omitempty"` // JSON path to extract, e.g. "id" or "metadata.key" + Format string `protobuf:"bytes,3,opt,name=format,proto3" json:"format,omitempty"` // Optional format hint: "timestamp", "number", "string" + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ColumnSpec) Reset() { + *x = ColumnSpec{} + mi := &file_plugin_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ColumnSpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ColumnSpec) ProtoMessage() {} + +func (x *ColumnSpec) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ColumnSpec.ProtoReflect.Descriptor instead. +func (*ColumnSpec) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{5} +} + +func (x *ColumnSpec) GetHeader() string { + if x != nil { + return x.Header + } + return "" +} + +func (x *ColumnSpec) GetJsonPath() string { + if x != nil { + return x.JsonPath + } + return "" +} + +func (x *ColumnSpec) GetFormat() string { + if x != nil { + return x.Format + } + return "" +} + +// SectionSpec describes a labeled section for detail views. +type SectionSpec struct { + state protoimpl.MessageState `protogen:"open.v1"` + Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"` + Fields []*FieldSpec `protobuf:"bytes,2,rep,name=fields,proto3" json:"fields,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SectionSpec) Reset() { + *x = SectionSpec{} + mi := &file_plugin_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SectionSpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SectionSpec) ProtoMessage() {} + +func (x *SectionSpec) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SectionSpec.ProtoReflect.Descriptor instead. +func (*SectionSpec) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{6} +} + +func (x *SectionSpec) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *SectionSpec) GetFields() []*FieldSpec { + if x != nil { + return x.Fields + } + return nil +} + +// FieldSpec describes a single field in a detail section. +type FieldSpec struct { + state protoimpl.MessageState `protogen:"open.v1"` + Label string `protobuf:"bytes,1,opt,name=label,proto3" json:"label,omitempty"` // Display label, e.g. "Created at" + JsonPath string `protobuf:"bytes,2,opt,name=json_path,json=jsonPath,proto3" json:"json_path,omitempty"` // JSON path to extract + Format string `protobuf:"bytes,3,opt,name=format,proto3" json:"format,omitempty"` // Optional format hint + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FieldSpec) Reset() { + *x = FieldSpec{} + mi := &file_plugin_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FieldSpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FieldSpec) ProtoMessage() {} + +func (x *FieldSpec) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FieldSpec.ProtoReflect.Descriptor instead. +func (*FieldSpec) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{7} +} + +func (x *FieldSpec) GetLabel() string { + if x != nil { + return x.Label + } + return "" +} + +func (x *FieldSpec) GetJsonPath() string { + if x != nil { + return x.JsonPath + } + return "" +} + +func (x *FieldSpec) GetFormat() string { + if x != nil { + return x.Format + } + return "" +} + +// FlagSpec describes a single CLI flag. +type FlagSpec struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Shorthand string `protobuf:"bytes,2,opt,name=shorthand,proto3" json:"shorthand,omitempty"` + DefaultValue string `protobuf:"bytes,3,opt,name=default_value,json=defaultValue,proto3" json:"default_value,omitempty"` + Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` + Type FlagType `protobuf:"varint,5,opt,name=type,proto3,enum=fctl.plugin.v1.FlagType" json:"type,omitempty"` + Persistent bool `protobuf:"varint,6,opt,name=persistent,proto3" json:"persistent,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FlagSpec) Reset() { + *x = FlagSpec{} + mi := &file_plugin_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FlagSpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FlagSpec) ProtoMessage() {} + +func (x *FlagSpec) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FlagSpec.ProtoReflect.Descriptor instead. +func (*FlagSpec) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{8} +} + +func (x *FlagSpec) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *FlagSpec) GetShorthand() string { + if x != nil { + return x.Shorthand + } + return "" +} + +func (x *FlagSpec) GetDefaultValue() string { + if x != nil { + return x.DefaultValue + } + return "" +} + +func (x *FlagSpec) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *FlagSpec) GetType() FlagType { + if x != nil { + return x.Type + } + return FlagType_FLAG_TYPE_STRING +} + +func (x *FlagSpec) GetPersistent() bool { + if x != nil { + return x.Persistent + } + return false +} + +// ExecuteRequest contains everything a plugin needs to execute a command. +type ExecuteRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // command_path is the slash-separated path, e.g. "ledger/transactions/list". + CommandPath string `protobuf:"bytes,1,opt,name=command_path,json=commandPath,proto3" json:"command_path,omitempty"` + // args are the positional arguments. + Args []string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"` + // flags are the resolved flag values keyed by flag name. + Flags map[string]string `protobuf:"bytes,3,rep,name=flags,proto3" json:"flags,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // auth_context provides authentication details from the core. + AuthContext *AuthContext `protobuf:"bytes,4,opt,name=auth_context,json=authContext,proto3" json:"auth_context,omitempty"` + // output_format is "plain" or "json". + OutputFormat string `protobuf:"bytes,5,opt,name=output_format,json=outputFormat,proto3" json:"output_format,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecuteRequest) Reset() { + *x = ExecuteRequest{} + mi := &file_plugin_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecuteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecuteRequest) ProtoMessage() {} + +func (x *ExecuteRequest) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecuteRequest.ProtoReflect.Descriptor instead. +func (*ExecuteRequest) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{9} +} + +func (x *ExecuteRequest) GetCommandPath() string { + if x != nil { + return x.CommandPath + } + return "" +} + +func (x *ExecuteRequest) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + +func (x *ExecuteRequest) GetFlags() map[string]string { + if x != nil { + return x.Flags + } + return nil +} + +func (x *ExecuteRequest) GetAuthContext() *AuthContext { + if x != nil { + return x.AuthContext + } + return nil +} + +func (x *ExecuteRequest) GetOutputFormat() string { + if x != nil { + return x.OutputFormat + } + return "" +} + +// AuthContext carries all authentication information from the fctl core. +type AuthContext struct { + state protoimpl.MessageState `protogen:"open.v1"` + StackUrl string `protobuf:"bytes,1,opt,name=stack_url,json=stackUrl,proto3" json:"stack_url,omitempty"` + AccessToken string `protobuf:"bytes,2,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"` + OrganizationId string `protobuf:"bytes,3,opt,name=organization_id,json=organizationId,proto3" json:"organization_id,omitempty"` + StackId string `protobuf:"bytes,4,opt,name=stack_id,json=stackId,proto3" json:"stack_id,omitempty"` + MembershipUrl string `protobuf:"bytes,5,opt,name=membership_url,json=membershipUrl,proto3" json:"membership_url,omitempty"` + MembershipToken string `protobuf:"bytes,6,opt,name=membership_token,json=membershipToken,proto3" json:"membership_token,omitempty"` + InsecureTls bool `protobuf:"varint,7,opt,name=insecure_tls,json=insecureTls,proto3" json:"insecure_tls,omitempty"` + Debug bool `protobuf:"varint,8,opt,name=debug,proto3" json:"debug,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AuthContext) Reset() { + *x = AuthContext{} + mi := &file_plugin_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AuthContext) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AuthContext) ProtoMessage() {} + +func (x *AuthContext) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AuthContext.ProtoReflect.Descriptor instead. +func (*AuthContext) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{10} +} + +func (x *AuthContext) GetStackUrl() string { + if x != nil { + return x.StackUrl + } + return "" +} + +func (x *AuthContext) GetAccessToken() string { + if x != nil { + return x.AccessToken + } + return "" +} + +func (x *AuthContext) GetOrganizationId() string { + if x != nil { + return x.OrganizationId + } + return "" +} + +func (x *AuthContext) GetStackId() string { + if x != nil { + return x.StackId + } + return "" +} + +func (x *AuthContext) GetMembershipUrl() string { + if x != nil { + return x.MembershipUrl + } + return "" +} + +func (x *AuthContext) GetMembershipToken() string { + if x != nil { + return x.MembershipToken + } + return "" +} + +func (x *AuthContext) GetInsecureTls() bool { + if x != nil { + return x.InsecureTls + } + return false +} + +func (x *AuthContext) GetDebug() bool { + if x != nil { + return x.Debug + } + return false +} + +// ExecuteResponse is the result of a command execution. +type ExecuteResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Result: + // + // *ExecuteResponse_Success + // *ExecuteResponse_Error + Result isExecuteResponse_Result `protobuf_oneof:"result"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecuteResponse) Reset() { + *x = ExecuteResponse{} + mi := &file_plugin_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecuteResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecuteResponse) ProtoMessage() {} + +func (x *ExecuteResponse) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecuteResponse.ProtoReflect.Descriptor instead. +func (*ExecuteResponse) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{11} +} + +func (x *ExecuteResponse) GetResult() isExecuteResponse_Result { + if x != nil { + return x.Result + } + return nil +} + +func (x *ExecuteResponse) GetSuccess() *ExecuteSuccess { + if x != nil { + if x, ok := x.Result.(*ExecuteResponse_Success); ok { + return x.Success + } + } + return nil +} + +func (x *ExecuteResponse) GetError() *ExecuteError { + if x != nil { + if x, ok := x.Result.(*ExecuteResponse_Error); ok { + return x.Error + } + } + return nil +} + +type isExecuteResponse_Result interface { + isExecuteResponse_Result() +} + +type ExecuteResponse_Success struct { + Success *ExecuteSuccess `protobuf:"bytes,1,opt,name=success,proto3,oneof"` +} + +type ExecuteResponse_Error struct { + Error *ExecuteError `protobuf:"bytes,2,opt,name=error,proto3,oneof"` +} + +func (*ExecuteResponse_Success) isExecuteResponse_Result() {} + +func (*ExecuteResponse_Error) isExecuteResponse_Result() {} + +// ExecuteSuccess carries the successful output. +type ExecuteSuccess struct { + state protoimpl.MessageState `protogen:"open.v1"` + // json_data is the structured data (for --output=json or Store equivalent). + JsonData string `protobuf:"bytes,1,opt,name=json_data,json=jsonData,proto3" json:"json_data,omitempty"` + // rendered_text is the human-readable output (pterm tables, etc.). + RenderedText string `protobuf:"bytes,2,opt,name=rendered_text,json=renderedText,proto3" json:"rendered_text,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecuteSuccess) Reset() { + *x = ExecuteSuccess{} + mi := &file_plugin_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecuteSuccess) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecuteSuccess) ProtoMessage() {} + +func (x *ExecuteSuccess) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecuteSuccess.ProtoReflect.Descriptor instead. +func (*ExecuteSuccess) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{12} +} + +func (x *ExecuteSuccess) GetJsonData() string { + if x != nil { + return x.JsonData + } + return "" +} + +func (x *ExecuteSuccess) GetRenderedText() string { + if x != nil { + return x.RenderedText + } + return "" +} + +// ExecuteError describes a failed execution. +type ExecuteError struct { + state protoimpl.MessageState `protogen:"open.v1"` + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + Code int32 `protobuf:"varint,2,opt,name=code,proto3" json:"code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecuteError) Reset() { + *x = ExecuteError{} + mi := &file_plugin_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecuteError) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecuteError) ProtoMessage() {} + +func (x *ExecuteError) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecuteError.ProtoReflect.Descriptor instead. +func (*ExecuteError) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{13} +} + +func (x *ExecuteError) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *ExecuteError) GetCode() int32 { + if x != nil { + return x.Code + } + return 0 +} + +var File_plugin_proto protoreflect.FileDescriptor + +const file_plugin_proto_rawDesc = "" + + "\n" + + "\fplugin.proto\x12\x0efctl.plugin.v1\"\x14\n" + + "\x12GetManifestRequest\"Q\n" + + "\x13GetManifestResponse\x12:\n" + + "\bmanifest\x18\x01 \x01(\v2\x1e.fctl.plugin.v1.PluginManifestR\bmanifest\"\xa0\x01\n" + + "\x0ePluginManifest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + + "\aversion\x18\x02 \x01(\tR\aversion\x12 \n" + + "\vdescription\x18\x03 \x01(\tR\vdescription\x12>\n" + + "\froot_command\x18\x04 \x01(\v2\x1b.fctl.plugin.v1.CommandSpecR\vrootCommand\"\xa7\x04\n" + + "\vCommandSpec\x12\x10\n" + + "\x03use\x18\x01 \x01(\tR\x03use\x12\x18\n" + + "\aaliases\x18\x02 \x03(\tR\aaliases\x12\x14\n" + + "\x05short\x18\x03 \x01(\tR\x05short\x12\x12\n" + + "\x04long\x18\x04 \x01(\tR\x04long\x12.\n" + + "\x05flags\x18\x05 \x03(\v2\x18.fctl.plugin.v1.FlagSpecR\x05flags\x12C\n" + + "\x10persistent_flags\x18\x06 \x03(\v2\x18.fctl.plugin.v1.FlagSpecR\x0fpersistentFlags\x12=\n" + + "\vsubcommands\x18\a \x03(\v2\x1b.fctl.plugin.v1.CommandSpecR\vsubcommands\x12\x1a\n" + + "\brunnable\x18\b \x01(\bR\brunnable\x12>\n" + + "\fcommand_type\x18\t \x01(\x0e2\x1b.fctl.plugin.v1.CommandTypeR\vcommandType\x12\x16\n" + + "\x06hidden\x18\n" + + " \x01(\bR\x06hidden\x12\x1e\n" + + "\n" + + "deprecated\x18\v \x01(\tR\n" + + "deprecated\x12'\n" + + "\x0fargs_constraint\x18\f \x01(\tR\x0eargsConstraint\x12\x18\n" + + "\aconfirm\x18\r \x01(\bR\aconfirm\x127\n" + + "\adisplay\x18\x0e \x01(\v2\x1d.fctl.plugin.v1.DisplaySchemaR\adisplay\"~\n" + + "\rDisplaySchema\x124\n" + + "\acolumns\x18\x01 \x03(\v2\x1a.fctl.plugin.v1.ColumnSpecR\acolumns\x127\n" + + "\bsections\x18\x02 \x03(\v2\x1b.fctl.plugin.v1.SectionSpecR\bsections\"Y\n" + + "\n" + + "ColumnSpec\x12\x16\n" + + "\x06header\x18\x01 \x01(\tR\x06header\x12\x1b\n" + + "\tjson_path\x18\x02 \x01(\tR\bjsonPath\x12\x16\n" + + "\x06format\x18\x03 \x01(\tR\x06format\"V\n" + + "\vSectionSpec\x12\x14\n" + + "\x05title\x18\x01 \x01(\tR\x05title\x121\n" + + "\x06fields\x18\x02 \x03(\v2\x19.fctl.plugin.v1.FieldSpecR\x06fields\"V\n" + + "\tFieldSpec\x12\x14\n" + + "\x05label\x18\x01 \x01(\tR\x05label\x12\x1b\n" + + "\tjson_path\x18\x02 \x01(\tR\bjsonPath\x12\x16\n" + + "\x06format\x18\x03 \x01(\tR\x06format\"\xd1\x01\n" + + "\bFlagSpec\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" + + "\tshorthand\x18\x02 \x01(\tR\tshorthand\x12#\n" + + "\rdefault_value\x18\x03 \x01(\tR\fdefaultValue\x12 \n" + + "\vdescription\x18\x04 \x01(\tR\vdescription\x12,\n" + + "\x04type\x18\x05 \x01(\x0e2\x18.fctl.plugin.v1.FlagTypeR\x04type\x12\x1e\n" + + "\n" + + "persistent\x18\x06 \x01(\bR\n" + + "persistent\"\xa7\x02\n" + + "\x0eExecuteRequest\x12!\n" + + "\fcommand_path\x18\x01 \x01(\tR\vcommandPath\x12\x12\n" + + "\x04args\x18\x02 \x03(\tR\x04args\x12?\n" + + "\x05flags\x18\x03 \x03(\v2).fctl.plugin.v1.ExecuteRequest.FlagsEntryR\x05flags\x12>\n" + + "\fauth_context\x18\x04 \x01(\v2\x1b.fctl.plugin.v1.AuthContextR\vauthContext\x12#\n" + + "\routput_format\x18\x05 \x01(\tR\foutputFormat\x1a8\n" + + "\n" + + "FlagsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x9c\x02\n" + + "\vAuthContext\x12\x1b\n" + + "\tstack_url\x18\x01 \x01(\tR\bstackUrl\x12!\n" + + "\faccess_token\x18\x02 \x01(\tR\vaccessToken\x12'\n" + + "\x0forganization_id\x18\x03 \x01(\tR\x0eorganizationId\x12\x19\n" + + "\bstack_id\x18\x04 \x01(\tR\astackId\x12%\n" + + "\x0emembership_url\x18\x05 \x01(\tR\rmembershipUrl\x12)\n" + + "\x10membership_token\x18\x06 \x01(\tR\x0fmembershipToken\x12!\n" + + "\finsecure_tls\x18\a \x01(\bR\vinsecureTls\x12\x14\n" + + "\x05debug\x18\b \x01(\bR\x05debug\"\x8d\x01\n" + + "\x0fExecuteResponse\x12:\n" + + "\asuccess\x18\x01 \x01(\v2\x1e.fctl.plugin.v1.ExecuteSuccessH\x00R\asuccess\x124\n" + + "\x05error\x18\x02 \x01(\v2\x1c.fctl.plugin.v1.ExecuteErrorH\x00R\x05errorB\b\n" + + "\x06result\"R\n" + + "\x0eExecuteSuccess\x12\x1b\n" + + "\tjson_data\x18\x01 \x01(\tR\bjsonData\x12#\n" + + "\rrendered_text\x18\x02 \x01(\tR\frenderedText\"<\n" + + "\fExecuteError\x12\x18\n" + + "\amessage\x18\x01 \x01(\tR\amessage\x12\x12\n" + + "\x04code\x18\x02 \x01(\x05R\x04code*Z\n" + + "\vCommandType\x12\x16\n" + + "\x12COMMAND_TYPE_BASIC\x10\x00\x12\x1b\n" + + "\x17COMMAND_TYPE_MEMBERSHIP\x10\x01\x12\x16\n" + + "\x12COMMAND_TYPE_STACK\x10\x02*c\n" + + "\bFlagType\x12\x14\n" + + "\x10FLAG_TYPE_STRING\x10\x00\x12\x12\n" + + "\x0eFLAG_TYPE_BOOL\x10\x01\x12\x11\n" + + "\rFLAG_TYPE_INT\x10\x02\x12\x1a\n" + + "\x16FLAG_TYPE_STRING_SLICE\x10\x032\xb3\x01\n" + + "\rPluginService\x12V\n" + + "\vGetManifest\x12\".fctl.plugin.v1.GetManifestRequest\x1a#.fctl.plugin.v1.GetManifestResponse\x12J\n" + + "\aExecute\x12\x1e.fctl.plugin.v1.ExecuteRequest\x1a\x1f.fctl.plugin.v1.ExecuteResponseB6Z4github.com/formancehq/fctl/v3/pkg/pluginsdk/pluginpbb\x06proto3" + +var ( + file_plugin_proto_rawDescOnce sync.Once + file_plugin_proto_rawDescData []byte +) + +func file_plugin_proto_rawDescGZIP() []byte { + file_plugin_proto_rawDescOnce.Do(func() { + file_plugin_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_plugin_proto_rawDesc), len(file_plugin_proto_rawDesc))) + }) + return file_plugin_proto_rawDescData +} + +var file_plugin_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 15) +var file_plugin_proto_goTypes = []any{ + (CommandType)(0), // 0: fctl.plugin.v1.CommandType + (FlagType)(0), // 1: fctl.plugin.v1.FlagType + (*GetManifestRequest)(nil), // 2: fctl.plugin.v1.GetManifestRequest + (*GetManifestResponse)(nil), // 3: fctl.plugin.v1.GetManifestResponse + (*PluginManifest)(nil), // 4: fctl.plugin.v1.PluginManifest + (*CommandSpec)(nil), // 5: fctl.plugin.v1.CommandSpec + (*DisplaySchema)(nil), // 6: fctl.plugin.v1.DisplaySchema + (*ColumnSpec)(nil), // 7: fctl.plugin.v1.ColumnSpec + (*SectionSpec)(nil), // 8: fctl.plugin.v1.SectionSpec + (*FieldSpec)(nil), // 9: fctl.plugin.v1.FieldSpec + (*FlagSpec)(nil), // 10: fctl.plugin.v1.FlagSpec + (*ExecuteRequest)(nil), // 11: fctl.plugin.v1.ExecuteRequest + (*AuthContext)(nil), // 12: fctl.plugin.v1.AuthContext + (*ExecuteResponse)(nil), // 13: fctl.plugin.v1.ExecuteResponse + (*ExecuteSuccess)(nil), // 14: fctl.plugin.v1.ExecuteSuccess + (*ExecuteError)(nil), // 15: fctl.plugin.v1.ExecuteError + nil, // 16: fctl.plugin.v1.ExecuteRequest.FlagsEntry +} +var file_plugin_proto_depIdxs = []int32{ + 4, // 0: fctl.plugin.v1.GetManifestResponse.manifest:type_name -> fctl.plugin.v1.PluginManifest + 5, // 1: fctl.plugin.v1.PluginManifest.root_command:type_name -> fctl.plugin.v1.CommandSpec + 10, // 2: fctl.plugin.v1.CommandSpec.flags:type_name -> fctl.plugin.v1.FlagSpec + 10, // 3: fctl.plugin.v1.CommandSpec.persistent_flags:type_name -> fctl.plugin.v1.FlagSpec + 5, // 4: fctl.plugin.v1.CommandSpec.subcommands:type_name -> fctl.plugin.v1.CommandSpec + 0, // 5: fctl.plugin.v1.CommandSpec.command_type:type_name -> fctl.plugin.v1.CommandType + 6, // 6: fctl.plugin.v1.CommandSpec.display:type_name -> fctl.plugin.v1.DisplaySchema + 7, // 7: fctl.plugin.v1.DisplaySchema.columns:type_name -> fctl.plugin.v1.ColumnSpec + 8, // 8: fctl.plugin.v1.DisplaySchema.sections:type_name -> fctl.plugin.v1.SectionSpec + 9, // 9: fctl.plugin.v1.SectionSpec.fields:type_name -> fctl.plugin.v1.FieldSpec + 1, // 10: fctl.plugin.v1.FlagSpec.type:type_name -> fctl.plugin.v1.FlagType + 16, // 11: fctl.plugin.v1.ExecuteRequest.flags:type_name -> fctl.plugin.v1.ExecuteRequest.FlagsEntry + 12, // 12: fctl.plugin.v1.ExecuteRequest.auth_context:type_name -> fctl.plugin.v1.AuthContext + 14, // 13: fctl.plugin.v1.ExecuteResponse.success:type_name -> fctl.plugin.v1.ExecuteSuccess + 15, // 14: fctl.plugin.v1.ExecuteResponse.error:type_name -> fctl.plugin.v1.ExecuteError + 2, // 15: fctl.plugin.v1.PluginService.GetManifest:input_type -> fctl.plugin.v1.GetManifestRequest + 11, // 16: fctl.plugin.v1.PluginService.Execute:input_type -> fctl.plugin.v1.ExecuteRequest + 3, // 17: fctl.plugin.v1.PluginService.GetManifest:output_type -> fctl.plugin.v1.GetManifestResponse + 13, // 18: fctl.plugin.v1.PluginService.Execute:output_type -> fctl.plugin.v1.ExecuteResponse + 17, // [17:19] is the sub-list for method output_type + 15, // [15:17] is the sub-list for method input_type + 15, // [15:15] is the sub-list for extension type_name + 15, // [15:15] is the sub-list for extension extendee + 0, // [0:15] is the sub-list for field type_name +} + +func init() { file_plugin_proto_init() } +func file_plugin_proto_init() { + if File_plugin_proto != nil { + return + } + file_plugin_proto_msgTypes[11].OneofWrappers = []any{ + (*ExecuteResponse_Success)(nil), + (*ExecuteResponse_Error)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_plugin_proto_rawDesc), len(file_plugin_proto_rawDesc)), + NumEnums: 2, + NumMessages: 15, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_plugin_proto_goTypes, + DependencyIndexes: file_plugin_proto_depIdxs, + EnumInfos: file_plugin_proto_enumTypes, + MessageInfos: file_plugin_proto_msgTypes, + }.Build() + File_plugin_proto = out.File + file_plugin_proto_goTypes = nil + file_plugin_proto_depIdxs = nil +} diff --git a/pkg/pluginsdk/pluginpb/plugin_grpc.pb.go b/pkg/pluginsdk/pluginpb/plugin_grpc.pb.go new file mode 100644 index 00000000..794a1c46 --- /dev/null +++ b/pkg/pluginsdk/pluginpb/plugin_grpc.pb.go @@ -0,0 +1,167 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v7.34.1 +// source: plugin.proto + +package pluginpb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + PluginService_GetManifest_FullMethodName = "/fctl.plugin.v1.PluginService/GetManifest" + PluginService_Execute_FullMethodName = "/fctl.plugin.v1.PluginService/Execute" +) + +// PluginServiceClient is the client API for PluginService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// PluginService is the gRPC service that every fctl plugin must implement. +type PluginServiceClient interface { + // GetManifest returns the plugin's command tree and metadata. + GetManifest(ctx context.Context, in *GetManifestRequest, opts ...grpc.CallOption) (*GetManifestResponse, error) + // Execute runs a specific command within the plugin. + Execute(ctx context.Context, in *ExecuteRequest, opts ...grpc.CallOption) (*ExecuteResponse, error) +} + +type pluginServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewPluginServiceClient(cc grpc.ClientConnInterface) PluginServiceClient { + return &pluginServiceClient{cc} +} + +func (c *pluginServiceClient) GetManifest(ctx context.Context, in *GetManifestRequest, opts ...grpc.CallOption) (*GetManifestResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetManifestResponse) + err := c.cc.Invoke(ctx, PluginService_GetManifest_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *pluginServiceClient) Execute(ctx context.Context, in *ExecuteRequest, opts ...grpc.CallOption) (*ExecuteResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ExecuteResponse) + err := c.cc.Invoke(ctx, PluginService_Execute_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// PluginServiceServer is the server API for PluginService service. +// All implementations must embed UnimplementedPluginServiceServer +// for forward compatibility. +// +// PluginService is the gRPC service that every fctl plugin must implement. +type PluginServiceServer interface { + // GetManifest returns the plugin's command tree and metadata. + GetManifest(context.Context, *GetManifestRequest) (*GetManifestResponse, error) + // Execute runs a specific command within the plugin. + Execute(context.Context, *ExecuteRequest) (*ExecuteResponse, error) + mustEmbedUnimplementedPluginServiceServer() +} + +// UnimplementedPluginServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedPluginServiceServer struct{} + +func (UnimplementedPluginServiceServer) GetManifest(context.Context, *GetManifestRequest) (*GetManifestResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetManifest not implemented") +} +func (UnimplementedPluginServiceServer) Execute(context.Context, *ExecuteRequest) (*ExecuteResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Execute not implemented") +} +func (UnimplementedPluginServiceServer) mustEmbedUnimplementedPluginServiceServer() {} +func (UnimplementedPluginServiceServer) testEmbeddedByValue() {} + +// UnsafePluginServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to PluginServiceServer will +// result in compilation errors. +type UnsafePluginServiceServer interface { + mustEmbedUnimplementedPluginServiceServer() +} + +func RegisterPluginServiceServer(s grpc.ServiceRegistrar, srv PluginServiceServer) { + // If the following call panics, it indicates UnimplementedPluginServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&PluginService_ServiceDesc, srv) +} + +func _PluginService_GetManifest_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetManifestRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PluginServiceServer).GetManifest(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: PluginService_GetManifest_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PluginServiceServer).GetManifest(ctx, req.(*GetManifestRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _PluginService_Execute_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ExecuteRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PluginServiceServer).Execute(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: PluginService_Execute_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PluginServiceServer).Execute(ctx, req.(*ExecuteRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// PluginService_ServiceDesc is the grpc.ServiceDesc for PluginService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var PluginService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "fctl.plugin.v1.PluginService", + HandlerType: (*PluginServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetManifest", + Handler: _PluginService_GetManifest_Handler, + }, + { + MethodName: "Execute", + Handler: _PluginService_Execute_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "plugin.proto", +} diff --git a/pkg/pluginsdk/sdk.go b/pkg/pluginsdk/sdk.go new file mode 100644 index 00000000..4a5cd670 --- /dev/null +++ b/pkg/pluginsdk/sdk.go @@ -0,0 +1,101 @@ +// Package pluginsdk provides the fctl plugin SDK. +// +// It integrates HashiCorp go-plugin with gRPC to allow external binaries +// to provide fctl commands. Both the core (client side) and plugins (server side) +// use this package. +package pluginsdk + +import ( + "context" + + goplugin "github.com/hashicorp/go-plugin" + "google.golang.org/grpc" + + "github.com/formancehq/fctl/v3/pkg/pluginsdk/pluginpb" +) + +// HandshakeConfig is the shared handshake that both the core and plugins +// must agree on. Changing this breaks compatibility. +var HandshakeConfig = goplugin.HandshakeConfig{ + ProtocolVersion: 1, + MagicCookieKey: "FCTL_PLUGIN", + MagicCookieValue: "formance", +} + +// FctlPlugin is the interface that every plugin must implement. +type FctlPlugin interface { + GetManifest(ctx context.Context) (*pluginpb.PluginManifest, error) + Execute(ctx context.Context, req *pluginpb.ExecuteRequest) (*pluginpb.ExecuteResponse, error) +} + +// PluginMap is the map of plugin types that go-plugin uses during negotiation. +var PluginMap = map[string]goplugin.Plugin{ + "fctl-plugin": &GRPCPlugin{}, +} + +// GRPCPlugin implements goplugin.GRPCPlugin for the fctl plugin protocol. +type GRPCPlugin struct { + goplugin.Plugin + + // Impl is only set on the server (plugin) side. + Impl FctlPlugin +} + +func (p *GRPCPlugin) GRPCServer(broker *goplugin.GRPCBroker, s *grpc.Server) error { + pluginpb.RegisterPluginServiceServer(s, &grpcServer{impl: p.Impl}) + return nil +} + +func (p *GRPCPlugin) GRPCClient(ctx context.Context, broker *goplugin.GRPCBroker, c *grpc.ClientConn) (interface{}, error) { + return &grpcClient{client: pluginpb.NewPluginServiceClient(c)}, nil +} + +// grpcServer wraps an FctlPlugin implementation as a gRPC server. +type grpcServer struct { + pluginpb.UnimplementedPluginServiceServer + impl FctlPlugin +} + +func (s *grpcServer) GetManifest(ctx context.Context, req *pluginpb.GetManifestRequest) (*pluginpb.GetManifestResponse, error) { + manifest, err := s.impl.GetManifest(ctx) + if err != nil { + return nil, err + } + return &pluginpb.GetManifestResponse{Manifest: manifest}, nil +} + +func (s *grpcServer) Execute(ctx context.Context, req *pluginpb.ExecuteRequest) (*pluginpb.ExecuteResponse, error) { + return s.impl.Execute(ctx, req) +} + +// grpcClient wraps a gRPC client connection as an FctlPlugin. +type grpcClient struct { + client pluginpb.PluginServiceClient +} + +func (c *grpcClient) GetManifest(ctx context.Context) (*pluginpb.PluginManifest, error) { + resp, err := c.client.GetManifest(ctx, &pluginpb.GetManifestRequest{}) + if err != nil { + return nil, err + } + return resp.Manifest, nil +} + +func (c *grpcClient) Execute(ctx context.Context, req *pluginpb.ExecuteRequest) (*pluginpb.ExecuteResponse, error) { + return c.client.Execute(ctx, req) +} + +// Serve is the entry point for plugins. Call this from your main() function. +// +// func main() { +// pluginsdk.Serve(&MyPlugin{}) +// } +func Serve(impl FctlPlugin) { + goplugin.Serve(&goplugin.ServeConfig{ + HandshakeConfig: HandshakeConfig, + Plugins: map[string]goplugin.Plugin{ + "fctl-plugin": &GRPCPlugin{Impl: impl}, + }, + GRPCServer: goplugin.DefaultGRPCServer, + }) +}