Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
785659c
docs: design connectivity CLI commands
Dav-14 Aug 7, 2026
0db22fd
docs: plan connectivity CLI implementation
Dav-14 Aug 7, 2026
c1ffaea
feat(connectivity): add API client
Dav-14 Aug 7, 2026
524e5cd
feat(connectivity): assemble instance configuration
Dav-14 Aug 7, 2026
cf56347
fix(connectivity): preserve dotenv values
Dav-14 Aug 7, 2026
e835df0
feat(connectivity): add plugin catalog commands
Dav-14 Aug 7, 2026
26ce959
fix(connectivity): keep completion non-interactive
Dav-14 Aug 7, 2026
23a0c62
feat(connectivity): list and show instances
Dav-14 Aug 7, 2026
50769cf
feat(connectivity): install plugin instances
Dav-14 Aug 7, 2026
0d5db54
feat(connectivity): configure and uninstall instances
Dav-14 Aug 7, 2026
ce26e92
feat(connectivity): expose fctl command tree
Dav-14 Aug 7, 2026
e37a683
fix(connectivity): harden API compatibility
Dav-14 Aug 7, 2026
0327241
chore: remove superpowers artifacts
Dav-14 Aug 7, 2026
0fdc07a
refactor(connectivity): align the CLI with the renamed connectivity API
Dav-14 Aug 12, 2026
d9775ff
feat(connectivity): support read model
Dav-14 Aug 14, 2026
49da22c
fix(connectivity): align selector and schema handling
Dav-14 Aug 14, 2026
2d319b2
fix(connectivity): mirror selector normalization
Dav-14 Aug 14, 2026
d4433cf
fix(connectivity): support legacy resolved identity
Dav-14 Aug 14, 2026
aa5f966
feat(connectivity): suspend connector instances (#178)
Dav-14 Aug 14, 2026
31e3cc4
fix(connectivity): drop the description column from `connectors list`
Dav-14 Aug 15, 2026
4471074
fix(connectivity): stop rejecting instances that pin an image
Dav-14 Aug 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
242 changes: 242 additions & 0 deletions cmd/connectivity/connectorinstances/completion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
package connectorinstances

import (
"context"
"os"
"path/filepath"
"sort"
"strings"

"github.com/spf13/cobra"

connectivityinternal "github.com/formancehq/fctl/v3/cmd/connectivity/internal"
connectivityclient "github.com/formancehq/fctl/v3/internal/connectivityclient"
)

const instanceCompletionMaxPages = 5

type PathCompleter func(prefix string) ([]string, error)

// ConnectorResolver names the Connector a command is operating on.
type ConnectorResolver func(context.Context, connectivityclient.Client, *cobra.Command, []string) (string, error)

// ConnectorVersionResolver resolves the ConnectorVersion whose configSchema
// drives value completion for a command.
type ConnectorVersionResolver func(context.Context, connectivityclient.Client, *cobra.Command, []string) (*connectivityclient.ConnectorVersion, error)

func CompleteConnectorInstanceNames(factory connectivityinternal.ClientFactory) cobra.CompletionFunc {
return func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
client, completionCommand, cancel, ok := connectivityinternal.CompletionClient(cmd, factory)
if !ok {
return nil, cobra.ShellCompDirectiveNoFileComp
}
defer cancel()

instances, err := connectivityinternal.CollectPagesBounded(connectivityinternal.CompletionPageSize, instanceCompletionMaxPages,
func(options connectivityclient.ListOptions) ([]connectivityclient.ConnectorInstance, bool, string, error) {
page, err := client.ListConnectorInstances(completionCommand.Context(), options)
if err != nil || page == nil {
return nil, false, "", err
}
return page.Items, page.HasMore, page.Next, nil
})
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}

candidates := make([]string, 0, len(instances))
for _, instance := range instances {
name := stringValue(instance.Metadata.Name)
if name == "" || !strings.HasPrefix(name, toComplete) {
continue
}
description := strings.Join(nonEmptyStrings(instance.Spec.Connector, instance.Spec.Ledger), " · ")
if description != "" {
name += "\t" + description
}
candidates = append(candidates, name)
}
sort.Strings(candidates)
return candidates, cobra.ShellCompDirectiveNoFileComp
}
}

func CompleteVersions(factory connectivityinternal.ClientFactory, connectorArg func(*cobra.Command, []string) string) cobra.CompletionFunc {
return completeVersions(factory, func(_ context.Context, _ connectivityclient.Client, cmd *cobra.Command, args []string) (string, error) {
if connectorArg == nil {
return "", nil
}
return connectorArg(cmd, args), nil
})
}

func completeVersions(factory connectivityinternal.ClientFactory, resolveConnector ConnectorResolver) cobra.CompletionFunc {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if resolveConnector == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
client, completionCommand, cancel, ok := connectivityinternal.CompletionClient(cmd, factory)
if !ok {
return nil, cobra.ShellCompDirectiveNoFileComp
}
defer cancel()

connector, err := resolveConnector(completionCommand.Context(), client, completionCommand, args)
if err != nil || connector == "" {
return nil, cobra.ShellCompDirectiveNoFileComp
}
versions, err := connectivityinternal.CollectPagesBounded(connectivityinternal.CompletionPageSize, instanceCompletionMaxPages,
func(options connectivityclient.ListOptions) ([]connectivityclient.ConnectorVersionSummary, bool, string, error) {
page, err := client.ListConnectorVersions(completionCommand.Context(), connector, options)
if err != nil || page == nil {
return nil, false, "", err
}
return page.Items, page.HasMore, page.Next, nil
})
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
candidates := make([]string, 0, len(versions))
for _, version := range versions {
if version.Version == "" || !strings.HasPrefix(version.Version, toComplete) {
continue
}
candidate := version.Version
description := version.Image
if description == "" {
description = stringValue(version.Digest)
}
if description != "" {
candidate += "\t" + description
}
candidates = append(candidates, candidate)
}
sort.Strings(candidates)
return candidates, cobra.ShellCompDirectiveNoFileComp
}
}

func CompleteSetValues(
factory connectivityinternal.ClientFactory,
resolveVersion ConnectorVersionResolver,
paths PathCompleter,
) cobra.CompletionFunc {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if marker := strings.Index(toComplete, "=@"); marker >= 0 {
if paths == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
candidatePrefix := toComplete[:marker+2]
pathPrefix := toComplete[marker+2:]
pathCandidates, err := paths(pathPrefix)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
candidates := make([]string, 0, len(pathCandidates))
for _, path := range pathCandidates {
if strings.HasPrefix(path, pathPrefix) {
candidates = append(candidates, candidatePrefix+path)
}
}
sort.Strings(candidates)
return candidates, cobra.ShellCompDirectiveNoFileComp
}

if resolveVersion == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
client, completionCommand, cancel, ok := connectivityinternal.CompletionClient(cmd, factory)
if !ok {
return nil, cobra.ShellCompDirectiveNoFileComp
}
defer cancel()
version, err := resolveVersion(completionCommand.Context(), client, completionCommand, args)
if err != nil || version == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
fields, err := SchemaFields(version)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
supplied := suppliedSetKeys(cmd, args)
ordered := make([]SchemaField, 0, len(fields))
for key, field := range fields {
if supplied[key] || !strings.HasPrefix(key, strings.TrimSuffix(toComplete, "=")) {
continue
}
ordered = append(ordered, field)
}
sort.Slice(ordered, func(i, j int) bool {
if ordered[i].Required != ordered[j].Required {
return ordered[i].Required
}
return ordered[i].Key < ordered[j].Key
})
candidates := make([]string, 0, len(ordered))
for _, field := range ordered {
candidates = append(candidates, field.Key+"=\t"+schemaFieldDescription(field))
}
return candidates, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
}
}

func schemaFieldDescription(field SchemaField) string {
if field.Description != "" {
return field.Description
}
if field.Kind == ConfigFile {
return "file configuration"
}
return "environment configuration"
}

func OSPathCompleter(prefix string) ([]string, error) {
directory, base := filepath.Split(prefix)
readDirectory := directory
if readDirectory == "" {
readDirectory = "."
}
entries, err := os.ReadDir(readDirectory)
if err != nil {
return nil, err
}
candidates := make([]string, 0, len(entries))
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), base) {
continue
}
candidate := directory + entry.Name()
if entry.IsDir() {
candidate += string(filepath.Separator)
}
candidates = append(candidates, candidate)
}
sort.Strings(candidates)
return candidates, nil
}

func suppliedSetKeys(cmd *cobra.Command, args []string) map[string]bool {
values := append([]string(nil), args...)
if cmd != nil && cmd.Flags().Lookup("set") != nil {
if setValues, err := cmd.Flags().GetStringArray("set"); err == nil {
values = append(values, setValues...)
}
}
supplied := make(map[string]bool)
for _, value := range values {
if separator := strings.IndexByte(value, '='); separator > 0 {
supplied[value[:separator]] = true
}
}
return supplied
}

func nonEmptyStrings(values ...string) []string {
result := make([]string, 0, len(values))
for _, value := range values {
if value != "" {
result = append(result, value)
}
}
return result
}
Loading
Loading