From 572665d65829efc408976be82d06dbcb2b3ddf5c Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 23 Apr 2026 09:31:47 +0300 Subject: [PATCH 1/5] feat(entity): Add filter lookup support for dynamic filter metadata and completions Implement Filter interface and lookup functions to enable dynamic filter metadata exposure through the RPC executor. This allows UI components to display available and selected filter options with rich formatting (styles, tooltips) and supports dependent filter narrowing based on other filter selections. Key additions: - Filter[T] interface for defining filter behavior with Key(), Label(), Lookup(), and Options() methods - buildLookupFunc() generates lookup metadata from filter definitions - buildFilterCompletionBinder() registers flag completion functions for shell and UI - resolveEntityOpts() applies filters to transform raw flag values into backend-canonical forms - LookupFunc field on EntityOperation and BulkActionInfo for RPC exposure - BindCompletions field for registering completion functions on generated commands - New clicky_lookup.go with entityLookupResponse and clickyNode types for rich filter metadata - RPC executor support for HEAD and GET __lookup=filters requests - Comprehensive test coverage in entity_filters_test.go and rpc/filter_lookup_test.go - End-to-end example in examples/entity/ demonstrating filters, admin entities, and executor-backed UI Breaking changes: buildOpts() now returns (T, error) instead of T; callers must handle error return value. --- clicky_lookup.go | 92 ++ cobra_command.go | 11 + entity.go | 383 +++++- entity_filters_test.go | 611 +++++++++ examples/README.md | 1 + examples/enitity/e2e/.gitignore | 4 + examples/enitity/e2e/playwright.config.ts | 44 + .../enitity/e2e/tests/entity-demo.spec.ts | 228 ++++ examples/enitity/main.go | 1108 +++++++++++++++++ examples/enitity/webapp/.gitignore | 13 + examples/enitity/webapp/src/App.tsx | 95 ++ examples/enitity/webapp/src/api.ts | 100 ++ examples/enitity/webapp/src/domains.ts | 64 + examples/enitity/webapp/src/index.css | 18 + examples/enitity/webapp/src/main.tsx | 29 + examples/enitity/webapp/tsconfig.tsbuildinfo | 1 + examples/enitity/webapp/vite.config.ts | 31 + examples/go.sum | 307 +++++ go.mod | 4 +- go.sum | 4 + rpc/converter.go | 3 + rpc/executor.go | 48 +- rpc/filter_lookup_test.go | 167 +++ rpc/serve.go | 93 +- rpc/types.go | 1 + sub_command_test.go | 3 + 26 files changed, 3390 insertions(+), 73 deletions(-) create mode 100644 clicky_lookup.go create mode 100644 entity_filters_test.go create mode 100644 examples/enitity/e2e/.gitignore create mode 100644 examples/enitity/e2e/playwright.config.ts create mode 100644 examples/enitity/e2e/tests/entity-demo.spec.ts create mode 100644 examples/enitity/main.go create mode 100644 examples/enitity/webapp/.gitignore create mode 100644 examples/enitity/webapp/src/App.tsx create mode 100644 examples/enitity/webapp/src/api.ts create mode 100644 examples/enitity/webapp/src/domains.ts create mode 100644 examples/enitity/webapp/src/index.css create mode 100644 examples/enitity/webapp/src/main.tsx create mode 100644 examples/enitity/webapp/tsconfig.tsbuildinfo create mode 100644 examples/enitity/webapp/vite.config.ts create mode 100644 rpc/filter_lookup_test.go diff --git a/clicky_lookup.go b/clicky_lookup.go new file mode 100644 index 00000000..4b51b9d5 --- /dev/null +++ b/clicky_lookup.go @@ -0,0 +1,92 @@ +package clicky + +import "github.com/flanksource/clicky/api" + +type entityLookupResponse struct { + Filters map[string]entityLookupFilter `json:"filters"` +} + +type entityLookupFilter struct { + Label string `json:"label,omitempty"` + Options map[string]clickyNode `json:"options,omitempty"` + Selected map[string]clickyNode `json:"selected,omitempty"` + Multi bool `json:"multi,omitempty"` + Type string `json:"type,omitempty"` +} + +type clickyNode struct { + Kind string `json:"kind"` + Plain string `json:"plain,omitempty"` + Text string `json:"text,omitempty"` + Style *clickyStyle `json:"style,omitempty"` + Children []clickyNode `json:"children,omitempty"` + Tooltip *clickyNode `json:"tooltip,omitempty"` +} + +type clickyStyle struct { + ClassName string `json:"className,omitempty"` +} + +func toClickyNodeMap(values map[string]api.Textable) map[string]clickyNode { + if len(values) == 0 { + return nil + } + + nodes := make(map[string]clickyNode, len(values)) + for key, value := range values { + nodes[key] = toClickyNode(value) + } + return nodes +} + +func toClickyNode(value api.Textable) clickyNode { + switch typed := value.(type) { + case api.Text: + return textToClickyNode(typed) + case *api.Text: + if typed == nil { + return clickyNode{Kind: "text"} + } + return textToClickyNode(*typed) + default: + if value == nil { + return clickyNode{Kind: "text"} + } + plain := value.String() + return clickyNode{ + Kind: "text", + Text: plain, + Plain: plain, + } + } +} + +func textToClickyNode(text api.Text) clickyNode { + node := clickyNode{ + Kind: "text", + Text: text.Content, + Plain: text.String(), + } + + if text.Style != "" { + node.Style = &clickyStyle{ClassName: text.Style} + } + + if len(text.Children) > 0 { + node.Children = make([]clickyNode, 0, len(text.Children)) + for _, child := range text.Children { + node.Children = append(node.Children, toClickyNode(child)) + } + } + + if text.Tooltip != nil { + tooltip := toClickyNode(text.Tooltip) + node.Tooltip = &tooltip + } + + if node.Text == "" && node.Plain != "" { + node.Text = node.Plain + } + + return node +} diff --git a/cobra_command.go b/cobra_command.go index 72710c52..fb6576d9 100644 --- a/cobra_command.go +++ b/cobra_command.go @@ -17,6 +17,9 @@ import ( // that can invoke the user function directly with flag values. var dataFuncRegistry sync.Map // map[*cobra.Command]func(flags map[string]string, args []string) (any, error) +// lookupFuncRegistry maps cobra commands to filter metadata lookup closures. +var lookupFuncRegistry sync.Map // map[*cobra.Command]func(flags map[string]string, args []string) (any, error) + // GetDataFunc returns the direct data function registered for a command, if any. // Used by the RPC converter to wire DataFunc on RPCOperation. func GetDataFunc(cmd *cobra.Command) func(flags map[string]string, args []string) (any, error) { @@ -26,6 +29,14 @@ func GetDataFunc(cmd *cobra.Command) func(flags map[string]string, args []string return nil } +// GetLookupFunc returns the direct lookup function registered for a command, if any. +func GetLookupFunc(cmd *cobra.Command) func(flags map[string]string, args []string) (any, error) { + if v, ok := lookupFuncRegistry.Load(cmd); ok { + return v.(func(flags map[string]string, args []string) (any, error)) + } + return nil +} + // AddCommand creates a Cobra command with automatic flag parsing from struct tags, // execution, and result formatting. // diff --git a/entity.go b/entity.go index f4e89e9c..d61bd388 100644 --- a/entity.go +++ b/entity.go @@ -7,6 +7,7 @@ import ( "os" "reflect" "sort" + "strings" "sync" "github.com/flanksource/clicky/api" @@ -37,6 +38,7 @@ type EntityInfo struct { Operations []EntityOperation Actions []ActionInfo BulkActions []BulkActionInfo + ValidArgs func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) IsAdmin bool } @@ -48,7 +50,9 @@ type EntityOperation struct { // onto the generated cobra command and collects their values into the // flag map passed to DataFunc. Used by actions that implement // ActionFlags; ignored for the built-in CRUD verbs. - FlagsType reflect.Type + FlagsType reflect.Type + LookupFunc func(flags map[string]string, args []string) (any, error) + BindCompletions func(cmd *cobra.Command) } // ActionInfo is the type-erased representation of a single-entity action. @@ -64,11 +68,13 @@ type ActionInfo struct { // BulkActionInfo is the type-erased representation of a bulk action. type BulkActionInfo struct { - Name string - Short string - DataFunc func(flags map[string]string, args []string) (any, error) - FilterFunc func(flags map[string]string, args []string) (any, error) - ListType reflect.Type + Name string + Short string + DataFunc func(flags map[string]string, args []string) (any, error) + FilterFunc func(flags map[string]string, args []string) (any, error) + LookupFunc func(flags map[string]string, args []string) (any, error) + ListType reflect.Type + BindCompletions func(cmd *cobra.Command) } // ActionFlags is a marker interface implemented by options structs that @@ -83,6 +89,15 @@ type ActionFlags interface { ClickyActionFlags() } +// Filter resolves raw entity option values into the backend-specific shape and +// exposes UI metadata for the currently selected and available values. +type Filter[ListOpts any] interface { + Key() string + Label() string + Lookup(opts *ListOpts) (map[string]api.Textable, error) + Options(opts ListOpts) map[string]api.Textable +} + // Action represents a custom operation on a single entity by ID. type Action[T EntityItem] struct { Name string @@ -128,6 +143,7 @@ type Entity[T EntityItem, ListOpts any] struct { Create func(body map[string]any) (any, error) Update func(id string, body map[string]any) (any, error) Delete func(id string) error + Filters []Filter[ListOpts] Actions []Action[T] BulkActions []BulkAction[T, ListOpts] @@ -150,24 +166,30 @@ func RegisterEntity[T EntityItem, ListOpts any](e Entity[T, ListOpts]) { } info := EntityInfo{ - Name: name, - Parent: e.Parent, - Aliases: e.Aliases, - Type: reflect.TypeOf((*T)(nil)).Elem(), - ListType: reflect.TypeOf((*ListOpts)(nil)).Elem(), + Name: name, + Parent: e.Parent, + Aliases: e.Aliases, + Type: reflect.TypeOf((*T)(nil)).Elem(), + ListType: reflect.TypeOf((*ListOpts)(nil)).Elem(), + ValidArgs: e.ValidArgs, } if e.List != nil { info.Operations = append(info.Operations, EntityOperation{ Verb: "list", DataFunc: func(flagMap map[string]string, args []string) (any, error) { - opts := buildOpts[ListOpts](flagMap) + opts, err := resolveEntityOpts[ListOpts](flagMap, e.Filters) + if err != nil { + return nil, err + } items, err := e.List(opts) if err != nil { return nil, err } return withEntityIDs(items), nil }, + LookupFunc: buildLookupFunc[ListOpts](e.Filters), + BindCompletions: buildFilterCompletionBinder[ListOpts](e.Filters), }) } @@ -285,9 +307,14 @@ func RegisterEntity[T EntityItem, ListOpts any](e Entity[T, ListOpts]) { } if b.RunFilter != nil { bai.FilterFunc = func(flagMap map[string]string, args []string) (any, error) { - opts := buildOpts[ListOpts](flagMap) + opts, err := resolveEntityOpts[ListOpts](flagMap, e.Filters) + if err != nil { + return nil, err + } return b.RunFilter(opts, flagMap) } + bai.LookupFunc = buildLookupFunc[ListOpts](e.Filters) + bai.BindCompletions = buildFilterCompletionBinder[ListOpts](e.Filters) } info.BulkActions = append(info.BulkActions, bai) } @@ -297,23 +324,48 @@ func RegisterEntity[T EntityItem, ListOpts any](e Entity[T, ListOpts]) { if admin.Name == "" || admin.Name == name { admin.Name = name } + adminValidArgs := admin.ValidArgs + if adminValidArgs == nil { + adminValidArgs = e.ValidArgs + } // Store as admin sub-entity — GenerateCLI nests under "admin" parent adminInfo := EntityInfo{ - Name: admin.Name, - Type: info.Type, - ListType: info.ListType, - IsAdmin: true, + Name: admin.Name, + Type: info.Type, + ListType: info.ListType, + ValidArgs: adminValidArgs, + IsAdmin: true, } if admin.List != nil { adminInfo.Operations = append(adminInfo.Operations, EntityOperation{ Verb: "list", DataFunc: func(flagMap map[string]string, args []string) (any, error) { - opts := buildOpts[ListOpts](flagMap) + opts, err := resolveEntityOpts[ListOpts](flagMap, admin.Filters) + if err != nil { + return nil, err + } return admin.List(opts) }, + LookupFunc: buildLookupFunc[ListOpts](admin.Filters), + BindCompletions: buildFilterCompletionBinder[ListOpts](admin.Filters), }) } - if admin.Get != nil { + if admin.GetWithFlags != nil { + adminInfo.Operations = append(adminInfo.Operations, EntityOperation{ + Verb: "get", + FlagsType: actionFlagsType(admin.GetFlags), + DataFunc: func(flagMap map[string]string, args []string) (any, error) { + id := flagMap["id"] + if id == "" && len(args) > 0 { + id = args[0] + } + if id == "" { + return nil, fmt.Errorf("id is required") + } + return admin.GetWithFlags(id, flagMap) + }, + }) + } else if admin.Get != nil { adminInfo.Operations = append(adminInfo.Operations, EntityOperation{ Verb: "get", DataFunc: func(flagMap map[string]string, args []string) (any, error) { @@ -424,7 +476,7 @@ func generateEntityCLI(parent *cobra.Command, entity EntityInfo) { Verb: action.Name, DataFunc: action.DataFunc, FlagsType: action.FlagsType, - }) + }, entity.ValidArgs) } for _, ba := range entity.BulkActions { @@ -437,13 +489,13 @@ func generateEntitySubcommand(parent *cobra.Command, entity EntityInfo, op Entit case "list": generateListCommand(parent, entity, op) case "get": - generateIDCommand(parent, "get", fmt.Sprintf("Get a %s by ID", entity.Name), op) + generateIDCommand(parent, "get", fmt.Sprintf("Get a %s by ID", entity.Name), op, entity.ValidArgs) case "create": generateBodyCommand(parent, "create", fmt.Sprintf("Create a %s", entity.Name), op) case "update": generateBodyCommand(parent, "update", fmt.Sprintf("Update a %s", entity.Name), op) case "delete": - generateIDCommand(parent, "delete", fmt.Sprintf("Delete a %s", entity.Name), op) + generateIDCommand(parent, "delete", fmt.Sprintf("Delete a %s", entity.Name), op, entity.ValidArgs) } } @@ -467,12 +519,18 @@ func generateListCommand(parent *cobra.Command, entity EntityInfo, op EntityOper // Bind filter flags from the ListOpts type bindTypeFlags(cmd, entity.ListType) + if op.BindCompletions != nil { + op.BindCompletions(cmd) + } parent.AddCommand(cmd) dataFuncRegistry.Store(cmd, op.DataFunc) + if op.LookupFunc != nil { + lookupFuncRegistry.Store(cmd, op.LookupFunc) + } } -func generateIDCommand(parent *cobra.Command, verb, short string, op EntityOperation) { +func generateIDCommand(parent *cobra.Command, verb, short string, op EntityOperation, validArgs func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective)) { hasFlags := op.FlagsType != nil use := fmt.Sprintf("%s ", verb) if hasFlags { @@ -503,6 +561,9 @@ func generateIDCommand(parent *cobra.Command, verb, short string, op EntityOpera if verb == "get" { cmd.Aliases = []string{"inspect"} } + if validArgs != nil { + cmd.ValidArgsFunction = validArgs + } if hasFlags { bindTypeFlags(cmd, op.FlagsType) } @@ -579,6 +640,13 @@ func generateBodyCommand(parent *cobra.Command, verb, short string, op EntityOpe } func generateBulkActionCommand(parent *cobra.Command, ba BulkActionInfo) { + execute := func(flagMap map[string]string, args []string) (any, error) { + if ba.FilterFunc != nil && flagMap["filter"] != "" { + return ba.FilterFunc(flagMap, args) + } + return ba.DataFunc(flagMap, args) + } + cmd := &cobra.Command{ Use: fmt.Sprintf("%s [id...]", ba.Name), Short: ba.Short, @@ -590,18 +658,7 @@ func generateBulkActionCommand(parent *cobra.Command, ba BulkActionInfo) { }) // Use filter mode if --filter flag is set and FilterFunc exists - if ba.FilterFunc != nil && flagMap["filter"] != "" { - result, err := ba.FilterFunc(flagMap, args) - if err != nil { - return err - } - if result != nil { - MustPrint(result, Flags.FormatOptions) - } - return nil - } - - result, err := ba.DataFunc(flagMap, args) + result, err := execute(flagMap, args) if err != nil { return err } @@ -614,10 +671,16 @@ func generateBulkActionCommand(parent *cobra.Command, ba BulkActionInfo) { if ba.FilterFunc != nil { bindTypeFlags(cmd, ba.ListType) + if ba.BindCompletions != nil { + ba.BindCompletions(cmd) + } } parent.AddCommand(cmd) - dataFuncRegistry.Store(cmd, ba.DataFunc) + dataFuncRegistry.Store(cmd, execute) + if ba.LookupFunc != nil { + lookupFuncRegistry.Store(cmd, ba.LookupFunc) + } } // bindTypeFlags registers cobra flags from a struct type's field tags. @@ -628,33 +691,243 @@ func bindTypeFlags(cmd *cobra.Command, t reflect.Type) { } } +func buildFilterCompletionBinder[T any](filters []Filter[T]) func(cmd *cobra.Command) { + if len(filters) == 0 { + return nil + } + + return func(cmd *cobra.Command) { + for _, filter := range filters { + if cmd.Flag(filter.Key()) == nil { + continue + } + if _, exists := cmd.GetFlagCompletionFunc(filter.Key()); exists { + continue + } + + filter := filter + _ = cmd.RegisterFlagCompletionFunc(filter.Key(), func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + flagMap := make(map[string]string) + cmd.Flags().Visit(func(f *pflag.Flag) { + if f.Name == filter.Key() { + return + } + flagMap[f.Name] = f.Value.String() + }) + + opts, err := resolveEntityOpts[T](flagMap, filters) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + options := filter.Options(opts) + if len(options) == 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + keys := make([]string, 0, len(options)) + for key := range options { + if strings.HasPrefix(key, toComplete) { + keys = append(keys, key) + } + } + sort.Strings(keys) + + completions := make([]string, 0, len(keys)) + for _, key := range keys { + description := sanitizeCompletionDescription(options[key].String()) + if description == "" || description == key { + completions = append(completions, key) + continue + } + completions = append(completions, cobra.CompletionWithDesc(key, description)) + } + + return completions, cobra.ShellCompDirectiveNoFileComp + }) + } + } +} + +func sanitizeCompletionDescription(value string) string { + value = strings.TrimSpace(value) + value = strings.ReplaceAll(value, "\t", " ") + value = strings.ReplaceAll(value, "\n", " ") + return value +} + // buildOpts creates an instance of T and populates it from a flag map. -func buildOpts[T any](flagMap map[string]string) T { +func buildOpts[T any](flagMap map[string]string) (T, error) { + var zero T t := reflect.TypeOf((*T)(nil)).Elem() - v := reflect.New(t).Elem() + if t.Kind() != reflect.Struct { + return zero, fmt.Errorf("expected struct options, got %s", t.Kind()) + } - fieldInfos, _ := flags.ParseStructFields(t) + cmd := &cobra.Command{Use: "opts"} + fieldInfos, err := flags.ParseStructFields(t) + if err != nil { + return zero, err + } + flagValues := make([]*flags.FlagValue, 0, len(fieldInfos)) for _, info := range fieldInfos { - name := info.FlagName - if name == "" { - name = lo.KebabCase(info.FieldName) - } - if val, ok := flagMap[name]; ok && val != "" { - field := v.FieldByIndex(info.FieldPath) - switch field.Kind() { - case reflect.String: - field.SetString(val) - case reflect.Int, reflect.Int64: - var n int64 - _, _ = fmt.Sscanf(val, "%d", &n) - field.SetInt(n) - case reflect.Bool: - field.SetBool(val == "true") + if fv := flags.BindFlag(cmd, info); fv != nil { + flagValues = append(flagValues, fv) + } + } + + for name, val := range flagMap { + flag := cmd.Flags().Lookup(name) + if flag == nil { + continue + } + if err := flag.Value.Set(val); err != nil { + return zero, fmt.Errorf("setting flag %s: %w", name, err) + } + flag.Changed = true + } + + v := reflect.New(t).Elem() + for _, fv := range flagValues { + if err := flags.AssignFieldValue(v, fv, nil, false); err != nil { + return zero, err + } + } + + return v.Interface().(T), nil +} + +func resolveEntityOpts[T any](flagMap map[string]string, filters []Filter[T]) (T, error) { + opts, err := buildOpts[T](flagMap) + if err != nil { + return opts, err + } + _, err = applyEntityFilters(&opts, filters) + return opts, err +} + +func buildLookupFunc[T any](filters []Filter[T]) func(flags map[string]string, args []string) (any, error) { + if len(filters) == 0 { + return nil + } + + lookupMetadata := buildLookupMetadata[T]() + + return func(flagMap map[string]string, args []string) (any, error) { + opts, err := buildOpts[T](flagMap) + if err != nil { + return nil, err + } + + selected, err := applyEntityFilters(&opts, filters) + if err != nil { + return nil, err + } + + response := entityLookupResponse{ + Filters: make(map[string]entityLookupFilter, len(filters)), + } + for _, filter := range filters { + meta := lookupMetadata[filter.Key()] + response.Filters[filter.Key()] = entityLookupFilter{ + Label: filter.Label(), + Options: toClickyNodeMap(filter.Options(opts)), + Selected: toClickyNodeMap(selected[filter.Key()]), + Multi: meta.Multi, + Type: meta.Type, } } + return response, nil + } +} + +type entityLookupMetadata struct { + Multi bool + Type string +} + +func buildLookupMetadata[T any]() map[string]entityLookupMetadata { + structType := reflect.TypeOf((*T)(nil)).Elem() + if structType.Kind() != reflect.Struct { + return nil + } + + fields, err := flags.ParseStructFields(structType) + if err != nil { + return nil + } + + metadata := make(map[string]entityLookupMetadata, len(fields)) + for _, field := range fields { + if field.FlagName == "" || field.FlagName == "-" { + continue + } + metadata[field.FlagName] = describeLookupField(field) + } + + return metadata +} + +func describeLookupField(field flags.FieldInfo) entityLookupMetadata { + fieldType := field.FieldType + for fieldType.Kind() == reflect.Ptr { + fieldType = fieldType.Elem() + } + + meta := entityLookupMetadata{ + Multi: fieldType.Kind() == reflect.Slice, } - return v.Interface().(T) + switch { + case fieldType.Kind() == reflect.Bool: + meta.Type = "bool" + case isNumericKind(fieldType.Kind()): + meta.Type = "number" + case fieldType.String() == "time.Time": + switch { + case isRangeStartFlag(field.FlagName): + meta.Type = "from" + case isRangeEndFlag(field.FlagName): + meta.Type = "to" + default: + meta.Type = "date" + } + } + + return meta +} + +func isNumericKind(kind reflect.Kind) bool { + switch kind { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + return true + default: + return false + } +} + +func isRangeStartFlag(name string) bool { + return name == "from" || strings.HasSuffix(name, "-from") +} + +func isRangeEndFlag(name string) bool { + return name == "to" || strings.HasSuffix(name, "-to") +} + +func applyEntityFilters[T any](opts *T, filters []Filter[T]) (map[string]map[string]api.Textable, error) { + selected := make(map[string]map[string]api.Textable, len(filters)) + for _, filter := range filters { + values, err := filter.Lookup(opts) + if err != nil { + return nil, fmt.Errorf("%s: %w", filter.Key(), err) + } + if len(values) > 0 { + selected[filter.Key()] = values + } + } + return selected, nil } // withEntityIDs wraps each EntityItem in the slice to include a _id field in JSON output. diff --git a/entity_filters_test.go b/entity_filters_test.go new file mode 100644 index 00000000..8fc5046b --- /dev/null +++ b/entity_filters_test.go @@ -0,0 +1,611 @@ +package clicky + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/flanksource/clicky/api" + "github.com/flanksource/commons/duration" + "github.com/spf13/cobra" +) + +type entityFilterTestEntity struct { + ID string + Name string +} + +func (e entityFilterTestEntity) GetID() string { return e.ID } +func (e entityFilterTestEntity) GetName() string { return e.Name } + +type entityFilterEmbeddedOpts struct { + Tags []string `flag:"tags"` + Window duration.Duration `flag:"window" default:"24h"` +} + +type entityFilterTestOpts struct { + entityFilterEmbeddedOpts + Owner string `flag:"owner"` + Status string `flag:"status"` + Active bool `flag:"active"` + Since time.Time `flag:"since"` + From time.Time `flag:"from"` + To time.Time `flag:"to"` +} + +type ownerEntityFilter struct{} + +func (ownerEntityFilter) Key() string { return "owner" } +func (ownerEntityFilter) Label() string { return "Owner" } + +func (ownerEntityFilter) Lookup(opts *entityFilterTestOpts) (map[string]api.Textable, error) { + if opts.Owner == "" { + return nil, nil + } + opts.Owner = "team/" + opts.Owner + return map[string]api.Textable{ + opts.Owner: api.Text{Content: strings.TrimPrefix(opts.Owner, "team/")}, + }, nil +} + +func (ownerEntityFilter) Options(opts entityFilterTestOpts) map[string]api.Textable { + return map[string]api.Textable{ + "team/platform": api.Text{Content: "Platform"}, + "team/core": api.Text{Content: "Core"}, + } +} + +type statusEntityFilter struct{} + +func (statusEntityFilter) Key() string { return "status" } +func (statusEntityFilter) Label() string { return "Status" } + +func (statusEntityFilter) Lookup(opts *entityFilterTestOpts) (map[string]api.Textable, error) { + if opts.Status == "" { + return nil, nil + } + + switch opts.Status { + case "healthy": + opts.Status = "status:healthy" + return map[string]api.Textable{ + opts.Status: api.Text{ + Content: "Healthy", + Style: "font-semibold", + Tooltip: api.Text{Content: "Ready"}, + }, + }, nil + case "degraded": + opts.Status = "status:degraded" + return map[string]api.Textable{ + opts.Status: api.Text{Content: "Degraded"}, + }, nil + default: + return nil, nil + } +} + +func (statusEntityFilter) Options(opts entityFilterTestOpts) map[string]api.Textable { + options := map[string]api.Textable{ + "status:healthy": api.Text{Content: "Healthy", Style: "font-semibold"}, + "status:degraded": api.Text{Content: "Degraded"}, + } + if opts.Owner == "team/platform" { + return map[string]api.Textable{ + "status:healthy": options["status:healthy"], + } + } + return options +} + +type tagsEntityFilter struct{} + +func (tagsEntityFilter) Key() string { return "tags" } +func (tagsEntityFilter) Label() string { return "Tags" } + +func (tagsEntityFilter) Lookup(opts *entityFilterTestOpts) (map[string]api.Textable, error) { + if len(opts.Tags) == 0 { + return nil, nil + } + + selected := make(map[string]api.Textable, len(opts.Tags)) + for _, tag := range opts.Tags { + selected[tag] = api.Text{Content: strings.ToUpper(tag)} + } + return selected, nil +} + +func (tagsEntityFilter) Options(opts entityFilterTestOpts) map[string]api.Textable { + return map[string]api.Textable{ + "api": api.Text{Content: "API"}, + "worker": api.Text{Content: "Worker"}, + } +} + +type activeEntityFilter struct{} + +func (activeEntityFilter) Key() string { return "active" } +func (activeEntityFilter) Label() string { return "Active" } + +func (activeEntityFilter) Lookup(opts *entityFilterTestOpts) (map[string]api.Textable, error) { + if !opts.Active { + return nil, nil + } + return map[string]api.Textable{ + "true": api.Text{Content: "Active only"}, + }, nil +} + +func (activeEntityFilter) Options(opts entityFilterTestOpts) map[string]api.Textable { + return map[string]api.Textable{ + "true": api.Text{Content: "Active only"}, + } +} + +type sinceEntityFilter struct{} + +func (sinceEntityFilter) Key() string { return "since" } +func (sinceEntityFilter) Label() string { return "Since" } + +func (sinceEntityFilter) Lookup(opts *entityFilterTestOpts) (map[string]api.Textable, error) { + return nil, nil +} + +func (sinceEntityFilter) Options(opts entityFilterTestOpts) map[string]api.Textable { + return nil +} + +type fromEntityFilter struct{} + +func (fromEntityFilter) Key() string { return "from" } +func (fromEntityFilter) Label() string { return "From" } + +func (fromEntityFilter) Lookup(opts *entityFilterTestOpts) (map[string]api.Textable, error) { + return nil, nil +} + +func (fromEntityFilter) Options(opts entityFilterTestOpts) map[string]api.Textable { + return nil +} + +type toEntityFilter struct{} + +func (toEntityFilter) Key() string { return "to" } +func (toEntityFilter) Label() string { return "To" } + +func (toEntityFilter) Lookup(opts *entityFilterTestOpts) (map[string]api.Textable, error) { + return nil, nil +} + +func (toEntityFilter) Options(opts entityFilterTestOpts) map[string]api.Textable { + return nil +} + +func TestBuildOptsSupportsRichTypes(t *testing.T) { + opts, err := buildOpts[entityFilterTestOpts](map[string]string{ + "tags": "api,worker", + "window": "48h", + "since": "2026-04-20T15:04:05Z", + "owner": "platform", + "status": "healthy", + }) + if err != nil { + t.Fatalf("buildOpts returned error: %v", err) + } + + if len(opts.Tags) != 2 || opts.Tags[0] != "api" || opts.Tags[1] != "worker" { + t.Fatalf("expected tags to be parsed, got %#v", opts.Tags) + } + + if time.Duration(opts.Window) != 48*time.Hour { + t.Fatalf("expected 48h window, got %s", opts.Window) + } + + expectedSince := time.Date(2026, 4, 20, 15, 4, 5, 0, time.UTC) + if !opts.Since.Equal(expectedSince) { + t.Fatalf("expected since %s, got %s", expectedSince, opts.Since) + } + + if opts.Owner != "platform" || opts.Status != "healthy" { + t.Fatalf("expected string fields to be populated, got %#v", opts) + } +} + +func TestEntityListFiltersResolveAndLookup(t *testing.T) { + resetEntityRegistry(t) + defer resetEntityRegistry(t) + + var received entityFilterTestOpts + + RegisterEntity(Entity[entityFilterTestEntity, entityFilterTestOpts]{ + Name: "filtered-entity", + Filters: []Filter[entityFilterTestOpts]{ + ownerEntityFilter{}, + statusEntityFilter{}, + tagsEntityFilter{}, + activeEntityFilter{}, + sinceEntityFilter{}, + fromEntityFilter{}, + toEntityFilter{}, + }, + List: func(opts entityFilterTestOpts) ([]entityFilterTestEntity, error) { + received = opts + return []entityFilterTestEntity{{ID: "1", Name: "alpha"}}, nil + }, + }) + + root := &cobra.Command{Use: "root"} + GenerateCLI(root) + + listCmd, _, err := root.Find([]string{"filtered-entity", "list"}) + if err != nil || listCmd == nil { + t.Fatalf("expected to find list command, got err=%v", err) + } + + dataFunc := GetDataFunc(listCmd) + if dataFunc == nil { + t.Fatal("expected list command to register data func") + } + + if _, err := dataFunc(map[string]string{ + "owner": "platform", + "status": "healthy", + "tags": "api,worker", + "window": "48h", + "since": "2026-04-20T15:04:05Z", + }, nil); err != nil { + t.Fatalf("list data func returned error: %v", err) + } + + if received.Owner != "team/platform" { + t.Fatalf("expected owner to be resolved, got %q", received.Owner) + } + + if received.Status != "status:healthy" { + t.Fatalf("expected status to be resolved, got %q", received.Status) + } + + if len(received.Tags) != 2 || received.Tags[0] != "api" || received.Tags[1] != "worker" { + t.Fatalf("expected tags to be parsed, got %#v", received.Tags) + } + + if time.Duration(received.Window) != 48*time.Hour { + t.Fatalf("expected resolved window to be 48h, got %s", received.Window) + } + + lookupFunc := GetLookupFunc(listCmd) + if lookupFunc == nil { + t.Fatal("expected list command to register lookup func") + } + + lookupAny, err := lookupFunc(map[string]string{ + "owner": "platform", + "status": "healthy", + }, nil) + if err != nil { + t.Fatalf("lookup func returned error: %v", err) + } + + lookup, ok := lookupAny.(entityLookupResponse) + if !ok { + t.Fatalf("expected lookup response type, got %T", lookupAny) + } + + statusLookup, ok := lookup.Filters["status"] + if !ok { + t.Fatalf("expected status lookup payload, got %#v", lookup.Filters) + } + + if len(statusLookup.Options) != 1 { + t.Fatalf("expected narrowed status options, got %#v", statusLookup.Options) + } + + if !lookup.Filters["tags"].Multi { + t.Fatalf("expected tags lookup to advertise multi=true, got %#v", lookup.Filters["tags"]) + } + + if lookup.Filters["active"].Type != "bool" { + t.Fatalf("expected active lookup type=bool, got %#v", lookup.Filters["active"]) + } + + if lookup.Filters["since"].Type != "date" { + t.Fatalf("expected since lookup type=date, got %#v", lookup.Filters["since"]) + } + + if lookup.Filters["from"].Type != "from" { + t.Fatalf("expected from lookup type=from, got %#v", lookup.Filters["from"]) + } + + if lookup.Filters["to"].Type != "to" { + t.Fatalf("expected to lookup type=to, got %#v", lookup.Filters["to"]) + } + + selected := statusLookup.Selected["status:healthy"] + if selected.Kind != "text" || selected.Text != "Healthy" { + t.Fatalf("expected selected option to be clicky text, got %#v", selected) + } + + if selected.Style == nil || selected.Style.ClassName != "font-semibold" { + t.Fatalf("expected selected option style to be preserved, got %#v", selected.Style) + } + + if selected.Tooltip == nil || selected.Tooltip.Text != "Ready" { + t.Fatalf("expected selected option tooltip to be preserved, got %#v", selected.Tooltip) + } + + data, err := json.Marshal(lookup) + if err != nil { + t.Fatalf("failed to marshal lookup response: %v", err) + } + + if !strings.Contains(string(data), `"kind":"text"`) { + t.Fatalf("expected marshaled lookup to contain clicky nodes, got %s", string(data)) + } +} + +func TestEntityBulkActionUsesResolvedFilters(t *testing.T) { + resetEntityRegistry(t) + defer resetEntityRegistry(t) + + var received entityFilterTestOpts + + RegisterEntity(Entity[entityFilterTestEntity, entityFilterTestOpts]{ + Name: "filtered-bulk", + Filters: []Filter[entityFilterTestOpts]{ownerEntityFilter{}, statusEntityFilter{}}, + BulkActions: []BulkAction[entityFilterTestEntity, entityFilterTestOpts]{ + { + Name: "bulk-suspend", + Short: "Suspend entities matched by filters", + Run: func(ids []string, flags map[string]string) (any, error) { + return ids, nil + }, + RunFilter: func(opts entityFilterTestOpts, flags map[string]string) (any, error) { + received = opts + return map[string]string{ + "owner": opts.Owner, + "status": opts.Status, + }, nil + }, + }, + }, + }) + + root := &cobra.Command{Use: "root"} + GenerateCLI(root) + + bulkCmd, _, err := root.Find([]string{"filtered-bulk", "bulk-suspend"}) + if err != nil || bulkCmd == nil { + t.Fatalf("expected to find bulk action command, got err=%v", err) + } + + dataFunc := GetDataFunc(bulkCmd) + if dataFunc == nil { + t.Fatal("expected bulk action to register data func") + } + + if _, err := dataFunc(map[string]string{ + "filter": "status == 'healthy'", + "owner": "platform", + "status": "healthy", + }, []string{"123"}); err != nil { + t.Fatalf("bulk action data func returned error: %v", err) + } + + if received.Owner != "team/platform" { + t.Fatalf("expected owner to be resolved for bulk action, got %q", received.Owner) + } + + if received.Status != "status:healthy" { + t.Fatalf("expected status to be resolved for bulk action, got %q", received.Status) + } + + if GetLookupFunc(bulkCmd) == nil { + t.Fatal("expected bulk action to register lookup func") + } +} + +func TestEntityListCommandsRegisterFilterCompletions(t *testing.T) { + resetEntityRegistry(t) + defer resetEntityRegistry(t) + + RegisterEntity(Entity[entityFilterTestEntity, entityFilterTestOpts]{ + Name: "completion-entity", + Filters: []Filter[entityFilterTestOpts]{ownerEntityFilter{}, statusEntityFilter{}}, + List: func(opts entityFilterTestOpts) ([]entityFilterTestEntity, error) { + return nil, nil + }, + }) + + root := &cobra.Command{Use: "root"} + GenerateCLI(root) + + listCmd, _, err := root.Find([]string{"completion-entity", "list"}) + if err != nil || listCmd == nil { + t.Fatalf("expected to find list command, got err=%v", err) + } + + ownerCompletion, ok := listCmd.GetFlagCompletionFunc("owner") + if !ok { + t.Fatal("expected owner flag completion to be registered") + } + + completions, directive := ownerCompletion(listCmd, nil, "") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("expected no-file completion directive, got %v", directive) + } + + expectedOwner := []string{ + cobra.CompletionWithDesc("team/core", "Core"), + cobra.CompletionWithDesc("team/platform", "Platform"), + } + if len(completions) != len(expectedOwner) { + t.Fatalf("expected owner completions %#v, got %#v", expectedOwner, completions) + } + for i, completion := range expectedOwner { + if completions[i] != completion { + t.Fatalf("expected owner completion %q at index %d, got %q", completion, i, completions[i]) + } + } + + if err := listCmd.Flags().Set("owner", "platform"); err != nil { + t.Fatalf("failed to set owner flag: %v", err) + } + + statusCompletion, ok := listCmd.GetFlagCompletionFunc("status") + if !ok { + t.Fatal("expected status flag completion to be registered") + } + + completions, directive = statusCompletion(listCmd, nil, "status:") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("expected no-file completion directive, got %v", directive) + } + + expectedStatus := []string{ + cobra.CompletionWithDesc("status:healthy", "Healthy"), + } + if len(completions) != len(expectedStatus) { + t.Fatalf("expected status completions %#v, got %#v", expectedStatus, completions) + } + for i, completion := range expectedStatus { + if completions[i] != completion { + t.Fatalf("expected status completion %q at index %d, got %q", completion, i, completions[i]) + } + } +} + +func TestEntityBulkCommandsRegisterFilterCompletions(t *testing.T) { + resetEntityRegistry(t) + defer resetEntityRegistry(t) + + RegisterEntity(Entity[entityFilterTestEntity, entityFilterTestOpts]{ + Name: "completion-bulk", + Filters: []Filter[entityFilterTestOpts]{ownerEntityFilter{}, statusEntityFilter{}}, + BulkActions: []BulkAction[entityFilterTestEntity, entityFilterTestOpts]{ + { + Name: "bulk-suspend", + Short: "Suspend entities matched by filters", + Run: func(ids []string, flags map[string]string) (any, error) { + return ids, nil + }, + RunFilter: func(opts entityFilterTestOpts, flags map[string]string) (any, error) { + return nil, nil + }, + }, + }, + }) + + root := &cobra.Command{Use: "root"} + GenerateCLI(root) + + bulkCmd, _, err := root.Find([]string{"completion-bulk", "bulk-suspend"}) + if err != nil || bulkCmd == nil { + t.Fatalf("expected to find bulk action command, got err=%v", err) + } + + if err := bulkCmd.Flags().Set("owner", "platform"); err != nil { + t.Fatalf("failed to set owner flag: %v", err) + } + + statusCompletion, ok := bulkCmd.GetFlagCompletionFunc("status") + if !ok { + t.Fatal("expected bulk status flag completion to be registered") + } + + completions, directive := statusCompletion(bulkCmd, nil, "status:") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("expected no-file completion directive, got %v", directive) + } + + expected := []string{ + cobra.CompletionWithDesc("status:healthy", "Healthy"), + } + if len(completions) != len(expected) { + t.Fatalf("expected status completions %#v, got %#v", expected, completions) + } + for i, completion := range expected { + if completions[i] != completion { + t.Fatalf("expected status completion %q at index %d, got %q", completion, i, completions[i]) + } + } +} + +func TestEntityValidArgsPropagateToGeneratedIDCommands(t *testing.T) { + resetEntityRegistry(t) + defer resetEntityRegistry(t) + + validArgs := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + all := []string{"alpha", "beta"} + var matches []string + for _, candidate := range all { + if strings.HasPrefix(candidate, toComplete) { + matches = append(matches, candidate) + } + } + return matches, cobra.ShellCompDirectiveNoFileComp + } + + RegisterEntity(Entity[entityFilterTestEntity, entityFilterTestOpts]{ + Name: "id-completion-entity", + ValidArgs: validArgs, + Get: func(id string) (any, error) { + return id, nil + }, + Delete: func(id string) error { + return nil + }, + Actions: []Action[entityFilterTestEntity]{ + { + Name: "restart", + Short: "Restart one entity", + Run: func(id string, flags map[string]string) (any, error) { + return id, nil + }, + }, + }, + Admin: &Entity[entityFilterTestEntity, entityFilterTestOpts]{ + Get: func(id string) (any, error) { + return id, nil + }, + Actions: []Action[entityFilterTestEntity]{ + { + Name: "reconcile", + Short: "Reconcile one entity", + Run: func(id string, flags map[string]string) (any, error) { + return id, nil + }, + }, + }, + }, + }) + + root := &cobra.Command{Use: "root"} + GenerateCLI(root) + + paths := [][]string{ + {"id-completion-entity", "get"}, + {"id-completion-entity", "delete"}, + {"id-completion-entity", "restart"}, + {"admin", "id-completion-entity", "get"}, + {"admin", "id-completion-entity", "reconcile"}, + } + + for _, path := range paths { + cmd, _, err := root.Find(path) + if err != nil || cmd == nil { + t.Fatalf("expected to find command %v, got err=%v", path, err) + } + if cmd.ValidArgsFunction == nil { + t.Fatalf("expected valid args function on %v", path) + } + + completions, directive := cmd.ValidArgsFunction(cmd, nil, "a") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("expected no-file completion directive for %v, got %v", path, directive) + } + if len(completions) != 1 || completions[0] != "alpha" { + t.Fatalf("expected alpha completion for %v, got %#v", path, completions) + } + } +} diff --git a/examples/README.md b/examples/README.md index 43dd1bd8..fadc0752 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,6 +5,7 @@ This directory contains examples showing how to use the clicky library to format ## Files - `clicky-pipe.go` - Main example program that can read JSON from stdin or file +- `enitity/` - End-to-end entity example covering CRUD, filters, admin entities, nested parents, and `openapi serve --enable-executor` - `example-data.json` - Sample order data with complex nested structures and arrays - `go.mod` - Module configuration diff --git a/examples/enitity/e2e/.gitignore b/examples/enitity/e2e/.gitignore new file mode 100644 index 00000000..15791c3f --- /dev/null +++ b/examples/enitity/e2e/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +playwright-report/ +test-results/ +.cache/ diff --git a/examples/enitity/e2e/playwright.config.ts b/examples/enitity/e2e/playwright.config.ts new file mode 100644 index 00000000..49df8923 --- /dev/null +++ b/examples/enitity/e2e/playwright.config.ts @@ -0,0 +1,44 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig, devices } from "@playwright/test"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const exampleDir = path.resolve(__dirname, ".."); + +const PORT = process.env.E2E_PORT ?? "18080"; +const HOST = process.env.E2E_HOST ?? "127.0.0.1"; +const BASE_URL = process.env.E2E_BASE_URL ?? `http://${HOST}:${PORT}`; +const GOCACHE = process.env.GOCACHE ?? "/tmp/go-build"; + +export default defineConfig({ + testDir: "./tests", + fullyParallel: false, + workers: 1, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? "github" : "list", + timeout: 45_000, + expect: { + timeout: 10_000, + }, + use: { + baseURL: BASE_URL, + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + }, + }, + ], + webServer: { + command: `cd "${exampleDir}" && GOCACHE="${GOCACHE}" task build && GOCACHE="${GOCACHE}" ./entity-demo serve-ui --host ${HOST} --port ${PORT}`, + url: BASE_URL, + reuseExistingServer: false, + timeout: 240_000, + stdout: "pipe", + stderr: "pipe", + }, +}); diff --git a/examples/enitity/e2e/tests/entity-demo.spec.ts b/examples/enitity/e2e/tests/entity-demo.spec.ts new file mode 100644 index 00000000..b57fe7d8 --- /dev/null +++ b/examples/enitity/e2e/tests/entity-demo.spec.ts @@ -0,0 +1,228 @@ +import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; + +const stackRow = (page: Page, name: string) => + page.locator("tbody tr").filter({ hasText: name }).first(); +const explorerSearch = (page: Page) => page.getByPlaceholder("Search api explorer..."); + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function explorerCard(page: Page, path: string) { + return page.locator("a").filter({ + has: page.locator("code").filter({ + hasText: new RegExp(`^${escapeRegExp(path)}$`), + }), + }); +} + +function waitForJson(page: Page, pathname: string, expected: Record = {}) { + return page.waitForResponse((response) => { + const url = new URL(response.url()); + if (url.pathname !== pathname || !response.ok()) { + return false; + } + if (url.searchParams.get("__lookup") === "filters") { + return false; + } + return Object.entries(expected).every( + ([key, value]) => url.searchParams.get(key) === value, + ); + }); +} + +function waitForLookup(page: Page, pathname: string, expected: Record) { + return page.waitForResponse((response) => { + const url = new URL(response.url()); + if (url.pathname !== pathname || !response.ok()) { + return false; + } + if (url.searchParams.get("__lookup") !== "filters") { + return false; + } + return Object.entries(expected).every( + ([key, value]) => url.searchParams.get(key) === value, + ); + }); +} + +async function resetDemoStore(request: APIRequestContext) { + const response = await request.post("/api/v1/stack/seed"); + expect(response.ok()).toBeTruthy(); + expect(await response.text()).toContain("reset demo store with 3 stacks and 3 clusters"); +} + +test.describe("entity demo", () => { + test.beforeEach(async ({ request }) => { + await resetDemoStore(request); + }); + + test("boots from / into stacks and narrows rows with the live lookup filter", async ({ + page, + }) => { + const openApi = waitForJson(page, "/api/openapi.json"); + const list = waitForJson(page, "/api/v1/stack"); + const lookup = waitForLookup(page, "/api/v1/stack", {}); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await Promise.all([openApi, list, lookup]); + + await expect(page).toHaveURL(/\/stacks$/); + await expect(page.getByRole("heading", { name: "Stacks" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Stacks", exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "Clusters" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Admin — Stacks" })).toBeVisible(); + await expect(page.getByRole("link", { name: "API Explorer" })).toBeVisible(); + + await expect(stackRow(page, "checkout")).toBeVisible(); + await expect(stackRow(page, "billing")).toBeVisible(); + await expect(page.getByText("marketing-site")).toHaveCount(0); + + const filteredLookup = waitForLookup(page, "/api/v1/stack", { + team: "team/platform", + }); + await page.getByLabel("Team").fill("team/platform"); + await filteredLookup; + + const filteredList = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + url.pathname === "/api/v1/stack" && + response.ok() && + !url.searchParams.has("__lookup") && + url.searchParams.get("team") === "team/platform" + ); + }); + await page.getByRole("button", { name: "Apply" }).click(); + await filteredList; + + await expect(stackRow(page, "checkout")).toBeVisible(); + await expect(stackRow(page, "billing")).toHaveCount(0); + await expect(page.getByText("marketing-site")).toHaveCount(0); + }); + + test("shows cluster rows and the real cluster endpoint list", async ({ page }) => { + const clusters = waitForJson(page, "/api/v1/catalog/cluster"); + + await page.goto("/clusters", { waitUntil: "domcontentloaded" }); + await clusters; + + await expect(page.getByRole("heading", { name: "Clusters" })).toBeVisible(); + await expect(stackRow(page, "shared-eu1")).toBeVisible(); + await expect(stackRow(page, "payments-us1")).toBeVisible(); + + await page.getByRole("button", { name: "Endpoint list view" }).click(); + + await expect(page.getByText("/api/v1/catalog/cluster/{id}")).toBeVisible(); + await expect(page.getByText("Get a cluster by ID")).toBeVisible(); + }); + + test("surfaces archived admin rows and explorer endpoints from the generated spec", async ({ + page, + }) => { + const adminList = waitForJson(page, "/api/v1/admin/stack"); + + await page.goto("/admin-stacks", { waitUntil: "domcontentloaded" }); + await adminList; + + await expect(page.getByRole("heading", { name: "Admin — Stacks" })).toBeVisible(); + await expect(stackRow(page, "marketing-site")).toBeVisible(); + + await page.goto("/explorer", { waitUntil: "domcontentloaded" }); + + await expect(page.getByRole("heading", { name: "API Explorer" })).toBeVisible(); + await expect(page.getByText("/api/v1/stack/{id}/restart")).toBeVisible(); + await expect(page.getByText("/api/v1/admin/stack/{id}/reconcile")).toBeVisible(); + }); + + test("API Explorer stack endpoints can be accessed and executed", async ({ + page, + }) => { + const openApi = waitForJson(page, "/api/openapi.json"); + + await page.goto("/explorer", { waitUntil: "domcontentloaded" }); + await openApi; + + await expect(page.getByRole("heading", { name: "API Explorer" })).toBeVisible(); + + const search = explorerSearch(page); + + await search.fill("list stack resources"); + const listCard = explorerCard(page, "/api/v1/stack"); + await expect(listCard).toBeVisible(); + const listRequest = waitForJson(page, "/api/v1/stack"); + await listCard.click(); + await expect(page).toHaveURL(/\/commands\/stack_list$/); + await page.getByRole("button", { name: "Execute request" }).click(); + await listRequest; + await expect(page.getByLabel("Response body")).toContainText("checkout"); + await expect(page.getByLabel("Response body")).toContainText("billing"); + + await page.goto("/explorer", { waitUntil: "domcontentloaded" }); + await search.fill("get a stack by id"); + const getCard = explorerCard(page, "/api/v1/stack/{id}"); + await expect(getCard).toBeVisible(); + const getRequest = waitForJson(page, "/api/v1/stack/stk-001"); + await getCard.click(); + await expect(page).toHaveURL(/\/commands\/stack_get$/); + await page.getByLabel("Id").fill("stk-001"); + await page.getByRole("button", { name: "Execute request" }).click(); + await getRequest; + await expect(page.getByLabel("Response body")).toContainText("stk-001"); + await expect(page.getByLabel("Response body")).toContainText("checkout"); + + await page.goto("/explorer", { waitUntil: "domcontentloaded" }); + await search.fill("restart a stack"); + const actionCard = explorerCard(page, "/api/v1/stack/{id}/restart"); + await expect(actionCard).toBeVisible(); + const actionRequest = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + url.pathname === "/api/v1/stack/stk-002/restart" && + response.request().method() === "POST" && + response.ok() + ); + }); + await actionCard.click(); + await expect(page).toHaveURL(/\/commands\/stack_restart$/); + await page.getByLabel("Id").fill("stk-002"); + await page.getByLabel("Reason").fill("explorer-test"); + await page.getByLabel("Drain").uncheck(); + await page.getByRole("button", { name: "Execute request" }).click(); + await actionRequest; + await expect(page.getByLabel("Response body")).toContainText('"action": "restart"'); + await expect(page.getByLabel("Response body")).toContainText('"reason": "explorer-test"'); + }); + + test("reflects a real restart mutation after reloading the stacks table", async ({ + page, + request, + }) => { + const initialList = waitForJson(page, "/api/v1/stack"); + + await page.goto("/stacks", { waitUntil: "domcontentloaded" }); + await initialList; + + await expect(stackRow(page, "billing")).toContainText("status:degraded"); + + const response = await request.post("/api/v1/stack/stk-002/restart", { + data: { + reason: "playwright", + drain: false, + }, + }); + expect(response.ok()).toBeTruthy(); + expect(await response.json()).toMatchObject({ + action: "restart", + ids: ["stk-002"], + reason: "playwright", + }); + + const refreshedList = waitForJson(page, "/api/v1/stack"); + await page.reload({ waitUntil: "domcontentloaded" }); + await refreshedList; + + await expect(stackRow(page, "billing")).toContainText("status:healthy"); + await expect(stackRow(page, "billing")).not.toContainText("status:degraded"); + }); +}); diff --git a/examples/enitity/main.go b/examples/enitity/main.go new file mode 100644 index 00000000..602376d8 --- /dev/null +++ b/examples/enitity/main.go @@ -0,0 +1,1108 @@ +package main + +import ( + "context" + "embed" + "fmt" + "io/fs" + "net/http" + "os" + "os/signal" + "path" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/flanksource/clicky" + "github.com/flanksource/clicky/api" + "github.com/flanksource/clicky/extensions" + "github.com/flanksource/clicky/rpc" + "github.com/flanksource/commons/duration" + "github.com/spf13/cobra" +) + +// webappFS carries the Vite-built single-page app. `webapp/dist/index.html` +// is committed as a placeholder so this embed always resolves; running +// `pnpm build` inside webapp/ replaces the contents with hashed bundles. +// +//go:embed webapp/dist +var webappFS embed.FS + +type stack struct { + ID string `json:"id"` + Name string `json:"name"` + Team string `json:"team"` + Status string `json:"status"` + Region string `json:"region"` + Tags []string `json:"tags,omitempty"` + Archived bool `json:"archived,omitempty"` + Version int `json:"version"` + LastDeploy time.Time `json:"lastDeploy"` +} + +func (s stack) GetID() string { return s.ID } +func (s stack) GetName() string { return s.Name } + +func (s stack) PrettyRow(_ interface{}) map[string]api.Text { + return map[string]api.Text{ + "Name": { + Content: s.Name, + Style: "font-semibold", + }, + "Team": { + Content: labelFromCanonicalTeam(s.Team), + }, + "Status": { + Content: labelFromCanonicalStatus(s.Status), + Style: statusStyle(s.Status), + }, + "Region": { + Content: s.Region, + }, + "Tags": { + Content: strings.Join(s.Tags, ", "), + Style: "text-slate-600", + }, + } +} + +type cluster struct { + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + Region string `json:"region"` +} + +func (c cluster) GetID() string { return c.ID } +func (c cluster) GetName() string { return c.Name } + +type stackWindowOpts struct { + Tags []string `flag:"tags" help:"Return only stacks containing all of these tags"` + UpdatedSince time.Time `flag:"updated-since" help:"Return stacks deployed after this time" default:"now-30d"` + Window duration.Duration `flag:"window" help:"Return stacks deployed within this duration" default:"720h"` +} + +type stackListOpts struct { + stackWindowOpts + Team string `flag:"team" help:"Team slug or canonical backend team id"` + Status string `flag:"status" help:"Status label or canonical backend status id"` + Region string `flag:"region" help:"Region filter"` + Filter string `flag:"filter" help:"Switch bulk actions into filter mode when set"` + IncludeArchived bool `flag:"include-archived" help:"Include archived stacks"` +} + +type clusterListOpts struct { + Provider string `flag:"provider" help:"Cloud provider filter"` + Region string `flag:"region" help:"Region filter"` +} + +type inspectFlags struct { + Events int `flag:"events" help:"Number of synthetic events to include" default:"3"` + IncludeAudit bool `flag:"include-audit" help:"Include audit metadata" default:"false"` +} + +func (inspectFlags) ClickyActionFlags() {} + +type restartFlags struct { + Drain bool `flag:"drain" help:"Drain traffic before restart" default:"true"` + Reason string `flag:"reason" help:"Reason to record for the restart" default:"manual"` +} + +func (restartFlags) ClickyActionFlags() {} + +type adminInspectFlags struct { + IncludeSecret bool `flag:"include-secret" help:"Include simulated secret material" default:"false"` +} + +func (adminInspectFlags) ClickyActionFlags() {} + +type stackSummaryOpts struct { + Team string `flag:"team" help:"Only summarize one team"` + IncludeArchived bool `flag:"include-archived" help:"Include archived stacks in the summary"` +} + +type stackDetail struct { + Stack stack `json:"stack"` + Events []string `json:"events,omitempty"` + Audit map[string]any `json:"audit,omitempty"` + Notes map[string]string `json:"notes,omitempty"` + Secret map[string]string `json:"secret,omitempty"` + Admin map[string]string `json:"admin,omitempty"` + Flags map[string]string `json:"flags,omitempty"` + Meta map[string]any `json:"meta,omitempty"` +} + +type actionResult struct { + Action string `json:"action"` + IDs []string `json:"ids,omitempty"` + Reason string `json:"reason,omitempty"` + Drain bool `json:"drain,omitempty"` + MatchedBy string `json:"matchedBy,omitempty"` + MatchedIDs []string `json:"matchedIds,omitempty"` +} + +type stackSummary struct { + Total int `json:"total"` + ByTeam map[string]int `json:"byTeam"` + ByStatus map[string]int `json:"byStatus"` +} + +type demoStore struct { + mu sync.Mutex + nextStackID int + stacks map[string]stack + clusters map[string]cluster + restartLog []string + reconcileLog []string +} + +func newDemoStore() *demoStore { + store := &demoStore{} + store.reset() + return store +} + +func (d *demoStore) reset() { + d.mu.Lock() + defer d.mu.Unlock() + + d.nextStackID = 4 + d.restartLog = nil + d.reconcileLog = nil + d.stacks = map[string]stack{ + "stk-001": { + ID: "stk-001", + Name: "checkout", + Team: "team/platform", + Status: "status:healthy", + Region: "eu-west-1", + Tags: []string{"critical", "customer"}, + Version: 12, + LastDeploy: time.Now().Add(-4 * time.Hour).UTC(), + }, + "stk-002": { + ID: "stk-002", + Name: "billing", + Team: "team/core", + Status: "status:degraded", + Region: "us-east-1", + Tags: []string{"payments", "database"}, + Version: 19, + LastDeploy: time.Now().Add(-48 * time.Hour).UTC(), + }, + "stk-003": { + ID: "stk-003", + Name: "marketing-site", + Team: "team/platform", + Status: "status:paused", + Region: "eu-west-1", + Tags: []string{"public", "edge"}, + Archived: true, + Version: 7, + LastDeploy: time.Now().Add(-14 * 24 * time.Hour).UTC(), + }, + } + d.clusters = map[string]cluster{ + "cls-001": {ID: "cls-001", Name: "shared-eu1", Provider: "aws", Region: "eu-west-1"}, + "cls-002": {ID: "cls-002", Name: "payments-us1", Provider: "aws", Region: "us-east-1"}, + "cls-003": {ID: "cls-003", Name: "labs-eu2", Provider: "gcp", Region: "europe-west2"}, + } +} + +func (d *demoStore) counts() (int, int) { + d.mu.Lock() + defer d.mu.Unlock() + return len(d.stacks), len(d.clusters) +} + +func (d *demoStore) listStacks(opts stackListOpts) ([]stack, error) { + d.mu.Lock() + defer d.mu.Unlock() + + items := d.matchingStacksLocked(opts, false) + sort.Slice(items, func(i, j int) bool { + return items[i].ID < items[j].ID + }) + return items, nil +} + +func (d *demoStore) listAdminStacks(opts stackListOpts) ([]stack, error) { + d.mu.Lock() + defer d.mu.Unlock() + + items := d.matchingStacksLocked(opts, true) + sort.Slice(items, func(i, j int) bool { + return items[i].ID < items[j].ID + }) + return items, nil +} + +func (d *demoStore) getStack(id string, flags map[string]string) (any, error) { + d.mu.Lock() + defer d.mu.Unlock() + + item, ok := d.stacks[id] + if !ok { + return nil, fmt.Errorf("stack %q not found", id) + } + + detail := stackDetail{ + Stack: item, + Events: syntheticEvents( + item.Name, + intFlag(flags, "events", 3), + ), + Flags: copyStringMap(flags), + Notes: map[string]string{ + "team": labelFromCanonicalTeam(item.Team), + "status": labelFromCanonicalStatus(item.Status), + }, + } + if boolFlag(flags, "include-audit") { + detail.Audit = map[string]any{ + "lastRestart": lastEntry(d.restartLog), + "lastDeploy": item.LastDeploy, + "version": item.Version, + } + } + + return detail, nil +} + +func (d *demoStore) getAdminStack(id string, flags map[string]string) (any, error) { + d.mu.Lock() + defer d.mu.Unlock() + + item, ok := d.stacks[id] + if !ok { + return nil, fmt.Errorf("stack %q not found", id) + } + + detail := stackDetail{ + Stack: item, + Admin: map[string]string{ + "canonicalTeam": item.Team, + "canonicalStatus": item.Status, + "reconcileHint": "run admin stack reconcile to refresh synthetic health", + }, + Meta: map[string]any{ + "restartLog": append([]string(nil), d.restartLog...), + "reconcileLog": append([]string(nil), d.reconcileLog...), + }, + Flags: copyStringMap(flags), + } + if boolFlag(flags, "include-secret") { + detail.Secret = map[string]string{ + "token": "demo-token", + "rotation": "disabled in the example store", + } + } + return detail, nil +} + +func (d *demoStore) createStack(body map[string]any) (any, error) { + d.mu.Lock() + defer d.mu.Unlock() + + name := valueOrDefault(stringValue(body["name"]), "unnamed") + team := canonicalTeam(valueOrDefault(stringValue(body["team"]), "platform")) + status := canonicalStatus(valueOrDefault(stringValue(body["status"]), "healthy")) + region := valueOrDefault(stringValue(body["region"]), "eu-west-1") + id := fmt.Sprintf("stk-%03d", d.nextStackID) + d.nextStackID++ + + item := stack{ + ID: id, + Name: name, + Team: team, + Status: status, + Region: region, + Tags: sliceValue(body["tags"]), + Archived: boolValue(body["archived"]), + Version: 1, + LastDeploy: time.Now().UTC(), + } + d.stacks[id] = item + return item, nil +} + +func (d *demoStore) updateStack(id string, body map[string]any) (any, error) { + d.mu.Lock() + defer d.mu.Unlock() + + item, ok := d.stacks[id] + if !ok { + return nil, fmt.Errorf("stack %q not found", id) + } + + if value := stringValue(body["name"]); value != "" { + item.Name = value + } + if value := stringValue(body["team"]); value != "" { + item.Team = canonicalTeam(value) + } + if value := stringValue(body["status"]); value != "" { + item.Status = canonicalStatus(value) + } + if value := stringValue(body["region"]); value != "" { + item.Region = value + } + if raw, ok := body["tags"]; ok { + item.Tags = sliceValue(raw) + } + if raw, ok := body["archived"]; ok { + item.Archived = boolValue(raw) + } + item.Version++ + item.LastDeploy = time.Now().UTC() + d.stacks[id] = item + return item, nil +} + +func (d *demoStore) deleteStack(id string) error { + d.mu.Lock() + defer d.mu.Unlock() + + if _, ok := d.stacks[id]; !ok { + return fmt.Errorf("stack %q not found", id) + } + delete(d.stacks, id) + return nil +} + +func (d *demoStore) restartStack(id string, flags map[string]string) (any, error) { + d.mu.Lock() + defer d.mu.Unlock() + + item, ok := d.stacks[id] + if !ok { + return nil, fmt.Errorf("stack %q not found", id) + } + item.Status = "status:healthy" + item.Version++ + item.LastDeploy = time.Now().UTC() + d.stacks[id] = item + + reason := valueOrDefault(strings.TrimSpace(flags["reason"]), "manual") + drain := boolFlagDefault(flags, "drain", true) + entry := fmt.Sprintf("%s restarted with reason=%s drain=%t", id, reason, drain) + d.restartLog = append(d.restartLog, entry) + + return actionResult{ + Action: "restart", + IDs: []string{id}, + Reason: reason, + Drain: drain, + }, nil +} + +func (d *demoStore) reconcileStack(id string, flags map[string]string) (any, error) { + d.mu.Lock() + defer d.mu.Unlock() + + item, ok := d.stacks[id] + if !ok { + return nil, fmt.Errorf("stack %q not found", id) + } + item.Status = "status:healthy" + d.stacks[id] = item + + entry := fmt.Sprintf("%s reconciled", id) + d.reconcileLog = append(d.reconcileLog, entry) + + return actionResult{ + Action: "reconcile", + IDs: []string{id}, + }, nil +} + +func (d *demoStore) pauseStacks(ids []string, _ map[string]string) (any, error) { + d.mu.Lock() + defer d.mu.Unlock() + + updated := make([]string, 0, len(ids)) + for _, id := range ids { + item, ok := d.stacks[id] + if !ok { + return nil, fmt.Errorf("stack %q not found", id) + } + item.Status = "status:paused" + item.Version++ + d.stacks[id] = item + updated = append(updated, id) + } + sort.Strings(updated) + + return actionResult{ + Action: "pause", + IDs: updated, + }, nil +} + +func (d *demoStore) pauseStacksByFilter(opts stackListOpts, _ map[string]string) (any, error) { + d.mu.Lock() + defer d.mu.Unlock() + + matched := d.matchingStacksLocked(opts, true) + ids := make([]string, 0, len(matched)) + for _, item := range matched { + current := d.stacks[item.ID] + current.Status = "status:paused" + current.Version++ + d.stacks[item.ID] = current + ids = append(ids, item.ID) + } + sort.Strings(ids) + + return actionResult{ + Action: "pause", + MatchedBy: opts.Filter, + MatchedIDs: ids, + }, nil +} + +func (d *demoStore) summarizeStacks(opts stackSummaryOpts) (any, error) { + d.mu.Lock() + defer d.mu.Unlock() + + summary := stackSummary{ + ByTeam: map[string]int{}, + ByStatus: map[string]int{}, + } + team := canonicalTeam(opts.Team) + for _, item := range d.stacks { + if !opts.IncludeArchived && item.Archived { + continue + } + if opts.Team != "" && item.Team != team { + continue + } + summary.Total++ + summary.ByTeam[labelFromCanonicalTeam(item.Team)]++ + summary.ByStatus[labelFromCanonicalStatus(item.Status)]++ + } + return summary, nil +} + +func (d *demoStore) listClusters(opts clusterListOpts) ([]cluster, error) { + d.mu.Lock() + defer d.mu.Unlock() + + items := make([]cluster, 0, len(d.clusters)) + for _, item := range d.clusters { + if opts.Provider != "" && !strings.EqualFold(item.Provider, opts.Provider) { + continue + } + if opts.Region != "" && !strings.EqualFold(item.Region, opts.Region) { + continue + } + items = append(items, item) + } + sort.Slice(items, func(i, j int) bool { + return items[i].ID < items[j].ID + }) + return items, nil +} + +func (d *demoStore) getCluster(id string) (any, error) { + d.mu.Lock() + defer d.mu.Unlock() + + item, ok := d.clusters[id] + if !ok { + return nil, fmt.Errorf("cluster %q not found", id) + } + return item, nil +} + +func (d *demoStore) completeStackIDs(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + d.mu.Lock() + defer d.mu.Unlock() + + ids := make([]string, 0, len(d.stacks)) + for id := range d.stacks { + if strings.HasPrefix(id, toComplete) { + ids = append(ids, id) + } + } + sort.Strings(ids) + return ids, cobra.ShellCompDirectiveNoFileComp +} + +func (d *demoStore) matchingStacksLocked(opts stackListOpts, includeArchived bool) []stack { + items := make([]stack, 0, len(d.stacks)) + for _, item := range d.stacks { + if item.Archived && !includeArchived && !opts.IncludeArchived { + continue + } + if opts.Team != "" && item.Team != opts.Team { + continue + } + if opts.Status != "" && item.Status != opts.Status { + continue + } + if opts.Region != "" && !strings.EqualFold(item.Region, opts.Region) { + continue + } + if len(opts.Tags) > 0 && !containsAll(item.Tags, opts.Tags) { + continue + } + if !opts.UpdatedSince.IsZero() && item.LastDeploy.Before(opts.UpdatedSince) { + continue + } + if time.Duration(opts.Window) > 0 && item.LastDeploy.Before(time.Now().Add(-time.Duration(opts.Window))) { + continue + } + items = append(items, item) + } + return items +} + +type stackTeamFilter struct{} + +func (stackTeamFilter) Key() string { return "team" } +func (stackTeamFilter) Label() string { return "Team" } + +func (stackTeamFilter) Lookup(opts *stackListOpts) (map[string]api.Textable, error) { + if opts.Team == "" { + return nil, nil + } + opts.Team = canonicalTeam(opts.Team) + label := labelFromCanonicalTeam(opts.Team) + return map[string]api.Textable{ + opts.Team: api.Text{ + Content: label, + Style: "font-semibold", + }, + }, nil +} + +func (stackTeamFilter) Options(opts stackListOpts) map[string]api.Textable { + options := map[string]api.Textable{ + "team/platform": api.Text{Content: "Platform"}, + "team/core": api.Text{Content: "Core"}, + "team/data": api.Text{Content: "Data"}, + } + if strings.EqualFold(opts.Region, "us-east-1") { + return map[string]api.Textable{ + "team/core": options["team/core"], + } + } + return options +} + +type stackStatusFilter struct{} + +func (stackStatusFilter) Key() string { return "status" } +func (stackStatusFilter) Label() string { return "Status" } + +func (stackStatusFilter) Lookup(opts *stackListOpts) (map[string]api.Textable, error) { + if opts.Status == "" { + return nil, nil + } + opts.Status = canonicalStatus(opts.Status) + label := labelFromCanonicalStatus(opts.Status) + return map[string]api.Textable{ + opts.Status: api.Text{ + Content: label, + Style: statusStyle(opts.Status), + Tooltip: api.Text{Content: "Canonical backend value: " + opts.Status}, + }, + }, nil +} + +func (stackStatusFilter) Options(opts stackListOpts) map[string]api.Textable { + options := map[string]api.Textable{ + "status:healthy": api.Text{Content: "Healthy", Style: statusStyle("status:healthy")}, + "status:degraded": api.Text{Content: "Degraded", Style: statusStyle("status:degraded")}, + "status:paused": api.Text{Content: "Paused", Style: statusStyle("status:paused")}, + } + if opts.Team == "team/platform" { + return map[string]api.Textable{ + "status:healthy": options["status:healthy"], + "status:paused": options["status:paused"], + } + } + return options +} + +func registerEntities(store *demoStore) { + stackFilters := []clicky.Filter[stackListOpts]{ + stackTeamFilter{}, + stackStatusFilter{}, + } + + clicky.RegisterEntity(clicky.Entity[stack, stackListOpts]{ + Name: "stack", + Aliases: []string{"stacks", "svc"}, + Filters: stackFilters, + List: store.listStacks, + GetFlags: inspectFlags{}, + GetWithFlags: func(id string, flags map[string]string) (any, error) { + return store.getStack(id, flags) + }, + Create: store.createStack, + Update: store.updateStack, + Delete: store.deleteStack, + Actions: []clicky.Action[stack]{ + { + Name: "restart", + Short: "Restart a stack and record synthetic audit metadata", + Flags: restartFlags{}, + Run: func(id string, flags map[string]string) (any, error) { + return store.restartStack(id, flags) + }, + }, + }, + BulkActions: []clicky.BulkAction[stack, stackListOpts]{ + { + Name: "pause", + Short: "Pause stacks directly by id or indirectly through filter mode", + Run: func(ids []string, flags map[string]string) (any, error) { + return store.pauseStacks(ids, flags) + }, + RunFilter: func(opts stackListOpts, flags map[string]string) (any, error) { + return store.pauseStacksByFilter(opts, flags) + }, + }, + }, + Admin: &clicky.Entity[stack, stackListOpts]{ + Filters: stackFilters, + List: store.listAdminStacks, + GetFlags: adminInspectFlags{}, + GetWithFlags: func(id string, flags map[string]string) (any, error) { + return store.getAdminStack(id, flags) + }, + Actions: []clicky.Action[stack]{ + { + Name: "reconcile", + Short: "Force a synthetic admin reconcile for a stack", + Run: func(id string, flags map[string]string) (any, error) { + return store.reconcileStack(id, flags) + }, + }, + }, + }, + ValidArgs: store.completeStackIDs, + }) + + clicky.RegisterEntity(clicky.Entity[cluster, clusterListOpts]{ + Parent: "catalog", + List: store.listClusters, + Get: store.getCluster, + }) +} + +func registerSubCommands(store *demoStore) { + clicky.RegisterSubCommand("stack", &cobra.Command{ + Use: "seed", + Short: "Reset the in-memory demo data set", + RunE: func(cmd *cobra.Command, args []string) error { + store.reset() + stacks, clusters := store.counts() + fmt.Fprintf(cmd.OutOrStdout(), "reset demo store with %d stacks and %d clusters\n", stacks, clusters) + return nil + }, + }) + + clicky.RegisterSubCommandFn("stack", func(parent *cobra.Command) { + clicky.AddNamedCommand("summary", parent, stackSummaryOpts{}, store.summarizeStacks) + }) +} + +// newServeUICommand wires the clicky RPC executor together with the Vite +// webapp embedded above. The webapp consumes `@flanksource/clicky-ui`'s +// `OperationCatalog` against `/api/openapi.json` and `/api/v1/...`. +func newServeUICommand() *cobra.Command { + var ( + host string + port int + ) + + cmd := &cobra.Command{ + Use: "serve-ui", + Short: "Start the HTTP API and embedded operation-catalog UI", + Long: `Start an HTTP server that exposes both the executor-backed OpenAPI endpoints +and the embedded React UI built from clicky-ui's OperationCatalog component. + +The API is served at /api/openapi.json + /api/v1/..., the UI at /. Build the +Vite frontend with ` + "`cd webapp && pnpm install && pnpm build`" + ` before +compiling the Go binary so the embedded assets are current.`, + Example: ` entity-demo serve-ui --port 8080 + entity-demo serve-ui --host 0.0.0.0 --port 9090`, + RunE: func(cmd *cobra.Command, args []string) error { + if port < 1 || port > 65535 { + return fmt.Errorf("invalid port: %d", port) + } + + rootCmd := cmd.Root() + openAPIConfig := &rpc.OpenAPIConfig{ + Title: "Clicky Entity Example", + Description: "Entity example app with embedded OperationCatalog UI.", + Version: "1.0.0", + } + serveConfig := &rpc.ServeConfig{ + Host: host, + Port: port, + Title: openAPIConfig.Title, + Version: openAPIConfig.Version, + Executor: &rpc.ExecutorConfig{ + Enabled: true, + SkipPreRun: true, + PathPrefix: "/api/v1", + }, + } + + server := rpc.NewSwaggerServer(serveConfig, rootCmd, openAPIConfig) + + mux := http.NewServeMux() + server.RegisterRoutes(mux) + + uiHandler, err := newWebappHandler() + if err != nil { + return fmt.Errorf("load embedded webapp: %w", err) + } + mux.Handle("/", uiHandler) + + addr := fmt.Sprintf("%s:%d", host, port) + httpSrv := &http.Server{ + Addr: addr, + Handler: mux, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) + defer stop() + + errCh := make(chan error, 1) + go func() { + fmt.Fprintf(cmd.OutOrStdout(), "🚀 Entity UI listening on http://%s\n", addr) + fmt.Fprintf(cmd.OutOrStdout(), " • UI: http://%s/\n", addr) + fmt.Fprintf(cmd.OutOrStdout(), " • OpenAPI JSON: http://%s/api/openapi.json\n", addr) + fmt.Fprintf(cmd.OutOrStdout(), " • Executor API: http://%s/api/v1/...\n", addr) + if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errCh <- err + } + }() + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return httpSrv.Shutdown(shutdownCtx) + case err := <-errCh: + return err + } + }, + } + + cmd.Flags().StringVar(&host, "host", "localhost", "Host to bind the server to") + cmd.Flags().IntVarP(&port, "port", "p", 8080, "Port to bind the server to") + + return cmd +} + +// newWebappHandler returns an http.Handler that serves the embedded Vite +// build. Unknown paths fall back to index.html so the React router can +// handle client-side routes on a full page load. +func newWebappHandler() (http.Handler, error) { + sub, err := fs.Sub(webappFS, "webapp/dist") + if err != nil { + return nil, err + } + fileServer := http.FileServer(http.FS(sub)) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requested := strings.TrimPrefix(r.URL.Path, "/") + if requested == "" { + serveIndex(w, sub) + return + } + if _, err := fs.Stat(sub, requested); err != nil { + // File not found: assume a SPA route and return index.html. + if !looksLikeAssetRequest(requested) { + serveIndex(w, sub) + return + } + http.NotFound(w, r) + return + } + fileServer.ServeHTTP(w, r) + }), nil +} + +func serveIndex(w http.ResponseWriter, sub fs.FS) { + data, err := fs.ReadFile(sub, "index.html") + if err != nil { + http.Error(w, "webapp index.html missing — run `pnpm build` in webapp/", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + _, _ = w.Write(data) +} + +// looksLikeAssetRequest returns true when the request targets a file with a +// known extension, so we don't swallow a genuine 404 (e.g. a missing image +// reference) with the SPA fallback. +func looksLikeAssetRequest(requested string) bool { + ext := strings.ToLower(path.Ext(requested)) + switch ext { + case ".js", ".mjs", ".css", ".map", ".ico", ".png", ".jpg", ".jpeg", + ".gif", ".svg", ".webp", ".woff", ".woff2", ".ttf", ".eot", ".txt": + return true + } + return false +} + +func main() { + store := newDemoStore() + + rootCmd := &cobra.Command{ + Use: "entity-demo", + Short: "Entity example covering clicky entity generation and served execution", + Long: `A self-contained example showing how clicky entities can power both a CLI +and the executor-backed OpenAPI serve mode from the same registrations.`, + } + + registerEntities(store) + registerSubCommands(store) + clicky.GenerateCLI(rootCmd) + + extensions.CobraExtensions(rootCmd).OpenAPICommandWithConfig(&rpc.OpenAPIConfig{ + Title: "Clicky Entity Example", + Description: "Entity example app covering CRUD, actions, filters, admin views, nested parents, and executor-backed serve mode.", + Version: "1.0.0", + Tags: []rpc.OpenAPITag{ + {Name: "stack", Description: "Stack entity operations"}, + {Name: "catalog", Description: "Nested catalog entity operations"}, + {Name: "admin", Description: "Administrative entity operations"}, + }, + }) + + rootCmd.AddCommand(newServeUICommand()) + + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func containsAll(have, want []string) bool { + if len(want) == 0 { + return true + } + set := make(map[string]struct{}, len(have)) + for _, value := range have { + set[strings.ToLower(strings.TrimSpace(value))] = struct{}{} + } + for _, value := range want { + if _, ok := set[strings.ToLower(strings.TrimSpace(value))]; !ok { + return false + } + } + return true +} + +func canonicalTeam(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + switch value { + case "", "team/platform": + return "team/platform" + case "platform": + return "team/platform" + case "team/core": + return "team/core" + case "core": + return "team/core" + case "team/data": + return "team/data" + case "data": + return "team/data" + default: + if strings.HasPrefix(value, "team/") { + return value + } + return "team/" + value + } +} + +func labelFromCanonicalTeam(value string) string { + return titleCase(strings.TrimPrefix(value, "team/")) +} + +func canonicalStatus(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + switch value { + case "", "status:healthy": + return "status:healthy" + case "healthy": + return "status:healthy" + case "status:degraded": + return "status:degraded" + case "degraded": + return "status:degraded" + case "status:paused": + return "status:paused" + case "paused": + return "status:paused" + default: + if strings.HasPrefix(value, "status:") { + return value + } + return "status:" + value + } +} + +func labelFromCanonicalStatus(value string) string { + return titleCase(strings.TrimPrefix(value, "status:")) +} + +func statusStyle(value string) string { + switch value { + case "status:healthy": + return "text-green-600 font-semibold" + case "status:degraded": + return "text-amber-600 font-semibold" + case "status:paused": + return "text-slate-500" + default: + return "text-slate-700" + } +} + +func syntheticEvents(name string, count int) []string { + if count <= 0 { + return nil + } + events := make([]string, 0, count) + for i := 0; i < count; i++ { + events = append(events, fmt.Sprintf("%s event %d", name, i+1)) + } + return events +} + +func lastEntry(values []string) string { + if len(values) == 0 { + return "" + } + return values[len(values)-1] +} + +func copyStringMap(values map[string]string) map[string]string { + if len(values) == 0 { + return nil + } + out := make(map[string]string, len(values)) + for key, value := range values { + out[key] = value + } + return out +} + +func intFlag(flags map[string]string, key string, fallback int) int { + if value, err := strconv.Atoi(flags[key]); err == nil { + return value + } + return fallback +} + +func boolFlag(flags map[string]string, key string) bool { + value, err := strconv.ParseBool(flags[key]) + return err == nil && value +} + +func boolFlagDefault(flags map[string]string, key string, fallback bool) bool { + value, err := strconv.ParseBool(flags[key]) + if err != nil { + return fallback + } + return value +} + +func stringValue(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case fmt.Stringer: + return strings.TrimSpace(typed.String()) + case nil: + return "" + default: + return strings.TrimSpace(fmt.Sprintf("%v", typed)) + } +} + +func sliceValue(value any) []string { + switch typed := value.(type) { + case []string: + return append([]string(nil), typed...) + case []any: + items := make([]string, 0, len(typed)) + for _, item := range typed { + if value := stringValue(item); value != "" { + items = append(items, value) + } + } + return items + case string: + if typed == "" { + return nil + } + parts := strings.Split(typed, ",") + items := make([]string, 0, len(parts)) + for _, part := range parts { + if value := strings.TrimSpace(part); value != "" { + items = append(items, value) + } + } + return items + default: + value := stringValue(value) + if value == "" { + return nil + } + return []string{value} + } +} + +func boolValue(value any) bool { + switch typed := value.(type) { + case bool: + return typed + case string: + parsed, err := strconv.ParseBool(typed) + return err == nil && parsed + default: + parsed, err := strconv.ParseBool(fmt.Sprintf("%v", typed)) + return err == nil && parsed + } +} + +func valueOrDefault(value, fallback string) string { + if value == "" { + return fallback + } + return value +} + +func titleCase(value string) string { + if value == "" { + return "" + } + parts := strings.FieldsFunc(value, func(r rune) bool { + return r == '-' || r == '_' || r == ' ' + }) + for i := range parts { + if parts[i] == "" { + continue + } + parts[i] = strings.ToUpper(parts[i][:1]) + strings.ToLower(parts[i][1:]) + } + return strings.Join(parts, " ") +} diff --git a/examples/enitity/webapp/.gitignore b/examples/enitity/webapp/.gitignore new file mode 100644 index 00000000..c5e4a1b2 --- /dev/null +++ b/examples/enitity/webapp/.gitignore @@ -0,0 +1,13 @@ +node_modules +# Keep dist/index.html committed as a placeholder so go:embed always has +# something to embed. `vite build` regenerates the directory with real +# hashed assets which we ignore here. +dist/assets +dist/*.js +dist/*.css +dist/*.svg +dist/*.ico +dist/*.png +dist/.vite +*.log +.vite diff --git a/examples/enitity/webapp/src/App.tsx b/examples/enitity/webapp/src/App.tsx new file mode 100644 index 00000000..7c19f774 --- /dev/null +++ b/examples/enitity/webapp/src/App.tsx @@ -0,0 +1,95 @@ +import { Link, Navigate, Route, Routes, useParams } from "react-router-dom"; +import { + OperationCatalog, + OperationCommandPage, + ThemeSwitcher, + type RenderLink, +} from "@flanksource/clicky-ui"; +import { apiClient } from "./api"; +import { domainOrder, domains } from "./domains"; + +const renderLink: RenderLink = ({ to, className, children, title, key }) => ( + + {children} + +); + +function DomainPage() { + const { domainKey } = useParams<{ domainKey: string }>(); + const spec = domainKey ? domains[domainKey] : undefined; + if (!spec) { + return ( +
+ Unknown domain: {domainKey} +
+ ); + } + return ( + + ); +} + +function CommandRoute() { + const { operationId } = useParams<{ operationId: string }>(); + return ( + + ); +} + +function Sidebar() { + return ( + + ); +} + +export function App() { + return ( +
+ +
+ + } /> + } /> + } /> + +
+
+ ); +} diff --git a/examples/enitity/webapp/src/api.ts b/examples/enitity/webapp/src/api.ts new file mode 100644 index 00000000..f132d0f0 --- /dev/null +++ b/examples/enitity/webapp/src/api.ts @@ -0,0 +1,100 @@ +import type { + ExecutionResponse, + OpenAPISpec, + OperationLookupResponse, + OperationsApiClient, +} from "@flanksource/clicky-ui"; + +// substitutePath replaces `{name}` segments in an OpenAPI path with the +// corresponding values from `params`, returning the resolved path and the +// remaining params (those not consumed by a path segment). +function substitutePath( + path: string, + params: Record, +): { resolved: string; remaining: Record } { + const remaining = { ...params }; + let resolved = path; + for (const [key, value] of Object.entries(params)) { + if (resolved.includes(`{${key}}`)) { + resolved = resolved.replace(`{${key}}`, encodeURIComponent(value)); + delete remaining[key]; + delete remaining.args; + } + } + return { resolved, remaining }; +} + +async function request( + path: string, + method: string, + body?: unknown, + headers?: Record, +): Promise { + const upper = method.toUpperCase(); + const init: RequestInit = { + method: upper, + headers: { Accept: "application/json", ...(headers ?? {}) }, + }; + if (upper !== "GET" && body !== undefined) { + init.headers = { "Content-Type": "application/json", ...init.headers }; + init.body = JSON.stringify(body); + } + const response = await fetch(path, init); + const text = await response.text(); + if (!response.ok) { + throw new Error( + `${upper} ${path} failed with ${response.status}: ${text || response.statusText}`, + ); + } + return { + success: true, + exit_code: 0, + stdout: text, + }; +} + +export const apiClient: OperationsApiClient = { + async getOpenAPISpec(): Promise { + const response = await fetch("/api/openapi.json", { + headers: { Accept: "application/json" }, + }); + if (!response.ok) { + throw new Error( + `GET /api/openapi.json failed with ${response.status}: ${response.statusText}`, + ); + } + return (await response.json()) as OpenAPISpec; + }, + + async executeCommand(path, method, params, headers) { + const { resolved, remaining } = substitutePath(path, params); + if (method.toUpperCase() === "GET") { + const search = new URLSearchParams(remaining).toString(); + const url = search ? `${resolved}?${search}` : resolved; + return request(url, method, undefined, headers); + } + return request(resolved, method, remaining, headers); + }, + + async lookupFilters( + path: string, + _method: string, + params: Record, + headers?: Record, + ): Promise { + const { resolved, remaining } = substitutePath(path, params); + const searchParams = new URLSearchParams(remaining); + searchParams.set("__lookup", "filters"); + const url = `${resolved}?${searchParams.toString()}`; + const response = await fetch(url, { + method: "GET", + headers: { Accept: "application/json+clicky", ...(headers ?? {}) }, + }); + if (!response.ok) { + throw new Error( + `GET ${url} failed with ${response.status}: ${await response.text() || response.statusText}`, + ); + } + return (await response.json()) as OperationLookupResponse; + }, +}; diff --git a/examples/enitity/webapp/src/domains.ts b/examples/enitity/webapp/src/domains.ts new file mode 100644 index 00000000..148e06f0 --- /dev/null +++ b/examples/enitity/webapp/src/domains.ts @@ -0,0 +1,64 @@ +import type { DomainDefinition } from "@flanksource/clicky-ui"; + +export type DomainSpec = { + definition: DomainDefinition; + entities: string[]; + // When true, the OperationCatalog displays all operations regardless of + // entity tags. Use for an API Explorer-style view. + allOperations?: boolean; + operationIdPrefix?: string; + listOperationId?: string; + detailOperationId?: string; +}; + +export const domains: Record = { + stacks: { + definition: { + key: "stacks", + title: "Stacks", + description: + "Deployable stacks (checkout, billing, …). Lists, filters, and runs entity actions registered with clicky.", + }, + entities: ["stack"], + }, + clusters: { + definition: { + key: "clusters", + title: "Clusters", + description: + "Cloud clusters backing the demo stacks. Nested under the `catalog` parent in the entity registration.", + }, + entities: ["cluster"], + operationIdPrefix: "catalog_", + listOperationId: "catalog_cluster_list", + detailOperationId: "catalog_cluster_get", + }, + "admin-stacks": { + definition: { + key: "admin-stacks", + title: "Admin — Stacks", + description: + "Administrative view of the stack entity. Surfaces archived rows and admin-only fields (secret material, reconcile metadata).", + }, + entities: ["stack"], + operationIdPrefix: "admin_", + listOperationId: "admin_stack_list", + detailOperationId: "admin_stack_get", + }, + explorer: { + definition: { + key: "explorer", + title: "API Explorer", + description: "Every operation exposed by the entity demo's OpenAPI spec.", + }, + entities: [], + allOperations: true, + }, +}; + +export const domainOrder: Array = [ + "stacks", + "clusters", + "admin-stacks", + "explorer", +]; diff --git a/examples/enitity/webapp/src/index.css b/examples/enitity/webapp/src/index.css new file mode 100644 index 00000000..1492e0b4 --- /dev/null +++ b/examples/enitity/webapp/src/index.css @@ -0,0 +1,18 @@ +@import "tailwindcss"; +@import "@flanksource/clicky-ui/styles.css"; +@source "../node_modules/@flanksource/clicky-ui/dist"; + +html, +body, +#root { + height: 100%; + margin: 0; + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + sans-serif; +} diff --git a/examples/enitity/webapp/src/main.tsx b/examples/enitity/webapp/src/main.tsx new file mode 100644 index 00000000..d8ca8f9c --- /dev/null +++ b/examples/enitity/webapp/src/main.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter } from "react-router-dom"; +import { ThemeProvider, DensityProvider } from "@flanksource/clicky-ui"; +// Register the web component so clicky-ui's glyphs +// render (it loads icons on-demand from the Iconify CDN at runtime). +import "iconify-icon"; +import "@flanksource/clicky-ui/styles.css"; +import "./index.css"; +import { App } from "./App"; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: 0, staleTime: 30_000 } }, +}); + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + + + + + + , +); diff --git a/examples/enitity/webapp/tsconfig.tsbuildinfo b/examples/enitity/webapp/tsconfig.tsbuildinfo new file mode 100644 index 00000000..d446189f --- /dev/null +++ b/examples/enitity/webapp/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.tsx","./src/api.ts","./src/domains.ts","./src/main.tsx"],"version":"5.9.3"} \ No newline at end of file diff --git a/examples/enitity/webapp/vite.config.ts b/examples/enitity/webapp/vite.config.ts new file mode 100644 index 00000000..b2820cde --- /dev/null +++ b/examples/enitity/webapp/vite.config.ts @@ -0,0 +1,31 @@ +import path from "path"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +// The Go server injects CLICKY_EXAMPLE_API_URL when launched via +// `entity-demo serve-ui --dev`, so Vite's proxy targets the same process. +const apiTarget = process.env.CLICKY_EXAMPLE_API_URL || "http://localhost:8080"; + +export default defineConfig({ + // Relative base so assets resolve correctly when served from any mount + // point in the Go binary (go:embed serves off "/"). + base: "./", + plugins: [tailwindcss(), react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + dedupe: ["react", "react-dom"], + }, + server: { + proxy: { + "/api": apiTarget, + "/health": apiTarget, + }, + }, + build: { + outDir: "dist", + emptyOutDir: true, + }, +}); diff --git a/examples/go.sum b/examples/go.sum index 8e6e54c9..d0ca1d2d 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -1,50 +1,115 @@ cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.12.0/go.mod h1:J7MUC/wtRpfGVbQ5sIItY5/FuVWmvzlY21WAOfQnq/I= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0/go.mod h1:fSvRkb8d26z9dbL40Uf/OO6Vo9iExtZK3D0ulRV+8M0= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= 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/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= github.com/Snawoot/go-http-digest-auth-client v1.1.3 h1:Xd/SNBuIUJqotzmxRpbXovBJxmlVZOT19IZZdMdrJ0Q= github.com/Snawoot/go-http-digest-auth-client v1.1.3/go.mod h1:WiwNiPXTRGyjTGpBtSQJlM2wDPRRPpFGhMkMWpV4uqg= +github.com/Venafi/vcert/v5 v5.12.2/go.mod h1:x3l0pB0q0E6wuhPe7nzfkUEwwraK7amnBWQ4LtT1bbw= github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= +github.com/akamai/AkamaiOPEN-edgegrid-golang/v12 v12.0.0/go.mod h1:Bf6hnZkloZnfL4I/gFGnMMMdMHiu/ERnSOWtFgnodDk= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= +github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/config v1.32.9/go.mod h1:U+fCQ+9QKsLW786BCfEjYRj34VVTbPdsLP3CHSYXMOI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= +github.com/aws/aws-sdk-go-v2/service/route53 v1.58.4/go.mod h1:xNLZLn4SusktBQ5moqUOgiDKGz3a7vHwF4W0KD+WBPc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.10/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cert-manager/cert-manager v1.16.1 h1:1ceFMqTtwiqY2vyfaRT85CNiVmK7pJjt3GebYCx9awY= github.com/cert-manager/cert-manager v1.16.1/go.mod h1:MfLVTL45hFZsqmaT1O0+b2ugaNNQQZttSFV9hASHUb0= +github.com/cert-manager/cert-manager v1.19.4 h1:7lOkSYj+nJNjgGFfAznQzPpOfWX+1Kgz6xUXwTa/K5k= +github.com/cert-manager/cert-manager v1.19.4/go.mod h1:9uBnn3IK9NxjjuXmQDYhwOwFUU5BtGVB1g/voPvvcVw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= @@ -55,36 +120,63 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ= +github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/digitalocean/godo v1.165.1/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU= github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A= +github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= 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/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/flanksource/commons v1.42.3 h1:53tr1A8fFywYD/56kZgYNAxUEqOdvbXAiQr+3/M2Zso= github.com/flanksource/commons v1.42.3/go.mod h1:xEBCobwaM0+EHn2SvFpqiCBJCVk1803An1kZleXFR9Y= +github.com/flanksource/commons v1.47.2 h1:3CE3MHBWM/1rehHDduUR8r55rP8HorbYuiB2LO8Rjmw= +github.com/flanksource/commons v1.47.2/go.mod h1:ouTo/VFVZ/7LKEDWgHLQKdCJsTreDrUOmV0Z3CxZosY= github.com/flanksource/gomplate/v3 v3.24.60 h1:g1SjmR3m5YlXpuxyegvEj6OVbkoqP3btZbqUH0f7920= github.com/flanksource/gomplate/v3 v3.24.60/go.mod h1:hGEObNtnOQs8rNUX8sM8aJTAhnt4ehjyOw1MvDhl6AU= +github.com/flanksource/gomplate/v3 v3.24.74 h1:LR1TGUxbQiZyjPgxP7dElVZi6LzMwBVqyZ3z0FgkMPU= +github.com/flanksource/gomplate/v3 v3.24.74/go.mod h1:l3iet81uj0sje3uMIkAURjOu1E4UErJ3PMVTYhvABCM= github.com/flanksource/is-healthy v1.0.79 h1:fBdmJJ7CoNtphZ08gOKxHWS76HltGwIi2L1396cxU2s= github.com/flanksource/is-healthy v1.0.79/go.mod h1:AV3S/uXPnjvfaB4X6CV7xojAHjOmATY6Y9emWdnkJuQ= +github.com/flanksource/is-healthy v1.0.86 h1:N4oxqdW8/YN7+EmEHk4rStpYXzuGADiMAXP2T75bynw= +github.com/flanksource/is-healthy v1.0.86/go.mod h1:xoEeeCamUiW8fGWGyRaGL9NU4xQzou+sgDC5raguDew= github.com/flanksource/kubectl-neat v1.0.4 h1:t5/9CqgE84oEtB0KitgJ2+WIeLfD+RhXSxYrqb4X8yI= github.com/flanksource/kubectl-neat v1.0.4/go.mod h1:Un/Voyh3cmiZNKQrW/TkAl28nAA7vwnwDGVjRErKjOw= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a/go.mod h1:I79BieaU4fxrw4LMXby6q5OS9XnoR9UIKLOzDFjUmuw= github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= +github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= +github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/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/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= +github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= +github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew= github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= @@ -93,76 +185,141 @@ github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUW github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= +github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= +github.com/google/certificate-transparency-go v1.3.1/go.mod h1:gg+UQlx6caKEDQ9EElFOujyxEQEfOiQzAt6782Bvi8k= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= 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/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/gosimple/slug v1.13.1 h1:bQ+kpX9Qa6tHRaK+fZR0A0M2Kd7Pa5eHPPsb1JpHD+Q= github.com/gosimple/slug v1.13.1/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= +github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo= +github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0/go.mod h1:qOchhhIlmRcqk/O9uCo/puJlyo07YINaIqdZfZG3Jkc= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= +github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/hairyhenderson/toml v0.4.2-0.20210923231440-40456b8e66cf h1:I1sbT4ZbIt9i+hB1zfKw2mE8C12TuGxPiW7YmtLbPa4= github.com/hairyhenderson/toml v0.4.2-0.20210923231440-40456b8e66cf/go.mod h1:jDHmWDKZY6MIIYltYYfW4Rs7hQ50oS4qf/6spSiZAxY= github.com/hairyhenderson/yaml v0.0.0-20220618171115-2d35fca545ce h1:cVkYhlWAxwuS2/Yp6qPtcl0fGpcWxuZNonywHZ6/I+s= github.com/hairyhenderson/yaml v0.0.0-20220618171115-2d35fca545ce/go.mod h1:7TyiGlHI+IO+iJbqRZ82QbFtvgj/AIcFm5qc9DLn7Kc= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hmac-drbg v0.0.0-20210916214228-a6e5a68489f6/go.mod h1:y+HSOcOGB48PkUxNyLAiCiY6rEENu+E+Ss4LG8QHwf4= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/cryptoutil v0.1.1/go.mod h1:hH8rgXHh9fPSDPerG6WzABHsHF+9ZpLhRI1LPk4JZ8c= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= +github.com/hashicorp/vault/sdk v0.20.0/go.mod h1:xEjAt/n/2lHBAkYiRPRmvf1d5B6HlisPh2pELlRCosk= github.com/henvic/httpretty v0.1.4 h1:Jo7uwIRWVFxkqOnErcoYfH90o3ddQyVrSANeS4cxYmU= github.com/henvic/httpretty v0.1.4/go.mod h1:Dn60sQTZfbt2dYsdUSNsCljyF4AfdqnuJFDLJA1I4AM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/go-yaml v0.0.0-20251001235044-fca9a0999f15/go.mod h1:Tmbz8uw5I/I6NvVpEGuhzlElCGS5hPoXJkt7l+ul6LE= github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg= github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY= +github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc= +github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg= github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= +github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA= +github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= github.com/jeremywohl/flatten v0.0.0-20180923035001-588fe0d4c603 h1:gSech9iGLFCosfl/DC7BWnpSSh/tQClWnKS2I2vdPww= github.com/jeremywohl/flatten v0.0.0-20180923035001-588fe0d4c603/go.mod h1:4AmD/VxjWcI5SRB0n6szE2A6s2fsNHDLO0nAlMHgfLQ= +github.com/jeremywohl/flatten v1.0.1 h1:LrsxmB3hfwJuE+ptGOijix1PIfOoKLJ3Uee/mzbgtrs= +github.com/jeremywohl/flatten v1.0.1/go.mod h1:4AmD/VxjWcI5SRB0n6szE2A6s2fsNHDLO0nAlMHgfLQ= github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW34dhU4az1GN0pTPADwNmvoRSeoZ6PItiqnY= github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w= github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I= +github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/lrita/cmap v0.0.0-20231108122212-cb084a67f554 h1:a0+bIffIh/HdvvgtPQLRhOef1VDSxZ+8bQiyjQlJzqc= github.com/lrita/cmap v0.0.0-20231108122212-cb084a67f554/go.mod h1:Cn9TaoncDT8Tt/aJ7CIZy+t48MaZWDEwhu1bBXwrzLI= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= 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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-sqlite3 v1.14.30/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -173,30 +330,53 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/nrdcg/goacmedns v0.2.0/go.mod h1:T5o6+xvSLrQpugmwHvrSNkzWht0UGAwj2ACBMhh73Cg= github.com/ohler55/ojg v1.25.0 h1:sDwc4u4zex65Uz5Nm7O1QwDKTT+YRcpeZQTy1pffRkw= github.com/ohler55/ojg v1.25.0/go.mod h1:gQhDVpQLqrmnd2eqGAvJtn+NfKoYJbe/A4Sj3/Vro4o= +github.com/ohler55/ojg v1.28.0 h1:8xClBgMIRRJGDUC9xNe7NprP4kD2C3mQMeon3wY4KXA= +github.com/ohler55/ojg v1.28.0/go.mod h1:/Y5dGWkekv9ocnUixuETqiL58f+5pAsUfg5P8e7Pa2o= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= +github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= +github.com/olekukonko/ll v0.1.7 h1:WyK1YZwOTUKHEXZz3VydBDT5t3zDqa9yI8iJg5PHon4= +github.com/olekukonko/ll v0.1.7/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY= github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= +github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA= +github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM= +github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0/go.mod h1:F/7q8/HZz+TXjlsoZQQKVYvXTZaFH4QRa3y+j1p7MS0= github.com/onsi/ginkgo/v2 v2.26.0 h1:1J4Wut1IlYZNEAWIV3ALrT9NfiaGW2cDCJQSFQMs/gE= github.com/onsi/ginkgo/v2 v2.26.0/go.mod h1:qhEywmzWTBUY88kfO0BRvX4py7scov9yR+Az2oavUzw= +github.com/onsi/ginkgo/v2 v2.28.0/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/orisano/pixelmatch v0.0.0-20230914042517-fa304d1dc785/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0/go.mod h1:lAVhWwbNaveeJmxrxuSTxMgKpF6DjnuVpn6T8WiBwYQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/playwright-community/playwright-go v0.4702.0 h1:3CwNpk4RoA42tyhmlgPDMxYEYtMydaeEqMYiW0RNlSY= github.com/playwright-community/playwright-go v0.4702.0/go.mod h1:bpArn5TqNzmP0jroCgw4poSOG9gSeQg490iLqWAaa7w= +github.com/playwright-community/playwright-go v0.5700.1 h1:PNFb1byWqrTT720rEO0JL88C6Ju0EmUnR5deFLvtP/U= +github.com/playwright-community/playwright-go v0.5700.1/go.mod h1:MlSn1dZrx8rszbCxY6x3qK89ZesJUYVx21B2JnkoNF0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -208,44 +388,69 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= +github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= +github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00= github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= +github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/robertkrimen/otto v0.3.0 h1:5RI+8860NSxvXywDY9ddF5HcPw0puRsd8EgbXV0oqRE= github.com/robertkrimen/otto v0.3.0/go.mod h1:uW9yN1CYflmUQYvAMS0m+ZiNo3dMzRUDQJX0jWbzgxw= +github.com/robertkrimen/otto v0.5.1 h1:avDI4ToRk8k1hppLdYFTuuzND41n37vPGJU7547dGf0= +github.com/robertkrimen/otto v0.5.1/go.mod h1:bS433I4Q9p+E5pZLu7r17vP6FkE6/wLxBdmKjoqJXF8= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= +github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/samber/oops v1.19.3 h1:GAfSUOCh/JbnKAj5Ia+r/3KNnBdK+VdVDq1F5I8nDfM= github.com/samber/oops v1.19.3/go.mod h1:1lIO/SwpPltzw5cDO8/oiyVuLiQt3/8iid21Vb8QP+8= +github.com/samber/oops v1.21.0 h1:18atcO4oEigNFuGXqr3NZWZ6P0XOSEXyBSAMXdQRxTc= +github.com/samber/oops v1.21.0/go.mod h1:Hsm/sKPxtCfPh0w/cE3xVoRfSiE1joDRiStPAsmG9bo= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= +github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/spf13/cobra v1.9.2-0.20250831231508-51d675196729 h1:w1V8WrGuuPFNsCtyTNLki5XUTOuiMpq5SfvI9eAq9QY= github.com/spf13/cobra v1.9.2-0.20250831231508-51d675196729/go.mod h1:9dhySC7dnTtEiqzmqfkLj47BslqLCUPMXjG2lj/NgoE= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.8/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -259,6 +464,8 @@ github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= @@ -266,6 +473,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tiendc/go-deepcopy v1.6.0 h1:0UtfV/imoCwlLxVsyfUd4hNHnB3drXsfle+wzSCA5Wo= github.com/tiendc/go-deepcopy v1.6.0/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I= +github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= +github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= github.com/timberio/go-datemath v0.1.0 h1:1OUCvSIX1qXLJ57h12OWfgt6MNpJnsdNvrp8dLIUFtg= github.com/timberio/go-datemath v0.1.0/go.mod h1:m7kjsbCuO4QKP3KLfnxiUZWiOiFXmxj30HeexjL3lc0= github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160 h1:NSWpaDaurcAJY7PkL8Xt0PhZE7qpvbZl5ljd8r6U0bI= @@ -276,8 +485,12 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/vadimi/go-http-ntlm v1.0.3 h1:o6n2vAtP1MlLT73jIXuQYryIcWzXyMN0SCQWZ2QVLLc= github.com/vadimi/go-http-ntlm v1.0.3/go.mod h1:SwhhmybQ4Yn1mC53UPmQ6MCrBX6UvJHlS1Xt89OmM9M= github.com/vadimi/go-http-ntlm/v2 v2.5.0 h1:sddEWZumD7GoeNkfFZyZq01pq6CB4U6L73EBw3X7vTU= @@ -288,16 +501,24 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/vektah/gqlparser/v2 v2.5.30/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw= github.com/xuri/excelize/v2 v2.9.1/go.mod h1:x7L6pKz2dvo9ejrRuD8Lnl98z4JLt0TGAwjhW+EiP8s= +github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0= +github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q= github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -305,26 +526,49 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= +go.etcd.io/etcd/api/v3 v3.6.5/go.mod h1:ob0/oWA/UQQlT1BmaEkWQzI0sJ1M0Et0mMpaABxguOQ= +go.etcd.io/etcd/client/pkg/v3 v3.6.5/go.mod h1:8Wx3eGRPiy0qOFMZT/hfvdos+DjEaPxdIDiCDUv/FQk= +go.etcd.io/etcd/client/v3 v3.6.5/go.mod h1:ZqwG/7TAFZ0BJ0jXRPoJjKQJtbFo/9NIY8uoFFKcCyo= +go.etcd.io/etcd/pkg/v3 v3.6.5/go.mod h1:uqrXrzmMIJDEy5j00bCqhVLzR5jEJIwDp5wTlLwPGOU= +go.etcd.io/etcd/server/v3 v3.6.5/go.mod h1:PLuhyVXz8WWRhzXDsl3A3zv/+aK9e4A9lpQkqawIaH0= +go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo= 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/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 h1:T0Ec2E+3YZf5bgTNQVet8iTDW7oIk03tXHq+wkwIDnE= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0/go.mod h1:30v2gqH+vYGJsesLWFov8u47EpYTcIQcBjKpI6pJThg= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -334,8 +578,12 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b h1:18qgiDvlvH7kk8Ioa8Ov+K6xCi0GMvmGfGW0sgd/SYA= golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -344,6 +592,7 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -354,8 +603,11 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +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.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -363,6 +615,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -375,9 +629,12 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -385,6 +642,8 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -393,8 +652,12 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI= golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -404,21 +667,37 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/api v0.251.0/go.mod h1:Rwy0lPf/TD7+T2VhYcffCHhyyInyuxGjICxdfLqT7KI= google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/readline.v1 v1.0.0-20160726135117-62c6fe619375/go.mod h1:lNEQeAhU009zbRxng+XOj5ITVgY24WcbNnQopyfKoYQ= gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI= gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -433,25 +712,53 @@ gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= +k8s.io/api v0.35.2 h1:tW7mWc2RpxW7HS4CoRXhtYHSzme1PN1UjGHJ1bdrtdw= +k8s.io/api v0.35.2/go.mod h1:7AJfqGoAZcwSFhOjcGM7WV05QxMMgUaChNfLTXDRE60= k8s.io/apiextensions-apiserver v0.31.1 h1:L+hwULvXx+nvTYX/MKM3kKMZyei+UiSXQWciX/N6E40= k8s.io/apiextensions-apiserver v0.31.1/go.mod h1:tWMPR3sgW+jsl2xm9v7lAyRF1rYEK71i9G5dRtkknoQ= +k8s.io/apiextensions-apiserver v0.35.2 h1:iyStXHoJZsUXPh/nFAsjC29rjJWdSgUmG1XpApE29c0= +k8s.io/apiextensions-apiserver v0.35.2/go.mod h1:OdyGvcO1FtMDWQ+rRh/Ei3b6X3g2+ZDHd0MSRGeS8rU= k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8= +k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apiserver v0.35.2/go.mod h1:CROJUAu0tfjZLyYgSeBsBan2T7LUJGh0ucWwTCSSk7g= k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= +k8s.io/client-go v0.35.2 h1:YUfPefdGJA4aljDdayAXkc98DnPkIetMl4PrKX97W9o= +k8s.io/client-go v0.35.2/go.mod h1:4QqEwh4oQpeK8AaefZ0jwTFJw/9kIjdQi0jpKeYvz7g= +k8s.io/code-generator v0.35.2/go.mod h1:id4XLCm0yAQq5nlvyfAKibMOKnMjzlesAwGw6kM3Adc= +k8s.io/component-base v0.35.2/go.mod h1:B1iBJjooe6xIJYUucAxb26RwhAjzx0gHnqO9htWIX+0= +k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b/go.mod h1:CgujABENc3KuTrcsdpGmrrASjtQsWCT7R99mEV4U/fM= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kms v0.35.2/go.mod h1:VT+4ekZAdrZDMgShK37vvlyHUVhwI9t/9tvh0AyCWmQ= +k8s.io/kube-aggregator v0.34.1/go.mod h1:RU8j+5ERfp0h+gIvWtxRPfsa5nK7rboDm8RST8BJfYQ= +k8s.io/kube-openapi v0.0.0-20260304202019-5b3e3fdb0acf h1:btPscg4cMql0XdYK2jLsJcNEKmACJz8l+U7geC06FiM= +k8s.io/kube-openapi v0.0.0-20260304202019-5b3e3fdb0acf/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= layeh.com/gopher-json v0.0.0-20201124131017-552bb3c4c3bf h1:rRz0YsF7VXj9fXRF6yQgFI7DzST+hsI3TeFSGupntu0= layeh.com/gopher-json v0.0.0-20201124131017-552bb3c4c3bf/go.mod h1:ivKkcY8Zxw5ba0jldhZCYYQfGdb2K6u9tbYK1AwMIBc= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +sigs.k8s.io/controller-runtime v0.22.3/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= sigs.k8s.io/gateway-api v1.1.0 h1:DsLDXCi6jR+Xz8/xd0Z1PYl2Pn0TyaFMOPPZIj4inDM= sigs.k8s.io/gateway-api v1.1.0/go.mod h1:ZH4lHrL2sDi0FHZ9jjneb8kKnGzFWyrTya35sWUTrRs= +sigs.k8s.io/gateway-api v1.5.0 h1:duoo14Ky/fJXpjpmyMISE2RTBGnfCg8zICfTYLTnBJA= +sigs.k8s.io/gateway-api v1.5.0/go.mod h1:GvCETiaMAlLym5CovLxGjS0NysqFk3+Yuq3/rh6QL2o= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= +software.sslmate.com/src/go-pkcs12 v0.6.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/go.mod b/go.mod index 6db8031d..8da49246 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,8 @@ require ( github.com/alecthomas/chroma/v2 v2.23.1 github.com/charmbracelet/lipgloss v1.1.0 github.com/creack/pty v1.1.24 - github.com/flanksource/commons v1.47.2 - github.com/flanksource/gomplate/v3 v3.24.74 + github.com/flanksource/commons v1.50.3 + github.com/flanksource/gomplate/v3 v3.24.77 github.com/go-xmlfmt/xmlfmt v1.1.3 github.com/goccy/go-yaml v1.19.2 github.com/golang-jwt/jwt/v5 v5.3.0 diff --git a/go.sum b/go.sum index bc4d55ff..2419f366 100644 --- a/go.sum +++ b/go.sum @@ -97,8 +97,12 @@ 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/flanksource/commons v1.47.2 h1:3CE3MHBWM/1rehHDduUR8r55rP8HorbYuiB2LO8Rjmw= github.com/flanksource/commons v1.47.2/go.mod h1:ouTo/VFVZ/7LKEDWgHLQKdCJsTreDrUOmV0Z3CxZosY= +github.com/flanksource/commons v1.50.3 h1:uWsI9dCxkCrpnCplFlQ+wy3jbOedOvsGcqoogrgmrsQ= +github.com/flanksource/commons v1.50.3/go.mod h1:IAZ94jQnUMMRShmbkg4+sPHEdyQfVc7a1tl2BnL1VF8= github.com/flanksource/gomplate/v3 v3.24.74 h1:LR1TGUxbQiZyjPgxP7dElVZi6LzMwBVqyZ3z0FgkMPU= github.com/flanksource/gomplate/v3 v3.24.74/go.mod h1:l3iet81uj0sje3uMIkAURjOu1E4UErJ3PMVTYhvABCM= +github.com/flanksource/gomplate/v3 v3.24.77 h1:StGjXRxeADULhxA9rXbPzAPk+C/952gNeU+LUO7qRXk= +github.com/flanksource/gomplate/v3 v3.24.77/go.mod h1:IioRhY9IdwdlPI/xdZOiLHGsFJqW4Sp6yXmrqCFuh+k= github.com/flanksource/is-healthy v1.0.86 h1:N4oxqdW8/YN7+EmEHk4rStpYXzuGADiMAXP2T75bynw= github.com/flanksource/is-healthy v1.0.86/go.mod h1:xoEeeCamUiW8fGWGyRaGL9NU4xQzou+sgDC5raguDew= github.com/flanksource/kubectl-neat v1.0.4 h1:t5/9CqgE84oEtB0KitgJ2+WIeLfD+RhXSxYrqb4X8yI= diff --git a/rpc/converter.go b/rpc/converter.go index 729d7da9..8e16d21c 100644 --- a/rpc/converter.go +++ b/rpc/converter.go @@ -165,6 +165,9 @@ func (c *Converter) ConvertCommand(cmd *cobra.Command) (*RPCOperation, error) { if df := clicky.GetDataFunc(cmd); df != nil { operation.DataFunc = df } + if lf := clicky.GetLookupFunc(cmd); lf != nil { + operation.LookupFunc = lf + } return operation, nil } diff --git a/rpc/executor.go b/rpc/executor.go index d50e37ab..82a58732 100644 --- a/rpc/executor.go +++ b/rpc/executor.go @@ -75,12 +75,15 @@ func NewCommandExecutor(service *RPCService, config *ExecutorConfig) *CommandExe // Supports templated paths like /api/v1/policy/{id} matched against // concrete paths like /api/v1/policy/12345. func (e *CommandExecutor) FindOperation(method, path string) *RPCOperation { + return e.findOperationForMethod(method, path) +} + +func (e *CommandExecutor) findOperationForMethod(method, path string) *RPCOperation { key := strings.ToUpper(method) + ":" + path if op := e.operations[key]; op != nil { return op } - // Try template matching for paths with {param} segments for _, op := range e.operations { if !strings.EqualFold(op.Method, method) { continue @@ -89,6 +92,49 @@ func (e *CommandExecutor) FindOperation(method, path string) *RPCOperation { return op } } + + return nil +} + +func (e *CommandExecutor) FindLookupOperation(method, path string) *RPCOperation { + if strings.EqualFold(method, http.MethodHead) { + if op := e.findLookupOperationForMethod(http.MethodGet, path); op != nil { + return op + } + } + + for _, candidateMethod := range []string{ + http.MethodGet, + http.MethodPost, + http.MethodPut, + http.MethodDelete, + http.MethodPatch, + } { + if op := e.findLookupOperationForMethod(candidateMethod, path); op != nil { + return op + } + } + + return nil +} + +func (e *CommandExecutor) findLookupOperationForMethod(method, path string) *RPCOperation { + preferredMethods := []string{ + method, + } + + for _, preferredMethod := range preferredMethods { + for i := range e.service.Operations { + op := &e.service.Operations[i] + if op.LookupFunc == nil || !strings.EqualFold(op.Method, preferredMethod) { + continue + } + if matchTemplatePath(op.Path, path) { + return op + } + } + } + return nil } diff --git a/rpc/filter_lookup_test.go b/rpc/filter_lookup_test.go new file mode 100644 index 00000000..386633a8 --- /dev/null +++ b/rpc/filter_lookup_test.go @@ -0,0 +1,167 @@ +package rpc + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/flanksource/clicky" + "github.com/flanksource/clicky/api" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type rpcFilterEntity struct { + ID string + Name string +} + +func (e rpcFilterEntity) GetID() string { return e.ID } +func (e rpcFilterEntity) GetName() string { return e.Name } + +type rpcFilterOpts struct { + Owner string `flag:"owner"` + Status string `flag:"status"` +} + +type rpcOwnerFilter struct{} + +func (rpcOwnerFilter) Key() string { return "owner" } +func (rpcOwnerFilter) Label() string { return "Owner" } + +func (rpcOwnerFilter) Lookup(opts *rpcFilterOpts) (map[string]api.Textable, error) { + if opts.Owner == "" { + return nil, nil + } + opts.Owner = "team/" + opts.Owner + return map[string]api.Textable{ + opts.Owner: api.Text{Content: strings.TrimPrefix(opts.Owner, "team/")}, + }, nil +} + +func (rpcOwnerFilter) Options(opts rpcFilterOpts) map[string]api.Textable { + return map[string]api.Textable{ + "team/platform": api.Text{Content: "Platform"}, + } +} + +type rpcStatusFilter struct{} + +func (rpcStatusFilter) Key() string { return "status" } +func (rpcStatusFilter) Label() string { return "Status" } + +func (rpcStatusFilter) Lookup(opts *rpcFilterOpts) (map[string]api.Textable, error) { + if opts.Status == "" { + return nil, nil + } + + opts.Status = "status:" + opts.Status + return map[string]api.Textable{ + opts.Status: api.Text{Content: "Healthy", Style: "font-semibold"}, + }, nil +} + +func (rpcStatusFilter) Options(opts rpcFilterOpts) map[string]api.Textable { + if opts.Owner == "team/platform" { + return map[string]api.Textable{ + "status:healthy": api.Text{Content: "Healthy", Style: "font-semibold"}, + } + } + return map[string]api.Textable{ + "status:healthy": api.Text{Content: "Healthy", Style: "font-semibold"}, + "status:degraded": api.Text{Content: "Degraded"}, + } +} + +func TestSwaggerServer_FilterLookupRoutes(t *testing.T) { + root := &cobra.Command{Use: "testapp", Short: "test app"} + + bulkExecutions := 0 + clicky.RegisterEntity(clicky.Entity[rpcFilterEntity, rpcFilterOpts]{ + Name: "rpc-filter-entity", + Filters: []clicky.Filter[rpcFilterOpts]{rpcOwnerFilter{}, rpcStatusFilter{}}, + List: func(opts rpcFilterOpts) ([]rpcFilterEntity, error) { + return []rpcFilterEntity{{ID: "1", Name: opts.Status}}, nil + }, + BulkActions: []clicky.BulkAction[rpcFilterEntity, rpcFilterOpts]{ + { + Name: "bulk-suspend", + Short: "Suspend matching entities", + Run: func(ids []string, flags map[string]string) (any, error) { + bulkExecutions++ + return ids, nil + }, + RunFilter: func(opts rpcFilterOpts, flags map[string]string) (any, error) { + bulkExecutions++ + return map[string]string{"status": opts.Status}, nil + }, + }, + }, + }) + clicky.GenerateCLI(root) + + server := NewSwaggerServer(&ServeConfig{ + Host: "localhost", + Port: 8080, + Executor: &ExecutorConfig{ + Enabled: true, + SkipPreRun: true, + PathPrefix: "/api/v1", + }, + }, root, &OpenAPIConfig{}) + + mux := http.NewServeMux() + server.RegisterRoutes(mux) + + t.Run("GET list lookup returns clicky metadata", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/rpc-filter-entity?owner=platform&status=healthy&__lookup=filters", nil) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json+clicky", w.Header().Get("Content-Type")) + + var payload map[string]any + err := json.Unmarshal(w.Body.Bytes(), &payload) + require.NoError(t, err) + + assert.Contains(t, w.Body.String(), `"status:healthy"`) + assert.NotContains(t, w.Body.String(), `"status:degraded"`) + }) + + t.Run("HEAD returns metadata headers with no body", func(t *testing.T) { + req := httptest.NewRequest(http.MethodHead, "/api/v1/rpc-filter-entity?owner=platform&status=healthy", nil) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json+clicky", w.Header().Get("Content-Type")) + assert.Empty(t, w.Body.String()) + }) + + t.Run("GET companion lookup on bulk route does not execute action", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/rpc-filter-entity/123/bulk-suspend?owner=platform&status=healthy&__lookup=filters", nil) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, 0, bulkExecutions) + assert.Contains(t, w.Body.String(), `"status:healthy"`) + }) + + t.Run("GET companion route without lookup does not execute action", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/rpc-filter-entity/123/bulk-suspend?owner=platform&status=healthy", nil) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + assert.Equal(t, 0, bulkExecutions) + }) +} diff --git a/rpc/serve.go b/rpc/serve.go index f2eade98..d7e11789 100644 --- a/rpc/serve.go +++ b/rpc/serve.go @@ -407,33 +407,45 @@ func (s *SwaggerServer) registerExecutionRoutes(mux *http.ServeMux) { registered := make(map[string]string) routeCount := 0 - // Register a route for each operation - for _, op := range s.executor.service.Operations { - path := op.Path - method := strings.ToUpper(op.Method) - - // Replace spaces in path segments with hyphens - path = strings.ReplaceAll(path, " ", "-") - - // Sanitize path parameter names: Go's ServeMux requires alphanumeric wildcard names + registerRoute := func(method, path, opName string) bool { sanitized, ok := sanitizePathParams(path) if !ok { fmt.Printf("⚠️ Warning: Skipping route with invalid path params: %s %s\n", method, path) - continue + return false } - // Register the route with method prefix (Go 1.22+ ServeMux) pattern := method + " " + sanitized if existingOp, found := registered[pattern]; found { fmt.Printf("⚠️ Warning: Duplicate endpoint detected\n") fmt.Printf(" Path: %s\n", pattern) fmt.Printf(" Already registered by: %s\n", existingOp) - fmt.Printf(" Skipping: %s\n", op.Name) - continue + fmt.Printf(" Skipping: %s\n", opName) + return false } - registered[pattern] = op.Name + + registered[pattern] = opName mux.HandleFunc(pattern, s.handleExecuteCommand) routeCount++ + return true + } + + // Register a route for each operation + for _, op := range s.executor.service.Operations { + path := op.Path + method := strings.ToUpper(op.Method) + + // Replace spaces in path segments with hyphens + path = strings.ReplaceAll(path, " ", "-") + registerRoute(method, path, op.Name) + + if op.LookupFunc == nil { + continue + } + + registerRoute(http.MethodHead, path, op.Name+" lookup") + if !strings.EqualFold(method, http.MethodGet) { + registerRoute(http.MethodGet, path, op.Name+" lookup") + } } fmt.Printf("✅ Registered %d executor routes\n", routeCount) } @@ -539,7 +551,7 @@ func (s *SwaggerServer) executeCommandCore(r *http.Request) (any, *ExecutionResp func (s *SwaggerServer) handleExecuteCommand(w http.ResponseWriter, r *http.Request) { // Set CORS headers w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, HEAD, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept") if r.Method == "OPTIONS" { @@ -547,6 +559,25 @@ func (s *SwaggerServer) handleExecuteCommand(w http.ResponseWriter, r *http.Requ return } + lookupRequested := isLookupRequest(r) + op := s.executor.FindOperation(r.Method, r.URL.Path) + if lookupRequested { + if op == nil || op.LookupFunc == nil { + op = s.executor.FindLookupOperation(r.Method, r.URL.Path) + } + if op == nil || op.LookupFunc == nil { + http.Error(w, fmt.Sprintf("No lookup found for %s %s", r.Method, r.URL.Path), http.StatusNotFound) + return + } + s.handleLookupCommand(w, r, op) + return + } + + if op == nil { + http.Error(w, fmt.Sprintf("No operation found for %s %s", r.Method, r.URL.Path), http.StatusNotFound) + return + } + // Execute command and get data + metadata data, metadata, statusCode, _ := s.executeCommandCore(r) @@ -568,6 +599,38 @@ func (s *SwaggerServer) handleExecuteCommand(w http.ResponseWriter, r *http.Requ s.writeFormattedResponse(w, data, opts, statusCode) } +func isLookupRequest(r *http.Request) bool { + if strings.EqualFold(r.Method, http.MethodHead) { + return true + } + return r.URL.Query().Get("__lookup") == "filters" +} + +func (s *SwaggerServer) handleLookupCommand(w http.ResponseWriter, r *http.Request, op *RPCOperation) { + req, err := s.executor.ExtractRequestFromHTTP(r, op) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to extract parameters: %v", err), http.StatusBadRequest) + return + } + + data, err := op.LookupFunc(req.Flags, req.Args) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json+clicky") + w.WriteHeader(http.StatusOK) + + if strings.EqualFold(r.Method, http.MethodHead) { + return + } + + if err := json.NewEncoder(w).Encode(data); err != nil { + http.Error(w, fmt.Sprintf("failed to encode lookup response: %v", err), http.StatusInternalServerError) + } +} + // writeFormattedResponse formats data using the FormatManager and writes it as // the raw response body with the appropriate Content-Type. func (s *SwaggerServer) writeFormattedResponse(w http.ResponseWriter, data any, opts formatOptions, statusCode int) { diff --git a/rpc/types.go b/rpc/types.go index 466a1b03..ccc970bb 100644 --- a/rpc/types.go +++ b/rpc/types.go @@ -17,6 +17,7 @@ type RPCOperation struct { Method string `json:"method,omitempty"` // HTTP method Tags []string `json:"tags,omitempty"` // For grouping DataFunc DataFunc `json:"-"` // Direct data provider, bypasses stdout capture + LookupFunc DataFunc `json:"-"` // Direct filter metadata provider } // RPCParameter represents a parameter in an RPC operation diff --git a/sub_command_test.go b/sub_command_test.go index 21dcfd37..b8bd29d3 100644 --- a/sub_command_test.go +++ b/sub_command_test.go @@ -1,6 +1,7 @@ package clicky import ( + "sync" "testing" "github.com/spf13/cobra" @@ -23,6 +24,8 @@ func resetEntityRegistry(t *testing.T) { entityRegistryMu.Lock() entityRegistry = nil entityRegistryMu.Unlock() + dataFuncRegistry = sync.Map{} + lookupFuncRegistry = sync.Map{} pendingSubCommandsMu.Lock() pendingSubCommands = nil pendingSubCommandsMu.Unlock() From 530ddd8a9ea1562ef97f1fb836f7722187efe245 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 23 Apr 2026 15:22:12 +0300 Subject: [PATCH 2/5] feat(prompt): Add interactive prompt APIs with select, multi-select, and text input Introduce comprehensive prompt functionality for interactive terminal UIs: - Vendor gumchoose from charmbracelet/gum for single and multi-select prompts - Add PromptSelect, PromptMultiSelect, and PromptText APIs for interactive user input - Implement terminal detection, PTY handling, and screen management - Support customizable rendering, validation, and pagination - Include extensive test coverage with PTY-based integration tests - Handle terminal state restoration and cursor positioning This enables rich interactive CLI experiences with proper terminal handling across macOS and Linux. --- internal/gumchoose/LICENSE | 23 + internal/gumchoose/choose.go | 417 ++++++++++++++++++ internal/gumchoose/choose_test.go | 37 ++ prompt.go | 606 +++++++++++++++++++++++++++ prompt_terminal_ioctl_darwin_test.go | 19 + prompt_terminal_ioctl_linux_test.go | 19 + prompt_test.go | 449 ++++++++++++++++++++ 7 files changed, 1570 insertions(+) create mode 100644 internal/gumchoose/LICENSE create mode 100644 internal/gumchoose/choose.go create mode 100644 internal/gumchoose/choose_test.go create mode 100644 prompt.go create mode 100644 prompt_terminal_ioctl_darwin_test.go create mode 100644 prompt_terminal_ioctl_linux_test.go create mode 100644 prompt_test.go diff --git a/internal/gumchoose/LICENSE b/internal/gumchoose/LICENSE new file mode 100644 index 00000000..49691c48 --- /dev/null +++ b/internal/gumchoose/LICENSE @@ -0,0 +1,23 @@ +Vendored from https://github.com/charmbracelet/gum/tree/main/choose + +MIT License + +Copyright (c) Charmbracelet, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/internal/gumchoose/choose.go b/internal/gumchoose/choose.go new file mode 100644 index 00000000..14770675 --- /dev/null +++ b/internal/gumchoose/choose.go @@ -0,0 +1,417 @@ +// Package gumchoose vendors the choose implementation from charmbracelet/gum. +// It is adapted for local use in clicky's prompt APIs. +package gumchoose + +import ( + "io" + "slices" + "strings" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/paginator" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +type keymap struct { + Down, Up, Right, Left, Home, End, ToggleAll, Toggle, Abort, Quit, Submit key.Binding +} + +func defaultKeymap() keymap { + return keymap{ + Down: key.NewBinding( + key.WithKeys("down", "j", "ctrl+j", "ctrl+n"), + ), + Up: key.NewBinding( + key.WithKeys("up", "k", "ctrl+k", "ctrl+p"), + ), + Right: key.NewBinding( + key.WithKeys("right", "l", "ctrl+f"), + ), + Left: key.NewBinding( + key.WithKeys("left", "h", "ctrl+b"), + ), + Home: key.NewBinding( + key.WithKeys("g", "home"), + ), + End: key.NewBinding( + key.WithKeys("G", "end"), + ), + ToggleAll: key.NewBinding( + key.WithKeys("a", "A", "ctrl+a"), + key.WithHelp("ctrl+a", "select all"), + key.WithDisabled(), + ), + Toggle: key.NewBinding( + key.WithKeys(" ", "tab", "x", "ctrl+@"), + key.WithHelp("x", "toggle"), + key.WithDisabled(), + ), + Abort: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "abort"), + ), + Quit: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "quit"), + ), + Submit: key.NewBinding( + key.WithKeys("enter", "ctrl+q"), + key.WithHelp("enter", "submit"), + ), + } +} + +func (k keymap) FullHelp() [][]key.Binding { + return nil +} + +func (k keymap) ShortHelp() []key.Binding { + return []key.Binding{ + k.Toggle, + key.NewBinding( + key.WithKeys("up", "down", "right", "left"), + key.WithHelp("←↓↑→", "navigate"), + ), + k.Submit, + k.ToggleAll, + } +} + +// Options configures the vendored chooser. +type Options struct { + Header string + Height int + Cursor string + ShowHelp bool + Limit int + Ordered bool + InitialIndex int + SelectedPrefix string + UnselectedPrefix string +} + +// Result is the outcome of a chooser run. +type Result struct { + Submitted bool + Indexes []int +} + +type item struct { + text string + selected bool + order int +} + +type model struct { + height int + cursor string + + selectedPrefix string + unselectedPrefix string + header string + + items []item + quitting bool + submitted bool + index int + limit int + ordered bool + + numSelected int + currentOrder int + + paginator paginator.Model + showHelp bool + help help.Model + keymap keymap + + cursorStyle lipgloss.Style + headerStyle lipgloss.Style + itemStyle lipgloss.Style + selectedItemStyle lipgloss.Style + paginatorStyle lipgloss.Style +} + +// Run executes the vendored chooser. +func Run(input io.Reader, output io.Writer, items []string, opts Options) (Result, error) { + m := newModel(items, opts) + + program := tea.NewProgram( + m, + tea.WithInput(input), + tea.WithOutput(output), + ) + + finalModel, err := program.Run() + if err != nil { + return Result{}, err + } + + finished := finalModel.(model) + return Result{ + Submitted: finished.submitted, + Indexes: finished.selectedIndexes(), + }, nil +} + +func newModel(options []string, opts Options) model { + height := opts.Height + if height <= 0 { + height = 10 + } + + limit := opts.Limit + if limit <= 0 { + limit = 1 + } + + km := defaultKeymap() + if limit > 1 { + km.Toggle.SetEnabled(true) + km.ToggleAll.SetEnabled(true) + } + + p := paginator.New() + p.Type = paginator.Dots + p.PerPage = height + p.SetTotalPages(len(options)) + + items := make([]item, 0, len(options)) + for _, option := range options { + items = append(items, item{text: option}) + } + + index := clamp(opts.InitialIndex, 0, max(len(items)-1, 0)) + p.Page = index / height + + theme := huh.ThemeCharm() + selectedPrefix := defaultString(opts.SelectedPrefix, "◉ ") + unselectedPrefix := defaultString(opts.UnselectedPrefix, "○ ") + if limit <= 1 { + selectedPrefix = "" + unselectedPrefix = "" + } + + return model{ + height: height, + cursor: defaultString(opts.Cursor, "> "), + selectedPrefix: selectedPrefix, + unselectedPrefix: unselectedPrefix, + header: opts.Header, + items: items, + index: index, + limit: limit, + ordered: opts.Ordered, + paginator: p, + showHelp: opts.ShowHelp, + help: themedHelp(theme), + keymap: km, + cursorStyle: theme.Focused.SelectSelector.UnsetString(), + headerStyle: theme.Group.Title, + itemStyle: theme.Focused.UnselectedOption, + selectedItemStyle: theme.Focused.SelectedOption, + paginatorStyle: theme.Group.Description, + } +} + +func themedHelp(theme *huh.Theme) help.Model { + model := help.New() + model.Styles = theme.Help + return model +} + +func defaultString(value, fallback string) string { + if value == "" { + return fallback + } + return value +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + return m, nil + case tea.KeyMsg: + start, end := m.paginator.GetSliceBounds(len(m.items)) + km := m.keymap + + switch { + case key.Matches(msg, km.Down): + m.index++ + if m.index >= len(m.items) { + m.index = 0 + m.paginator.Page = 0 + } + if m.index >= end { + m.paginator.NextPage() + } + case key.Matches(msg, km.Up): + m.index-- + if m.index < 0 { + m.index = len(m.items) - 1 + m.paginator.Page = m.paginator.TotalPages - 1 + } + if m.index < start { + m.paginator.PrevPage() + } + case key.Matches(msg, km.Right): + m.index = clamp(m.index+m.height, 0, len(m.items)-1) + m.paginator.NextPage() + case key.Matches(msg, km.Left): + m.index = clamp(m.index-m.height, 0, len(m.items)-1) + m.paginator.PrevPage() + case key.Matches(msg, km.End): + m.index = len(m.items) - 1 + m.paginator.Page = m.paginator.TotalPages - 1 + case key.Matches(msg, km.Home): + m.index = 0 + m.paginator.Page = 0 + case key.Matches(msg, km.ToggleAll): + if m.limit <= 1 { + break + } + if m.numSelected < len(m.items) && m.numSelected < m.limit { + m = m.selectAll() + } else { + m = m.deselectAll() + } + case key.Matches(msg, km.Quit): + m.quitting = true + return m, tea.Quit + case key.Matches(msg, km.Abort): + m.quitting = true + return m, tea.Interrupt + case key.Matches(msg, km.Toggle): + if m.limit == 1 { + break + } + if m.items[m.index].selected { + m.items[m.index].selected = false + m.numSelected-- + } else if m.numSelected < m.limit { + m.items[m.index].selected = true + m.items[m.index].order = m.currentOrder + m.numSelected++ + m.currentOrder++ + } + case key.Matches(msg, km.Submit): + m.quitting = true + if m.limit <= 1 && m.numSelected < 1 { + m.items[m.index].selected = true + } + m.submitted = true + return m, tea.Quit + } + } + + var cmd tea.Cmd + m.paginator, cmd = m.paginator.Update(msg) + return m, cmd +} + +func (m model) selectAll() model { + for i := range m.items { + if m.numSelected >= m.limit { + break + } + if m.items[i].selected { + continue + } + m.items[i].selected = true + m.items[i].order = m.currentOrder + m.numSelected++ + m.currentOrder++ + } + return m +} + +func (m model) deselectAll() model { + for i := range m.items { + m.items[i].selected = false + m.items[i].order = 0 + } + m.numSelected = 0 + m.currentOrder = 0 + return m +} + +func (m model) selectedIndexes() []int { + indexes := make([]int, 0, len(m.items)) + for index, item := range m.items { + if item.selected { + indexes = append(indexes, index) + } + } + + if m.ordered { + slices.SortStableFunc(indexes, func(a, b int) int { + return m.items[a].order - m.items[b].order + }) + } + + return indexes +} + +func (m model) View() string { + if m.quitting { + return "" + } + + var s strings.Builder + start, end := m.paginator.GetSliceBounds(len(m.items)) + + for i, item := range m.items[start:end] { + isCurrent := i == m.index%m.height + if isCurrent { + s.WriteString(m.cursorStyle.Render(m.cursor)) + } else { + s.WriteString(strings.Repeat(" ", lipgloss.Width(m.cursor))) + } + + switch { + case item.selected: + s.WriteString(m.selectedItemStyle.Render(m.selectedPrefix + item.text)) + case isCurrent: + s.WriteString(m.selectedItemStyle.Render(m.unselectedPrefix + item.text)) + default: + s.WriteString(m.itemStyle.Render(m.unselectedPrefix + item.text)) + } + + if i != m.height { + s.WriteRune('\n') + } + } + + if m.paginator.TotalPages > 1 { + s.WriteString(strings.Repeat("\n", m.height-m.paginator.ItemsOnPage(len(m.items))+1)) + s.WriteString(m.paginatorStyle.Render(" " + m.paginator.View())) + } + + var parts []string + if m.header != "" { + parts = append(parts, m.headerStyle.Render(m.header)) + } + parts = append(parts, s.String()) + if m.showHelp { + parts = append(parts, "", m.help.View(m.keymap)) + } + + return lipgloss.JoinVertical(lipgloss.Left, parts...) +} + +func clamp(x, low, high int) int { + if x < low { + return low + } + if x > high { + return high + } + return x +} diff --git a/internal/gumchoose/choose_test.go b/internal/gumchoose/choose_test.go new file mode 100644 index 00000000..75423671 --- /dev/null +++ b/internal/gumchoose/choose_test.go @@ -0,0 +1,37 @@ +package gumchoose + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +func TestSingleSelectCurrentRowDoesNotDuplicateCursorOrPrefix(t *testing.T) { + model := newModel([]string{"Scale canary up", "Abort release"}, Options{ + Header: "Choose action", + Height: 5, + Limit: 1, + }) + + view := ansi.Strip(model.View()) + if strings.Contains(view, "> > ") { + t.Fatalf("expected no duplicated cursor, got %q", view) + } + if !strings.Contains(view, "> Scale canary up") { + t.Fatalf("expected single-select row to render without extra prefix, got %q", view) + } +} + +func TestMultiSelectUsesCirclePrefixes(t *testing.T) { + model := newModel([]string{"Check pods", "Inspect logs"}, Options{ + Header: "Choose checks", + Height: 5, + Limit: 2, + }) + + view := ansi.Strip(model.View()) + if !strings.Contains(view, "> ○ Check pods") { + t.Fatalf("expected current multi-select row to include unselected circle, got %q", view) + } +} diff --git a/prompt.go b/prompt.go new file mode 100644 index 00000000..2b298dac --- /dev/null +++ b/prompt.go @@ -0,0 +1,606 @@ +package clicky + +import ( + "errors" + "fmt" + "io" + "os" + "reflect" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/flanksource/clicky/api" + "github.com/flanksource/clicky/internal/gumchoose" + "github.com/flanksource/clicky/task" + "golang.org/x/sys/unix" + "golang.org/x/term" +) + +const ( + promptMinSelectPageSize = 5 + promptClearScreenSequence = "\x1b[2J\x1b[H" + promptCursorQuerySequence = "\x1b[6n" + promptCursorQueryTimeout = 150 * time.Millisecond +) + +// PromptSelectOptions configures an ANSI list prompt. +type PromptSelectOptions[T any] struct { + Title string + InitialIndex int + PageSize int + Render func(T) api.Textable +} + +// PromptMultiSelectOptions configures an ANSI multi-select prompt. +type PromptMultiSelectOptions[T any] struct { + Title string + PageSize int + Limit int + Ordered bool + Render func(T) api.Textable +} + +// PromptTextOptions configures an ANSI text prompt. +type PromptTextOptions struct { + Title string + Default string + Placeholder string + Secret bool + Validate func(string) error +} + +type promptPretty interface { + Pretty() api.Text +} + +type promptTerminal struct { + input *os.File + output *os.File + close func() error +} + +func openPromptTerminal() (*promptTerminal, error) { + if term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stderr.Fd())) { + return &promptTerminal{ + input: os.Stdin, + output: os.Stderr, + }, nil + } + + if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil { + return &promptTerminal{ + input: tty, + output: tty, + close: tty.Close, + }, nil + } + + return nil, fmt.Errorf("interactive terminal required") +} + +func (p *promptTerminal) Close() error { + if p == nil || p.close == nil { + return nil + } + return p.close() +} + +func (p *promptTerminal) lineCount() int { + if p != nil { + if lines := promptTerminalLines(p.output); lines > 0 { + return lines + } + if p.input != p.output { + if lines := promptTerminalLines(p.input); lines > 0 { + return lines + } + } + } + + return api.GetTerminalLines() +} + +func (p *promptTerminal) columnCount() int { + if p != nil { + if columns := promptTerminalColumns(p.output); columns > 0 { + return columns + } + if p.input != p.output { + if columns := promptTerminalColumns(p.input); columns > 0 { + return columns + } + } + } + + return api.GetTerminalWidth() +} + +func (p *promptTerminal) clearScreen() { + if p == nil || p.output == nil { + return + } + _, _ = io.WriteString(p.output, promptClearScreenSequence) +} + +func (p *promptTerminal) cursorRow() (int, bool) { + if p == nil || p.input == nil || p.output == nil { + return 0, false + } + + fd := int(p.input.Fd()) + oldState, err := term.MakeRaw(fd) + if err != nil { + return 0, false + } + defer func() { + _ = term.Restore(fd, oldState) + }() + + if _, err := io.WriteString(p.output, promptCursorQuerySequence); err != nil { + return 0, false + } + + response, ok := readPromptCursorResponse(fd, promptCursorQueryTimeout) + if !ok { + return 0, false + } + + row, _, ok := parsePromptCursorResponse(response) + return row, ok +} + +type promptSession struct { + terminal *promptTerminal + releaseTerminal func() + tookOverRender bool +} + +func newPromptSession() (*promptSession, error) { + terminal, err := openPromptTerminal() + if err != nil { + return nil, err + } + + releaseTerminal, tookOverRender := task.AcquirePromptTerminal() + + return &promptSession{ + terminal: terminal, + releaseTerminal: releaseTerminal, + tookOverRender: tookOverRender, + }, nil +} + +func (s *promptSession) Close() { + if s == nil { + return + } + + if s.releaseTerminal != nil { + s.releaseTerminal() + s.releaseTerminal = nil + } + if s.terminal != nil { + _ = s.terminal.Close() + s.terminal = nil + } +} + +func (s *promptSession) prepareSelectPrompt(promptHeight int) { + if s == nil || s.terminal == nil || promptHeight <= 0 { + return + } + + if s.tookOverRender { + s.terminal.clearScreen() + return + } + + if row, ok := s.terminal.cursorRow(); ok && shouldClearPromptScreen(promptHeight, s.terminal.lineCount(), row) { + s.terminal.clearScreen() + } +} + +// PromptChoose renders a minimal arrow-key choice prompt. +func PromptChoose[T any](items []T) (T, bool) { + return Prompt(items, PromptSelectOptions[T]{}) +} + +// PromptSelect renders a configurable arrow-key choice prompt. +func PromptSelect[T any](items []T, opts PromptSelectOptions[T]) (T, bool) { + return Prompt(items, opts) +} + +// PromptMultiSelect renders a configurable ANSI multi-select prompt. +func PromptMultiSelect[T any](items []T, opts PromptMultiSelectOptions[T]) ([]T, bool) { + if len(items) == 0 { + return nil, false + } + + session, err := newPromptSession() + if err != nil { + return nil, false + } + defer session.Close() + + title := promptTitle(opts.Title, "Choose one or more options") + labels, pageSize, promptHeight := preparePromptChoices(session, title, opts.PageSize, items, opts.Render) + session.prepareSelectPrompt(promptHeight) + + limit := opts.Limit + if limit <= 0 || limit > len(items) { + limit = len(items) + } + + result, err := gumchoose.Run(session.terminal.input, session.terminal.output, labels, gumchoose.Options{ + Header: title, + Height: pageSize, + Cursor: "> ", + ShowHelp: false, + Limit: limit, + Ordered: opts.Ordered, + InitialIndex: 0, + }) + if err != nil { + if errors.Is(err, tea.ErrInterrupted) { + return nil, false + } + return nil, false + } + if !result.Submitted { + return nil, false + } + + selected := make([]T, 0, len(result.Indexes)) + for _, index := range result.Indexes { + if index >= 0 && index < len(items) { + selected = append(selected, items[index]) + } + } + + return selected, true +} + +// Prompt is the shared ANSI list prompt runner used by PromptChoose and PromptSelect. +func Prompt[T any](items []T, opts PromptSelectOptions[T]) (T, bool) { + var zero T + if len(items) == 0 { + return zero, false + } + + session, err := newPromptSession() + if err != nil { + return zero, false + } + defer session.Close() + + title := promptTitle(opts.Title, "Choose an option") + labels, pageSize, promptHeight := preparePromptChoices(session, title, opts.PageSize, items, opts.Render) + selectedIndex := clampPromptIndex(opts.InitialIndex, len(items)) + session.prepareSelectPrompt(promptHeight) + + result, err := gumchoose.Run(session.terminal.input, session.terminal.output, labels, gumchoose.Options{ + Header: title, + Height: pageSize, + Cursor: "> ", + ShowHelp: false, + Limit: 1, + InitialIndex: selectedIndex, + }) + if err != nil { + if errors.Is(err, tea.ErrInterrupted) { + return zero, false + } + return zero, false + } + if !result.Submitted || len(result.Indexes) == 0 { + return zero, false + } + + return items[result.Indexes[0]], true +} + +// PromptText renders an ANSI text entry prompt. +func PromptText(opts PromptTextOptions) (string, bool) { + session, err := newPromptSession() + if err != nil { + return "", false + } + defer session.Close() + + value := opts.Default + + field := huh.NewInput(). + Title(promptTitle(opts.Title, "Enter a value")). + Prompt("> "). + Placeholder(opts.Placeholder). + Value(&value) + + if opts.Secret { + field.EchoMode(huh.EchoModePassword) + } + if opts.Validate != nil { + field.Validate(opts.Validate) + } + + form := newPromptForm(session, field) + if err := form.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return "", false + } + return "", false + } + + return value, true +} + +func newPromptForm(session *promptSession, field huh.Field) *huh.Form { + return huh.NewForm( + huh.NewGroup(field), + ). + WithInput(session.terminal.input). + WithOutput(session.terminal.output). + WithShowHelp(false) +} + +func promptTitle(title, fallback string) string { + if strings.TrimSpace(title) == "" { + return fallback + } + return title +} + +func preparePromptChoices[T any](session *promptSession, title string, requestedPageSize int, items []T, render func(T) api.Textable) ([]string, int, int) { + titleHeight := promptRenderedLineCount(title, session.terminal.columnCount()) + pageSize := clampPromptPageSize(requestedPageSize, len(items), session.terminal.lineCount(), titleHeight) + promptHeight := promptSelectHeight(pageSize, len(items), titleHeight) + + labels := make([]string, 0, len(items)) + for _, item := range items { + labels = append(labels, defaultPromptLabel(item, render)) + } + + return labels, pageSize, promptHeight +} + +func defaultPromptLabel[T any](item T, render func(T) api.Textable) string { + if render != nil { + if rendered := render(item); rendered != nil { + if label := sanitizePromptLabel(rendered.String()); label != "" { + return label + } + } + } + + if label, ok := promptPrettyLabel(any(item)); ok && label != "" { + return label + } + + return sanitizePromptLabel(fmt.Sprintf("%v", item)) +} + +func promptPrettyLabel(item any) (string, bool) { + if isNilPromptValue(item) { + return "", false + } + + pretty, ok := item.(promptPretty) + if !ok { + return "", false + } + + return sanitizePromptLabel(pretty.Pretty().String()), true +} + +func isNilPromptValue(value any) bool { + if value == nil { + return true + } + + v := reflect.ValueOf(value) + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return v.IsNil() + default: + return false + } +} + +func sanitizePromptLabel(label string) string { + label = strings.Join(strings.Fields(label), " ") + return label +} + +func clampPromptIndex(index, itemCount int) int { + if itemCount == 0 { + return 0 + } + if index < 0 { + return 0 + } + if index >= itemCount { + return itemCount - 1 + } + return index +} + +func promptTerminalLines(file *os.File) int { + if file == nil { + return 0 + } + + _, lines, err := term.GetSize(int(file.Fd())) + if err != nil || lines <= 0 { + return 0 + } + + return lines +} + +func promptTerminalColumns(file *os.File) int { + if file == nil { + return 0 + } + + columns, _, err := term.GetSize(int(file.Fd())) + if err != nil || columns <= 0 { + return 0 + } + + return columns +} + +func promptRenderedLineCount(text string, width int) int { + text = strings.TrimSpace(text) + if text == "" { + return 0 + } + + lines := 0 + for _, line := range strings.Split(text, "\n") { + if line == "" { + lines++ + continue + } + if width <= 0 { + lines++ + continue + } + lineLength := len([]rune(line)) + lines += (lineLength-1)/width + 1 + } + + return lines +} + +func promptSelectHeight(pageSize, itemCount, titleHeight int) int { + height := pageSize + titleHeight + if itemCount > pageSize { + height++ + } + return height +} + +func promptAvailableLines(terminalLines, cursorRow int) int { + if terminalLines <= 0 { + return 0 + } + if cursorRow <= 0 { + return terminalLines + } + + availableLines := terminalLines - cursorRow + if availableLines < 0 { + return 0 + } + + return availableLines +} + +func shouldClearPromptScreen(pageSize, terminalLines, cursorRow int) bool { + if pageSize <= 0 || terminalLines <= 0 { + return false + } + + return pageSize > promptAvailableLines(terminalLines, cursorRow) +} + +func readPromptCursorResponse(fd int, timeout time.Duration) ([]byte, bool) { + deadline := time.Now().Add(timeout) + buf := make([]byte, 0, 32) + tmp := []byte{0} + + for time.Now().Before(deadline) { + remaining := time.Until(deadline) + timeoutMillis := 1 + if remaining > 0 { + timeoutMillis = int(remaining / time.Millisecond) + if timeoutMillis < 1 { + timeoutMillis = 1 + } + } + + pollfds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} + ready, err := unix.Poll(pollfds, timeoutMillis) + if err != nil { + if err == unix.EINTR { + continue + } + return nil, false + } + if ready == 0 || pollfds[0].Revents&unix.POLLIN == 0 { + continue + } + + n, err := unix.Read(fd, tmp) + if err != nil { + if err == unix.EINTR { + continue + } + return nil, false + } + if n == 0 { + continue + } + + buf = append(buf, tmp[0]) + if tmp[0] == 'R' { + return buf, true + } + } + + return nil, false +} + +func parsePromptCursorResponse(response []byte) (row, col int, ok bool) { + start := strings.LastIndex(string(response), "\x1b[") + end := strings.LastIndexByte(string(response), 'R') + if start == -1 || end == -1 || end <= start { + return 0, 0, false + } + + if _, err := fmt.Sscanf(string(response[start:end+1]), "\x1b[%d;%dR", &row, &col); err != nil { + return 0, 0, false + } + if row < 1 || col < 1 { + return 0, 0, false + } + + return row, col, true +} + +func clampPromptPageSize(pageSize, itemCount, terminalLines, chromeHeight int) int { + if itemCount == 0 { + return 0 + } + + maxPageSize := itemCount + if terminalLines > 0 && itemCount+chromeHeight > terminalLines { + maxPageSize = terminalLines - chromeHeight - 1 + if maxPageSize < 1 { + maxPageSize = 1 + } + } + + minPageSize := promptMinSelectPageSize + if minPageSize > itemCount { + minPageSize = itemCount + } + if maxPageSize > 0 && maxPageSize < minPageSize { + minPageSize = maxPageSize + } + + if pageSize <= 0 { + pageSize = maxPageSize + } else if pageSize > maxPageSize { + pageSize = maxPageSize + } + if pageSize > itemCount { + pageSize = itemCount + } + if pageSize < minPageSize { + pageSize = minPageSize + } + return pageSize +} diff --git a/prompt_terminal_ioctl_darwin_test.go b/prompt_terminal_ioctl_darwin_test.go new file mode 100644 index 00000000..efb5d251 --- /dev/null +++ b/prompt_terminal_ioctl_darwin_test.go @@ -0,0 +1,19 @@ +package clicky + +import ( + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +func getTerminalFlags(t *testing.T, fd int) terminalFlags { + t.Helper() + termios, err := unix.IoctlGetTermios(fd, unix.TIOCGETA) + require.NoError(t, err, "failed to get termios") + return terminalFlags{ + iflag: uint64(termios.Iflag), + oflag: uint64(termios.Oflag), + lflag: uint64(termios.Lflag), + } +} diff --git a/prompt_terminal_ioctl_linux_test.go b/prompt_terminal_ioctl_linux_test.go new file mode 100644 index 00000000..92c20c06 --- /dev/null +++ b/prompt_terminal_ioctl_linux_test.go @@ -0,0 +1,19 @@ +package clicky + +import ( + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +func getTerminalFlags(t *testing.T, fd int) terminalFlags { + t.Helper() + termios, err := unix.IoctlGetTermios(fd, unix.TCGETS) + require.NoError(t, err, "failed to get termios") + return terminalFlags{ + iflag: uint64(termios.Iflag), + oflag: uint64(termios.Oflag), + lflag: uint64(termios.Lflag), + } +} diff --git a/prompt_test.go b/prompt_test.go new file mode 100644 index 00000000..5141188c --- /dev/null +++ b/prompt_test.go @@ -0,0 +1,449 @@ +package clicky + +import ( + "bytes" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/creack/pty" + "github.com/flanksource/clicky/api" + clickytext "github.com/flanksource/clicky/text" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +type terminalFlags struct { + iflag uint64 + oflag uint64 + lflag uint64 +} + +const criticalLflagMask = unix.ISIG | unix.ICANON | unix.ECHO | unix.IEXTEN +const criticalIflagMask = unix.ICRNL | unix.IXON | unix.BRKINT +const criticalOflagMask = unix.OPOST + +var promptHelperBinary string + +func TestMain(m *testing.M) { + if runtime.GOOS == "darwin" || runtime.GOOS == "linux" { + tmpFile, err := os.CreateTemp("", "prompt_test_helper_*") + if err != nil { + panic(err) + } + promptHelperBinary = tmpFile.Name() + _ = tmpFile.Close() + + cmd := exec.Command("go", "build", "-o", promptHelperBinary, "./testdata/prompt_test_helper.go") + cmd.Dir = findProjectRoot() + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + panic("failed to build prompt test helper: " + err.Error()) + } + } + + code := m.Run() + if promptHelperBinary != "" { + _ = os.Remove(promptHelperBinary) + } + os.Exit(code) +} + +func findProjectRoot() string { + dir, err := os.Getwd() + if err != nil { + panic("failed to get working directory: " + err.Error()) + } + + current := dir + for { + if _, err := os.Stat(filepath.Join(current, "go.mod")); err == nil { + return current + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + + return dir +} + +type capturedPTY struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (c *capturedPTY) Write(p []byte) (int, error) { + c.mu.Lock() + defer c.mu.Unlock() + return c.buf.Write(p) +} + +func (c *capturedPTY) String() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.buf.String() +} + +type promptPrettyItem struct { + label string +} + +func (p promptPrettyItem) Pretty() api.Text { + return api.Text{Content: p.label} +} + +type promptStringerItem struct { + label string +} + +func (p promptStringerItem) String() string { + return p.label +} + +func TestDefaultPromptLabelUsesPrettyFirst(t *testing.T) { + got := defaultPromptLabel(promptPrettyItem{label: "pretty-label"}, nil) + if got != "pretty-label" { + t.Fatalf("expected pretty label, got %q", got) + } +} + +func TestDefaultPromptLabelFallsBackToFmt(t *testing.T) { + got := defaultPromptLabel(promptStringerItem{label: "stringer-label"}, nil) + if got != "stringer-label" { + t.Fatalf("expected stringer label, got %q", got) + } +} + +func TestClampPromptPageSizeUsesTerminalHeightWhenUnset(t *testing.T) { + got := clampPromptPageSize(0, 50, 18, 1) + if got != 16 { + t.Fatalf("expected page size 16, got %d", got) + } +} + +func TestClampPromptPageSizeCapsRequestedSizeToTerminalHeight(t *testing.T) { + got := clampPromptPageSize(40, 50, 18, 1) + if got != 16 { + t.Fatalf("expected page size 16, got %d", got) + } +} + +func TestClampPromptPageSizeKeepsRequestedSizeWithinAvailableHeight(t *testing.T) { + got := clampPromptPageSize(6, 50, 18, 1) + if got != 6 { + t.Fatalf("expected page size 6, got %d", got) + } +} + +func TestClampPromptPageSizeUsesMinimumSelectHeight(t *testing.T) { + got := clampPromptPageSize(1, 50, 18, 1) + if got != 5 { + t.Fatalf("expected page size 5, got %d", got) + } +} + +func TestPromptSelectHeightIncludesTitleAndPaginator(t *testing.T) { + got := promptSelectHeight(5, 8, 1) + if got != 7 { + t.Fatalf("expected prompt height 7, got %d", got) + } +} + +func TestPromptRenderedLineCount(t *testing.T) { + got := promptRenderedLineCount("Choose the next rollout action", 80) + if got != 1 { + t.Fatalf("expected rendered line count 1, got %d", got) + } +} + +func TestPromptAvailableLinesUsesRemainingRows(t *testing.T) { + got := promptAvailableLines(24, 10) + if got != 14 { + t.Fatalf("expected 14 available lines, got %d", got) + } +} + +func TestShouldClearPromptScreenWhenPageSizeExceedsRemainingRows(t *testing.T) { + if !shouldClearPromptScreen(8, 24, 18) { + t.Fatalf("expected prompt to clear when page size exceeds remaining rows") + } +} + +func TestShouldNotClearPromptScreenWhenPageFitsRemainingRows(t *testing.T) { + if shouldClearPromptScreen(6, 24, 18) { + t.Fatalf("expected prompt to fit without clearing") + } +} + +func TestPrepareSelectPromptClearsScreenAfterTaskTakeover(t *testing.T) { + output, err := os.CreateTemp("", "prompt_output_*") + require.NoError(t, err) + defer os.Remove(output.Name()) + defer output.Close() + + session := &promptSession{ + terminal: &promptTerminal{output: output}, + tookOverRender: true, + } + + session.prepareSelectPrompt(8) + + _, err = output.Seek(0, 0) + require.NoError(t, err) + + data, err := io.ReadAll(output) + require.NoError(t, err) + require.Equal(t, promptClearScreenSequence, string(data)) +} + +func TestPromptChooseConfirmsSelectionAndRestoresTerminal(t *testing.T) { + skipPromptPTYTests(t) + + ptmx, capture, before, cmd := startPromptHelper(t, "choose_confirm") + defer ptmx.Close() + + time.Sleep(500 * time.Millisecond) + _, err := ptmx.Write([]byte("\x1b[B")) + require.NoError(t, err) + time.Sleep(100 * time.Millisecond) + _, err = ptmx.Write([]byte{'\r'}) + require.NoError(t, err) + + requireExitWithin(t, cmd, 10*time.Second) + after := getTerminalFlags(t, int(ptmx.Fd())) + assertFlagsMatch(t, before, after) + + output := clickytext.StripANSI(capture.String()) + require.Contains(t, output, "RESULT=beta OK=true") +} + +func TestPromptChooseCancel(t *testing.T) { + skipPromptPTYTests(t) + + ptmx, capture, _, cmd := startPromptHelper(t, "choose_cancel") + defer ptmx.Close() + + time.Sleep(500 * time.Millisecond) + _, err := ptmx.Write([]byte{27}) + require.NoError(t, err) + + requireExitWithin(t, cmd, 10*time.Second) + output := clickytext.StripANSI(capture.String()) + require.Contains(t, output, "RESULT= OK=false") +} + +func TestPromptMultiSelectConfirmsSelections(t *testing.T) { + skipPromptPTYTests(t) + + ptmx, capture, _, cmd := startPromptHelper(t, "multi_confirm") + defer ptmx.Close() + + time.Sleep(500 * time.Millisecond) + _, err := ptmx.Write([]byte("x")) + require.NoError(t, err) + _, err = ptmx.Write([]byte("\x1b[B")) + require.NoError(t, err) + time.Sleep(100 * time.Millisecond) + _, err = ptmx.Write([]byte("x")) + require.NoError(t, err) + time.Sleep(100 * time.Millisecond) + _, err = ptmx.Write([]byte{'\r'}) + require.NoError(t, err) + + requireExitWithin(t, cmd, 10*time.Second) + output := clickytext.StripANSI(capture.String()) + require.Contains(t, output, "RESULT=alpha,beta OK=true") +} + +func TestPromptTextValidation(t *testing.T) { + skipPromptPTYTests(t) + + ptmx, capture, _, cmd := startPromptHelper(t, "text_validate") + defer ptmx.Close() + + waitForCapturedText(t, capture, "> ") + _, err := ptmx.Write([]byte{'\r'}) + require.NoError(t, err) + waitForCapturedText(t, capture, "value required") + + _, err = ptmx.Write([]byte("moshe\r")) + require.NoError(t, err) + + requireExitWithin(t, cmd, 10*time.Second) + output := clickytext.StripANSI(capture.String()) + require.Contains(t, output, "RESULT=moshe OK=true") +} + +func TestPromptTextSecretDoesNotEchoInput(t *testing.T) { + skipPromptPTYTests(t) + + ptmx, capture, _, cmd := startPromptHelper(t, "secret") + defer ptmx.Close() + + waitForCapturedText(t, capture, "> ") + _, err := ptmx.Write([]byte("shh\r")) + require.NoError(t, err) + + requireExitWithin(t, cmd, 10*time.Second) + output := clickytext.StripANSI(capture.String()) + require.Contains(t, output, "RESULT_LEN=3 OK=true") + if strings.Contains(output, "shh") { + t.Fatalf("secret input was echoed in prompt output: %q", output) + } +} + +func TestPromptStopsTaskRenderingBeforePrompt(t *testing.T) { + skipPromptPTYTests(t) + + ptmx, capture, _, cmd := startPromptHelper(t, "task_takeover") + defer ptmx.Close() + + time.Sleep(800 * time.Millisecond) + _, err := ptmx.Write([]byte("\x1b[B")) + require.NoError(t, err) + time.Sleep(100 * time.Millisecond) + _, err = ptmx.Write([]byte{'\r'}) + require.NoError(t, err) + + requireExitWithin(t, cmd, 10*time.Second) + output := clickytext.StripANSI(capture.String()) + + if !strings.Contains(output, "TAKEOVER-MARKER") { + t.Fatalf("expected task output before prompt takeover, got %q", output) + } + require.Contains(t, output, "stop-marker") + require.Contains(t, output, "OK=true") +} + +func skipPromptPTYTests(t *testing.T) { + t.Helper() + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skip("PTY prompt tests only run on darwin and linux") + } + if promptHelperBinary == "" { + t.Skip("prompt helper not built") + } +} + +func startPromptHelper(t *testing.T, mode string) (*os.File, *capturedPTY, terminalFlags, *exec.Cmd) { + t.Helper() + + cmd := exec.Command(promptHelperBinary) + cmd.Env = append(os.Environ(), + "PROMPT_MODE="+mode, + "TERM=xterm-256color", + ) + + ptmx, err := pty.Start(cmd) + require.NoError(t, err) + + capture := &capturedPTY{} + go func() { + buffer := make([]byte, 4096) + pending := "" + + for { + n, err := ptmx.Read(buffer) + if n > 0 { + chunk := buffer[:n] + _, _ = capture.Write(chunk) + pending += string(chunk) + pending = replyToTerminalQueries(t, ptmx, pending) + } + if err != nil { + return + } + } + }() + + before := getTerminalFlags(t, int(ptmx.Fd())) + return ptmx, capture, before, cmd +} + +func replyToTerminalQueries(t *testing.T, ptmx *os.File, pending string) string { + t.Helper() + + replies := map[string]string{ + "\x1b[6n": "\x1b[1;1R", + "\x1b]11;?\x1b\\": "\x1b]11;rgb:0000/0000/0000\x1b\\", + } + + for query, reply := range replies { + for { + index := strings.Index(pending, query) + if index == -1 { + break + } + _, err := ptmx.Write([]byte(reply)) + require.NoError(t, err) + pending = pending[:index] + pending[index+len(query):] + } + } + + if len(pending) > 128 { + pending = pending[len(pending)-128:] + } + + return pending +} + +func waitForCapturedText(t *testing.T, capture *capturedPTY, want string) { + t.Helper() + + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if strings.Contains(clickytext.StripANSI(capture.String()), want) { + return + } + time.Sleep(20 * time.Millisecond) + } + + t.Fatalf("timeout waiting for %q in output: %q", want, clickytext.StripANSI(capture.String())) +} + +func requireExitWithin(t *testing.T, cmd *exec.Cmd, timeout time.Duration) { + t.Helper() + + done := make(chan error, 1) + go func() { + done <- cmd.Wait() + }() + + select { + case err := <-done: + if err == nil { + return + } + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() != 0 { + t.Fatalf("helper exited with error: %v", err) + } + case <-time.After(timeout): + _ = cmd.Process.Signal(syscall.SIGKILL) + t.Fatal("timeout waiting for prompt helper to exit") + } +} + +func assertFlagsMatch(t *testing.T, before, after terminalFlags) { + t.Helper() + + if before.lflag&criticalLflagMask != after.lflag&criticalLflagMask { + t.Fatalf("lflag mismatch: before=%#x after=%#x", before.lflag&criticalLflagMask, after.lflag&criticalLflagMask) + } + if before.iflag&criticalIflagMask != after.iflag&criticalIflagMask { + t.Fatalf("iflag mismatch: before=%#x after=%#x", before.iflag&criticalIflagMask, after.iflag&criticalIflagMask) + } + if before.oflag&criticalOflagMask != after.oflag&criticalOflagMask { + t.Fatalf("oflag mismatch: before=%#x after=%#x", before.oflag&criticalOflagMask, after.oflag&criticalOflagMask) + } +} From 80a36f8185336e86d528b1435e809febc6126c2c Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 23 Apr 2026 15:22:37 +0300 Subject: [PATCH 3/5] feat(task,examples): Add interactive prompt support and task terminal ownership management Implement exclusive ANSI terminal ownership control to allow prompts to safely take over the terminal from task renderers. Add new task-prompt demo command showcasing interactive select, multi-select, and text input prompts integrated with concurrent task execution. Key changes: - Add AcquirePromptTerminal() for prompt-to-task renderer handoff - Implement ansiTerminalController to coordinate terminal access - Add SetNoRender() to suppress all task output including final summaries - Add StopTask(id) to cancel specific tasks by immutable UUID - Add Task.ID() returning stable UUID independent of task name - Update go.mod dependencies (Go 1.26.1, commons v1.50.3, gomplate v3.24.77) - Add comprehensive tests for no-render and stop-task functionality This enables building interactive CLI applications where prompts can safely interrupt task rendering and restore it afterward. --- examples/uber_demo/go.mod | 24 +++++- examples/uber_demo/go.sum | 60 +++++++++++++ examples/uber_demo/main.go | 168 +++++++++++++++++++++++++++++++++++++ task/group.go | 2 +- task/manager.go | 26 ++++-- task/manager_lifecycle.go | 36 +++++++- task/manager_wait.go | 34 +++++++- task/no_render_test.go | 59 +++++++++++++ task/options.go | 3 + task/snapshot.go | 2 +- task/stop_test.go | 113 +++++++++++++++++++++++++ task/task.go | 7 ++ task/terminal_owner.go | 131 +++++++++++++++++++++++++++++ task/worker.go | 12 +++ 14 files changed, 662 insertions(+), 15 deletions(-) create mode 100644 task/no_render_test.go create mode 100644 task/stop_test.go create mode 100644 task/terminal_owner.go diff --git a/examples/uber_demo/go.mod b/examples/uber_demo/go.mod index 36de9f27..505ea514 100644 --- a/examples/uber_demo/go.mod +++ b/examples/uber_demo/go.mod @@ -1,15 +1,16 @@ module github.com/flanksource/clicky/examples/uber_demo -go 1.25.1 +go 1.26.1 require ( github.com/flanksource/clicky v1.21.1 - github.com/flanksource/commons v1.47.2 - github.com/flanksource/gomplate/v3 v3.24.74 + github.com/flanksource/commons v1.50.3 + github.com/flanksource/gomplate/v3 v3.24.77 github.com/google/uuid v1.6.0 github.com/onsi/ginkgo/v2 v2.28.0 github.com/onsi/gomega v1.39.1 github.com/spf13/cobra v1.10.2 + golang.org/x/term v0.40.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -19,15 +20,23 @@ require ( github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b // indirect github.com/alecthomas/chroma/v2 v2.23.1 // indirect + github.com/antchfx/xmlquery v1.5.1 // indirect + github.com/antchfx/xpath v1.3.6 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect github.com/cert-manager/cert-manager v1.19.4 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/huh v1.0.0 // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect @@ -36,6 +45,7 @@ require ( github.com/deckarep/golang-set/v2 v2.8.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect github.com/fatih/color v1.18.0 // indirect github.com/flanksource/is-healthy v1.0.86 // indirect @@ -51,6 +61,7 @@ require ( github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/cel-go v0.27.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect @@ -72,9 +83,12 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ohler55/ojg v1.28.0 // indirect @@ -100,6 +114,8 @@ require ( github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/samber/lo v1.53.0 // indirect github.com/samber/oops v1.21.0 // indirect + github.com/shirou/gopsutil/v3 v3.24.5 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // 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 @@ -110,6 +126,7 @@ require ( github.com/tiendc/go-deepcopy v1.7.2 // indirect github.com/timberio/go-datemath v0.1.0 // indirect github.com/tj/go-naturaldate v1.3.0 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect @@ -128,7 +145,6 @@ require ( golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect - golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/tools v0.42.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect diff --git a/examples/uber_demo/go.sum b/examples/uber_demo/go.sum index 9a8a078f..463694b9 100644 --- a/examples/uber_demo/go.sum +++ b/examples/uber_demo/go.sum @@ -32,9 +32,15 @@ github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HR github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= +github.com/antchfx/xmlquery v1.5.1 h1:T9I4Ns1EXiWHy0IqKupGhnfTQtJwlGrpXtauYOoNv78= +github.com/antchfx/xmlquery v1.5.1/go.mod h1:bVqnl7TaDXSReKINrhZz+2E/PbCu2tUahb+wZ7WZNT8= +github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI= +github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/config v1.32.9/go.mod h1:U+fCQ+9QKsLW786BCfEjYRj34VVTbPdsLP3CHSYXMOI= github.com/aws/aws-sdk-go-v2/credentials v1.19.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w= @@ -63,14 +69,22 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cert-manager/cert-manager v1.19.4 h1:7lOkSYj+nJNjgGFfAznQzPpOfWX+1Kgz6xUXwTa/K5k= github.com/cert-manager/cert-manager v1.19.4/go.mod h1:9uBnn3IK9NxjjuXmQDYhwOwFUU5BtGVB1g/voPvvcVw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= @@ -79,6 +93,8 @@ github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMx github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= @@ -107,6 +123,7 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU= @@ -118,10 +135,14 @@ github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/ github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/flanksource/commons v1.47.2 h1:3CE3MHBWM/1rehHDduUR8r55rP8HorbYuiB2LO8Rjmw= github.com/flanksource/commons v1.47.2/go.mod h1:ouTo/VFVZ/7LKEDWgHLQKdCJsTreDrUOmV0Z3CxZosY= +github.com/flanksource/commons v1.50.3 h1:uWsI9dCxkCrpnCplFlQ+wy3jbOedOvsGcqoogrgmrsQ= +github.com/flanksource/commons v1.50.3/go.mod h1:IAZ94jQnUMMRShmbkg4+sPHEdyQfVc7a1tl2BnL1VF8= github.com/flanksource/gomplate/v3 v3.24.71 h1:c610TQ+YEhA39bJIl5wiG3ynWxyrPlbgu4BoCHxlJfo= github.com/flanksource/gomplate/v3 v3.24.71/go.mod h1:PMJGo4K81b0TB0FrC8WJDE9+vW3X/zWHQ8eGpCMYXmo= github.com/flanksource/gomplate/v3 v3.24.74 h1:LR1TGUxbQiZyjPgxP7dElVZi6LzMwBVqyZ3z0FgkMPU= github.com/flanksource/gomplate/v3 v3.24.74/go.mod h1:l3iet81uj0sje3uMIkAURjOu1E4UErJ3PMVTYhvABCM= +github.com/flanksource/gomplate/v3 v3.24.77 h1:StGjXRxeADULhxA9rXbPzAPk+C/952gNeU+LUO7qRXk= +github.com/flanksource/gomplate/v3 v3.24.77/go.mod h1:IioRhY9IdwdlPI/xdZOiLHGsFJqW4Sp6yXmrqCFuh+k= github.com/flanksource/is-healthy v1.0.83 h1:5wiq4gwUXpWnihqikLyGLTri+bbpxWtiUCQPesvJle4= github.com/flanksource/is-healthy v1.0.83/go.mod h1:6/KOjVUeevIbaIaVtSDhy6qsnbqyy5WPZNlRGQCBxcw= github.com/flanksource/is-healthy v1.0.86 h1:N4oxqdW8/YN7+EmEHk4rStpYXzuGADiMAXP2T75bynw= @@ -173,6 +194,8 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw= @@ -182,6 +205,7 @@ github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXn github.com/google/certificate-transparency-go v1.3.1/go.mod h1:gg+UQlx6caKEDQ9EElFOujyxEQEfOiQzAt6782Bvi8k= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= @@ -279,6 +303,8 @@ github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3v github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= @@ -289,6 +315,10 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -359,7 +389,9 @@ github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/samber/oops v1.21.0 h1:18atcO4oEigNFuGXqr3NZWZ6P0XOSEXyBSAMXdQRxTc= github.com/samber/oops v1.21.0/go.mod h1:Hsm/sKPxtCfPh0w/cE3xVoRfSiE1joDRiStPAsmG9bo= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= @@ -397,6 +429,7 @@ github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160 h1:NSWpaDaurcAJY7PkL8Xt0 github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0= github.com/tj/go-naturaldate v1.3.0 h1:OgJIPkR/Jk4bFMBLbxZ8w+QUxwjqSvzd9x+yXocY4RI= github.com/tj/go-naturaldate v1.3.0/go.mod h1:rpUbjivDKiS1BlfMGc2qUKNZ/yxgthOfmytQs8d8hKk= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= @@ -466,7 +499,10 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= @@ -476,6 +512,9 @@ golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -485,6 +524,10 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v 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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 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.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= @@ -492,6 +535,10 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -505,15 +552,23 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -521,7 +576,10 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= @@ -531,6 +589,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 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/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= diff --git a/examples/uber_demo/main.go b/examples/uber_demo/main.go index 90d36fa2..b52e313a 100644 --- a/examples/uber_demo/main.go +++ b/examples/uber_demo/main.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "os" + "strings" "time" "github.com/flanksource/clicky" @@ -14,6 +15,7 @@ import ( flanksourceContext "github.com/flanksource/commons/context" "github.com/google/uuid" "github.com/spf13/cobra" + "golang.org/x/term" ) // Helper functions for creating pointers @@ -614,6 +616,21 @@ type TasksOptions struct { Timeout time.Duration `flag:"timeout" help:"Overall timeout for all tasks" default:"0"` } +// TaskPromptOptions configures the interactive task/prompt demo. +type TaskPromptOptions struct { + NoSleep bool `flag:"no-sleep" help:"Skip simulated delays before the prompt appears" default:"false"` + Count int `flag:"count" help:"Number of options to generate for the select prompts" default:"8"` +} + +type TaskPromptResult struct { + SelectedAction string `json:"selected_action" pretty:"label=Selected Action"` + SelectedChecks []string `json:"selected_checks,omitempty" pretty:"label=Selected Checks,omitempty"` + ChangeTicket string `json:"change_ticket,omitempty" pretty:"label=Change Ticket,omitempty"` + PromptStatus string `json:"prompt_status" pretty:"label=Prompt Status"` + OptionCount int `json:"option_count" pretty:"label=Option Count"` + TerminalNote string `json:"terminal_note" pretty:"label=Terminal Handoff"` +} + // ProductTable demonstrates basic table formatting type ProductTable struct { ID int `json:"id" pretty:"label=ID"` @@ -868,6 +885,156 @@ func showTasks(opts TasksOptions) (any, error) { return nil, nil } +func showTaskPrompt(opts TaskPromptOptions) (any, error) { + if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stderr.Fd())) { + return nil, fmt.Errorf("task-prompt requires an interactive terminal") + } + + optionCount := max(opts.Count, 3) + actions := buildRolloutActions(optionCount) + checks := buildValidationChecks(optionCount) + + sleep := func(d time.Duration) { + if !opts.NoSleep { + time.Sleep(d) + } + } + + releaseWatch := make(chan struct{}) + + task.StartTask("Collect rollout snapshot", func(ctx flanksourceContext.Context, t *task.Task) (map[string]any, error) { + steps := []string{ + "Inspecting cluster state", + "Checking canary pods", + "Verifying recent error rates", + } + for i, step := range steps { + sleep(180 * time.Millisecond) + t.SetProgress(i+1, len(steps)) + t.Infof("%s", step) + } + t.Success() + return map[string]any{"region": "us-east-1", "healthy_pods": 12}, nil + }) + + task.StartTask("Watch operator gate", func(ctx flanksourceContext.Context, t *task.Task) (string, error) { + stages := []string{ + "Streaming canary metrics", + "Tailing ingress logs", + "Waiting for operator prompt", + } + for i, stage := range stages { + sleep(150 * time.Millisecond) + t.SetDescription(stage) + t.SetProgress(i+1, len(stages)) + } + + <-releaseWatch + t.SetDescription("Operator responded") + t.Success() + return "gate released", nil + }) + + sleep(350 * time.Millisecond) + + result := TaskPromptResult{ + SelectedAction: "operator cancelled", + PromptStatus: "selection cancelled", + OptionCount: optionCount, + TerminalNote: "Task rendering stops before prompt UI takes over the ANSI terminal.", + } + + action, ok := clicky.PromptSelect(actions, clicky.PromptSelectOptions[string]{ + Title: "Choose the next rollout action", + }) + + if ok { + result.SelectedAction = action + result.PromptStatus = "action selected" + + selectedChecks, checksOK := clicky.PromptMultiSelect(checks, clicky.PromptMultiSelectOptions[string]{ + Title: "Choose the rollout checks to keep running", + Limit: min(4, len(checks)), + Ordered: true, + }) + + if checksOK { + result.SelectedChecks = selectedChecks + result.PromptStatus = "checks selected" + + ticket, ticketOK := clicky.PromptText(clicky.PromptTextOptions{ + Title: "Enter the change ticket", + Placeholder: "e.g. REL-1234", + Validate: func(value string) error { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("change ticket is required") + } + return nil + }, + }) + if ticketOK { + result.ChangeTicket = ticket + result.PromptStatus = "confirmed" + } else { + result.PromptStatus = "ticket entry cancelled" + } + } else { + result.PromptStatus = "check selection cancelled" + } + } + + close(releaseWatch) + task.Wait() + + return result, nil +} + +func buildRolloutActions(count int) []string { + base := []string{ + "Promote canary", + "Hold rollout", + "Abort release", + "Roll back deploy", + "Pause traffic shift", + "Scale canary up", + "Scale canary down", + "Open incident bridge", + "Notify API owners", + "Trigger smoke suite", + } + + return buildPromptOptions(base, count, "Action") +} + +func buildValidationChecks(count int) []string { + base := []string{ + "Tail ingress error logs", + "Watch saturation dashboard", + "Run synthetic checkout probe", + "Verify canary pod restarts", + "Inspect downstream queue depth", + "Track p95 latency panel", + "Confirm feature flag rollout", + "Ping on-call database owner", + "Check payment gateway health", + "Review deployment event stream", + } + + return buildPromptOptions(base, count, "Check") +} + +func buildPromptOptions(base []string, count int, prefix string) []string { + options := make([]string, 0, count) + for i := 0; i < count; i++ { + if i < len(base) { + options = append(options, base[i]) + continue + } + options = append(options, fmt.Sprintf("%s %02d", prefix, i+1)) + } + return options +} + func runBasicTasks(sleep func(time.Duration), opts ...task.Option) { // Task that succeeds and returns a string result task.StartTask("Download dependencies", func(ctx flanksourceContext.Context, t *task.Task) (string, error) { @@ -1277,6 +1444,7 @@ func main() { clicky.AddNamedCommand("table-provider", rootCmd, TableProviderOptions{}, showTableProvider) clicky.AddNamedCommand("trees", rootCmd, clicky.FileTreeOptions{}, showTrees) clicky.AddNamedCommand("tasks", rootCmd, TasksOptions{}, showTasks) + clicky.AddNamedCommand("task-prompt", rootCmd, TaskPromptOptions{}, showTaskPrompt) clicky.AddNamedCommand("task-ui", rootCmd, TaskUIOptions{}, showTaskUI) clicky.AddNamedCommand("nil-handling", rootCmd, NilHandlingOptions{}, showNilHandling) clicky.AddNamedCommand("components", rootCmd, ComponentsOptions{}, showComponents) diff --git a/task/group.go b/task/group.go index 9288e8a5..733a6ed6 100644 --- a/task/group.go +++ b/task/group.go @@ -214,7 +214,7 @@ func (g *TypedGroup[T]) WaitFor() *WaitResult { result.Duration = g.Duration() // For plain render mode, force a final render - if g.manager != nil && g.manager.noProgress.Load() { + if g.manager != nil && g.manager.noProgress.Load() && !g.manager.noRender.Load() { g.manager.PlainRender() } diff --git a/task/manager.go b/task/manager.go index 1dd7f0be..948c4cd3 100644 --- a/task/manager.go +++ b/task/manager.go @@ -15,6 +15,7 @@ import ( "github.com/flanksource/commons/collections" flanksourceContext "github.com/flanksource/commons/context" "github.com/flanksource/commons/logger" + "github.com/google/uuid" "golang.org/x/sync/semaphore" "golang.org/x/term" ) @@ -37,6 +38,7 @@ type Manager struct { onInterrupt func() // optional cleanup callback noColor atomic.Bool // Disable colored output noProgress atomic.Bool // Disable progress display + noRender atomic.Bool // Disable all task rendering // Priority queue for task scheduling taskQueue *collections.Queue[*Task] @@ -52,6 +54,7 @@ type Manager struct { renderDone chan struct{} renderStopped sync.Once renderStarted sync.Once + renderOwnsTTY bool // Terminal state originalTermState *term.State @@ -266,6 +269,16 @@ func SetNoProgress(noProgress bool) { global.noProgress.Store(noProgress) } +// SetNoRender enables or disables all task rendering, including final summaries. +func SetNoRender(noRender bool) { + global.noRender.Store(noRender) +} + +// IsNoRender reports whether task rendering is currently disabled. +func IsNoRender() bool { + return global.noRender.Load() +} + // SetMaxConcurrent sets the maximum number of concurrent tasks func SetMaxConcurrent(max int) { global.mu.Lock() @@ -315,6 +328,7 @@ func (tm *Manager) newTask(name string, opts ...Option) *Task { task := &Task{ name: name, + id: uuid.NewString(), status: StatusPending, progress: 0, maxValue: 100, @@ -355,11 +369,13 @@ func (tm *Manager) newTask(name string, opts ...Option) *Task { } func (tm *Manager) enqueue(task *Task) *Task { - tm.renderStarted.Do(func() { - if !tm.noProgress.Load() { - tm.startRenderLoop() - } - }) + if !tm.noRender.Load() { + tm.renderStarted.Do(func() { + if !tm.noProgress.Load() { + tm.startRenderLoop() + } + }) + } if task.identity != "" { if existing, ok := tm.tasksByIdentity.Load(task.identity); ok { diff --git a/task/manager_lifecycle.go b/task/manager_lifecycle.go index 27197cf6..b47aeda5 100644 --- a/task/manager_lifecycle.go +++ b/task/manager_lifecycle.go @@ -12,9 +12,17 @@ import ( // startRenderLoop starts the unified render loop for both interactive and non-interactive modes. // Must not be called concurrently with stopRender. func (tm *Manager) startRenderLoop() { + if tm.noRender.Load() { + return + } + if tm.isInteractive && !globalANSITerminal.tryAcquireTaskRenderer(tm) { + return + } + tm.mu.Lock() tm.stopRenderCh = make(chan struct{}) tm.renderDone = make(chan struct{}) + tm.renderOwnsTTY = tm.isInteractive tm.mu.Unlock() go tm.renderLoop() } @@ -27,6 +35,7 @@ func (tm *Manager) renderLoop() { defer func() { if r := recover(); r != nil { tm.cleanupTerminal() + tm.releaseRenderTerminal() panic(r) } }() @@ -71,14 +80,17 @@ func (tm *Manager) stopRender() { close(ch) <-done } - tm.renderFinal() + if !tm.noRender.Load() { + tm.renderFinal() + } tm.cleanupTerminal() + tm.releaseRenderTerminal() }) } // cleanupTerminal restores terminal to a clean state func (tm *Manager) cleanupTerminal() { - if !tm.isInteractive || tm.noProgress.Load() { + if !tm.isInteractive || tm.noProgress.Load() || !tm.ownsRenderTerminal() { return } output := tm.renderer.Output() @@ -93,6 +105,9 @@ func (tm *Manager) cleanupTerminal() { // renderFinal outputs the final task status func (tm *Manager) renderFinal() { + if tm.noRender.Load() { + return + } tm.mu.RLock() if len(tm.tasks) == 0 { tm.mu.RUnlock() @@ -109,3 +124,20 @@ func (tm *Manager) renderFinal() { fmt.Fprintln(os.Stderr, rendered.ANSI()) } } + +func (tm *Manager) ownsRenderTerminal() bool { + tm.mu.RLock() + defer tm.mu.RUnlock() + return tm.renderOwnsTTY +} + +func (tm *Manager) releaseRenderTerminal() { + tm.mu.Lock() + ownsTTY := tm.renderOwnsTTY + tm.renderOwnsTTY = false + tm.mu.Unlock() + + if ownsTTY { + globalANSITerminal.releaseTaskRenderer(tm) + } +} diff --git a/task/manager_wait.go b/task/manager_wait.go index cc627146..19b31e0a 100644 --- a/task/manager_wait.go +++ b/task/manager_wait.go @@ -35,6 +35,36 @@ func CancelAll() { } } +// StopTask cancels a specific pending or running task by immutable ID. +func StopTask(id string) bool { + if global == nil || id == "" { + return false + } + + global.mu.RLock() + tasks := make([]*Task, len(global.tasks)) + copy(tasks, global.tasks) + global.mu.RUnlock() + + for _, task := range tasks { + if task == nil || task.ID() != id { + continue + } + + task.mu.Lock() + runnable := task.status == StatusPending || task.status == StatusRunning + task.mu.Unlock() + if !runnable { + return false + } + + task.Cancel() + return true + } + + return false +} + // ClearTasks removes all completed tasks from the task list func ClearTasks() { global.mu.Lock() @@ -157,7 +187,7 @@ func Debug() string { defer global.mu.RUnlock() var result string - result += fmt.Sprintf("Task Manager: {no-color=%v, no-progress=%v, workers=%v}\n", global.noColor.Load(), global.noProgress.Load(), global.workersActive.Load()) + result += fmt.Sprintf("Task Manager: {no-color=%v, no-progress=%v, no-render=%v, workers=%v}\n", global.noColor.Load(), global.noProgress.Load(), global.noRender.Load(), global.workersActive.Load()) result += fmt.Sprintf(" Total Tasks: %d\n", len(global.tasks)) result += fmt.Sprintf(" Active Workers: %d\n", global.workersActive.Load()) result += " Task Details:\n" @@ -201,7 +231,7 @@ func WaitForAllTasks() { } // For plain render mode, force a final render - if global.noProgress.Load() { + if global.noProgress.Load() && !global.noRender.Load() { global.PlainRender() } } diff --git a/task/no_render_test.go b/task/no_render_test.go new file mode 100644 index 00000000..494724d0 --- /dev/null +++ b/task/no_render_test.go @@ -0,0 +1,59 @@ +package task + +import ( + "bytes" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/flanksource/clicky/text" + flanksourceContext "github.com/flanksource/commons/context" +) + +func TestNoRenderSuppressesFinalOutput(t *testing.T) { + originalStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create stderr pipe: %v", err) + } + t.Cleanup(func() { + os.Stderr = originalStderr + _ = w.Close() + _ = r.Close() + }) + os.Stderr = w + + var output bytes.Buffer + done := make(chan struct{}) + go func() { + _, _ = io.Copy(&output, r) + close(done) + }() + + manager := newTestManager(1) + manager.noRender.Store(true) + + task := manager.newTask("hidden") + task.runFunc = func(flanksourceContext.Context, *Task) error { + time.Sleep(20 * time.Millisecond) + return nil + } + manager.enqueue(task) + + select { + case <-task.doneChan: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for task completion") + } + + manager.stopRender() + + _ = w.Close() + <-done + + if got := strings.TrimSpace(text.StripANSI(output.String())); got != "" { + t.Fatalf("expected no rendered task output, got %q", got) + } +} diff --git a/task/options.go b/task/options.go index 808dda0f..fdfe8b09 100644 --- a/task/options.go +++ b/task/options.go @@ -104,6 +104,7 @@ func WithIdentity(identity string) Option { // ManagerOptions contains configuration options for TaskManager type ManagerOptions struct { NoProgress bool // Disable progress display + NoRender bool // Disable all task rendering MaxConcurrent int // Maximum concurrent tasks (0 = unlimited) GracefulTimeout time.Duration // Timeout for graceful shutdown @@ -116,6 +117,7 @@ type ManagerOptions struct { func DefaultManagerOptions() *ManagerOptions { return &ManagerOptions{ NoProgress: false, + NoRender: false, MaxConcurrent: 1, GracefulTimeout: 10 * time.Second, MaxRetries: 3, @@ -126,6 +128,7 @@ func DefaultManagerOptions() *ManagerOptions { // Apply configures a TaskManager with these options func (opts *ManagerOptions) Apply() { SetNoProgress(opts.NoProgress) + SetNoRender(opts.NoRender) SetMaxConcurrent(opts.MaxConcurrent) SetGracefulTimeout(opts.GracefulTimeout) diff --git a/task/snapshot.go b/task/snapshot.go index 68cc7f49..8c7dbbab 100644 --- a/task/snapshot.go +++ b/task/snapshot.go @@ -29,7 +29,7 @@ type TaskSnapshot struct { // SnapshotTask creates a TaskSnapshot from a Task. func SnapshotTask(t *Task, groupName string) TaskSnapshot { snap := TaskSnapshot{ - ID: t.Name(), + ID: t.ID(), Name: t.Name(), Type: "task", Group: groupName, diff --git a/task/stop_test.go b/task/stop_test.go new file mode 100644 index 00000000..0ccfb89c --- /dev/null +++ b/task/stop_test.go @@ -0,0 +1,113 @@ +package task + +import ( + "sync/atomic" + "testing" + "time" + + flanksourceContext "github.com/flanksource/commons/context" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSnapshotTaskIDIsStableAcrossRename(t *testing.T) { + tm := newTestManager(1) + task := tm.newTask("first-name", WithIdentity("dedupe-key")) + + require.NotEmpty(t, task.ID()) + assert.NotEqual(t, "dedupe-key", task.ID()) + + before := task.ID() + task.SetName("second-name") + after := task.ID() + + assert.Equal(t, before, after) + assert.Equal(t, before, SnapshotTask(task, "group").ID) +} + +func TestStopTaskCancelsRunningTaskByID(t *testing.T) { + originalGlobal := global + global = newTestManager(1) + t.Cleanup(func() { + global.stopRender() + global = originalGlobal + }) + + started := make(chan struct{}) + task := StartTask[string]("blocking", func(ctx flanksourceContext.Context, _ *Task) (string, error) { + close(started) + <-ctx.Done() + return "", ctx.Err() + }) + + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("task did not start") + } + + require.True(t, StopTask(task.ID())) + + wait := task.WaitFor() + assert.Equal(t, StatusCancelled, wait.Status) +} + +func TestStopTaskCancelsPendingTaskByID(t *testing.T) { + originalGlobal := global + global = newTestManager(1) + t.Cleanup(func() { + global.stopRender() + global = originalGlobal + }) + + started := make(chan struct{}) + release := make(chan struct{}) + first := StartTask[string]("first", func(ctx flanksourceContext.Context, _ *Task) (string, error) { + close(started) + select { + case <-release: + return "done", nil + case <-ctx.Done(): + return "", ctx.Err() + } + }) + + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("first task did not start") + } + + var secondRan atomic.Bool + second := StartTask[string]("second", func(flanksourceContext.Context, *Task) (string, error) { + secondRan.Store(true) + return "done", nil + }) + + require.True(t, StopTask(second.ID())) + close(release) + + first.WaitFor() + wait := second.WaitFor() + assert.Equal(t, StatusCancelled, wait.Status) + assert.False(t, secondRan.Load()) +} + +func TestStopTaskReturnsFalseForUnknownOrCompletedTask(t *testing.T) { + originalGlobal := global + global = newTestManager(1) + t.Cleanup(func() { + global.stopRender() + global = originalGlobal + }) + + task := StartTask[string]("done", func(flanksourceContext.Context, *Task) (string, error) { + return "ok", nil + }) + wait := task.WaitFor() + require.Equal(t, StatusSuccess, wait.Status) + + assert.False(t, StopTask("")) + assert.False(t, StopTask("missing")) + assert.False(t, StopTask(task.ID())) +} diff --git a/task/task.go b/task/task.go index f44b2f77..8c69f8df 100644 --- a/task/task.go +++ b/task/task.go @@ -200,6 +200,7 @@ type Task struct { name string description string modelName string + id string prompt string identity string // Unique identifier for task deduplication @@ -257,6 +258,11 @@ func (t *Task) Identity() string { return t.identity } +// ID returns the task's immutable UUID. +func (t *Task) ID() string { + return t.id +} + // Context returns the task's context for cancellation func (t *Task) Context() context.Context { return t.ctx @@ -276,6 +282,7 @@ func (t *Task) Cancel() { if t.cancel != nil { t.cancel() } + t.dirty.Store(true) t.signalDone() // Signal task completion t.mu.Unlock() } else { diff --git a/task/terminal_owner.go b/task/terminal_owner.go new file mode 100644 index 00000000..20d72785 --- /dev/null +++ b/task/terminal_owner.go @@ -0,0 +1,131 @@ +package task + +import "sync" + +type ansiTerminalOwner string + +const ( + ansiTerminalOwnerNone ansiTerminalOwner = "" + ansiTerminalOwnerTaskRenderer ansiTerminalOwner = "task-renderer" + ansiTerminalOwnerPrompt ansiTerminalOwner = "prompt" +) + +type ansiTerminalController struct { + mu sync.Mutex + cond *sync.Cond + owner ansiTerminalOwner + manager *Manager + promptWaiters int +} + +func newANSITerminalController() *ansiTerminalController { + controller := &ansiTerminalController{} + controller.cond = sync.NewCond(&controller.mu) + return controller +} + +var globalANSITerminal = newANSITerminalController() + +func (c *ansiTerminalController) tryAcquireTaskRenderer(manager *Manager) bool { + c.mu.Lock() + defer c.mu.Unlock() + + if c.owner != ansiTerminalOwnerNone || c.promptWaiters > 0 { + return false + } + + c.owner = ansiTerminalOwnerTaskRenderer + c.manager = manager + return true +} + +func (c *ansiTerminalController) beginPromptAcquire() *Manager { + c.mu.Lock() + defer c.mu.Unlock() + + c.promptWaiters++ + if c.owner == ansiTerminalOwnerTaskRenderer { + return c.manager + } + + return nil +} + +func (c *ansiTerminalController) cancelPromptAcquire() { + c.mu.Lock() + defer c.mu.Unlock() + + if c.promptWaiters == 0 { + return + } + + c.promptWaiters-- + if c.promptWaiters == 0 { + c.cond.Broadcast() + } +} + +func (c *ansiTerminalController) acquirePrompt() { + c.mu.Lock() + defer c.mu.Unlock() + + for c.owner != ansiTerminalOwnerNone { + c.cond.Wait() + } + + c.owner = ansiTerminalOwnerPrompt + c.manager = nil + if c.promptWaiters > 0 { + c.promptWaiters-- + } +} + +func (c *ansiTerminalController) releaseTaskRenderer(manager *Manager) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.owner != ansiTerminalOwnerTaskRenderer || c.manager != manager { + return + } + + c.owner = ansiTerminalOwnerNone + c.manager = nil + c.cond.Broadcast() +} + +func (c *ansiTerminalController) releasePrompt() { + c.mu.Lock() + defer c.mu.Unlock() + + if c.owner != ansiTerminalOwnerPrompt { + return + } + + c.owner = ansiTerminalOwnerNone + c.manager = nil + c.cond.Broadcast() +} + +// AcquirePromptTerminal waits for exclusive ANSI terminal ownership for prompt +// rendering, stopping the active task renderer first when necessary. +// The second return value reports whether the prompt displaced an active task renderer. +func AcquirePromptTerminal() (func(), bool) { + manager := globalANSITerminal.beginPromptAcquire() + acquired := false + defer func() { + if !acquired { + globalANSITerminal.cancelPromptAcquire() + } + }() + + if manager != nil { + manager.stopRender() + } + + globalANSITerminal.acquirePrompt() + acquired = true + + return func() { + globalANSITerminal.releasePrompt() + }, manager != nil +} diff --git a/task/worker.go b/task/worker.go index 5739156f..d28dbb19 100644 --- a/task/worker.go +++ b/task/worker.go @@ -32,6 +32,18 @@ func (w *worker) run() { continue } + task.mu.Lock() + skip := task.status == StatusCancelled + task.mu.Unlock() + if skip { + task.completed.Store(true) + task.signalDone() + if task.identity != "" { + w.manager.tasksByIdentity.Delete(task.identity) + } + continue + } + // Check dependencies if !w.checkDependencies(task) { // Dependencies not met, re-enqueue with delay From 252e0170216f89714db30844b8a40db1d43234d6 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 23 Apr 2026 15:22:59 +0300 Subject: [PATCH 4/5] feat(formatters): standardize clicky JSON content type to application/json+clicky Change the primary content type for clicky JSON format from application/clicky+json to application/json+clicky to follow standard IANA media type conventions. The HTTP layer now accepts both the new standard format and the legacy alias for backward compatibility. BREAKING CHANGE: The Content-Type header for clicky JSON responses is now application/json+clicky instead of application/clicky+json. Clients should update their expectations, though the legacy format is still accepted in Accept headers. --- formatters/html_react_formatter.go | 6 +++--- formatters/http/http.go | 4 ++-- formatters/http/http_test.go | 12 ++++++++++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/formatters/html_react_formatter.go b/formatters/html_react_formatter.go index 06b23f4a..500e9f01 100644 --- a/formatters/html_react_formatter.go +++ b/formatters/html_react_formatter.go @@ -21,9 +21,9 @@ func init() { type HTMLReactFormatter struct{} -// ClickyJSONFormatter emits the Clicky document JSON (application/clicky+json) -// that the html-react formatter embeds inside its HTML shell. The payload is -// the same shape consumed by @flanksource/clicky-ui's . +// ClickyJSONFormatter emits the Clicky document JSON (application/json+clicky). +// The HTTP layer also accepts the legacy application/clicky+json alias. The +// payload is the same shape consumed by @flanksource/clicky-ui's . type ClickyJSONFormatter struct{} type clickyDocument struct { diff --git a/formatters/http/http.go b/formatters/http/http.go index 2eb8728e..1bcca215 100644 --- a/formatters/http/http.go +++ b/formatters/http/http.go @@ -188,7 +188,7 @@ func acceptToFormat(accept string) string { return "excel" case "application/vnd.slack.block-kit+json": return "slack" - case "application/clicky+json": + case "application/clicky+json", "application/json+clicky": return "clicky-json" case "text/plain": return "pretty" @@ -241,7 +241,7 @@ func formatToContentType(format string) string { case "json": return "application/json" case "clicky-json": - return "application/clicky+json" + return "application/json+clicky" case "yaml", "yml": return "application/yaml" case "csv": diff --git a/formatters/http/http_test.go b/formatters/http/http_test.go index f27f78b1..e0f686eb 100644 --- a/formatters/http/http_test.go +++ b/formatters/http/http_test.go @@ -70,6 +70,14 @@ func TestFormatHandler_ClickyJSON(t *testing.T) { return r }(), }, + { + name: "Accept: application/json+clicky", + req: func() *http.Request { + r := httptest.NewRequest("GET", "http://example.com/users", nil) + r.Header.Set("Accept", "application/json+clicky") + return r + }(), + }, } for _, tc := range cases { @@ -80,8 +88,8 @@ func TestFormatHandler_ClickyJSON(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } - if ct := w.Header().Get("Content-Type"); ct != "application/clicky+json" { - t.Errorf("Content-Type = %q, want application/clicky+json", ct) + if ct := w.Header().Get("Content-Type"); ct != "application/json+clicky" { + t.Errorf("Content-Type = %q, want application/json+clicky", ct) } var doc struct { From 2eac43a8dccfddfecbab328c882a084aa3841a5a Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 23 Apr 2026 15:23:23 +0300 Subject: [PATCH 5/5] feat(api,ui,example): add time range filtering and entity detail page navigation Implement time range filtering with from/to parameters replacing the previous window-based approach. Add entity detail page with row action support and response body viewing. Update API to support both application/clicky+json and application/json+clicky content types. Add StopTask function to global task API for canceling specific tasks. Upgrade dependencies including Go 1.26.1 and charmbracelet UI libraries. BREAKING CHANGE: stackWindowOpts now uses From and To time.Time fields instead of UpdatedSince and Window duration fields. Content-Type header for clicky-json format changed from application/clicky+json to application/json+clicky. Refs: entity demo tests, example app routing, API response handling --- .../enitity/e2e/tests/entity-demo.spec.ts | 99 ++++++++- examples/enitity/main.go | 39 +++- examples/enitity/main_test.go | 25 +++ examples/enitity/webapp/src/App.tsx | 31 +++ examples/enitity/webapp/src/api.ts | 28 ++- examples/enitity/webapp/vite.config.ts | 6 +- examples/go.mod | 155 +++++++------ examples/go.sum | 56 +++++ global_task_api.go | 5 + go.mod | 15 +- go.sum | 209 ++++++++++++++++++ rpc/serve.go | 4 +- rpc/serve_test.go | 11 +- testdata/prompt_test_helper.go | 103 +++++++++ 14 files changed, 698 insertions(+), 88 deletions(-) create mode 100644 examples/enitity/main_test.go create mode 100644 testdata/prompt_test_helper.go diff --git a/examples/enitity/e2e/tests/entity-demo.spec.ts b/examples/enitity/e2e/tests/entity-demo.spec.ts index b57fe7d8..94f2f066 100644 --- a/examples/enitity/e2e/tests/entity-demo.spec.ts +++ b/examples/enitity/e2e/tests/entity-demo.spec.ts @@ -1,8 +1,10 @@ -import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; +import { expect, test, type APIRequestContext, type Locator, type Page } from "@playwright/test"; const stackRow = (page: Page, name: string) => page.locator("tbody tr").filter({ hasText: name }).first(); const explorerSearch = (page: Page) => page.getByPlaceholder("Search api explorer..."); +const responseBody = (scope: Page | Locator) => + scope.locator('[aria-label="Response body"]'); function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -77,6 +79,7 @@ test.describe("entity demo", () => { await expect(stackRow(page, "checkout")).toBeVisible(); await expect(stackRow(page, "billing")).toBeVisible(); await expect(page.getByText("marketing-site")).toHaveCount(0); + await expect(page.getByRole("button", { name: /time range filter/i })).toBeVisible(); const filteredLookup = waitForLookup(page, "/api/v1/stack", { team: "team/platform", @@ -101,6 +104,43 @@ test.describe("entity demo", () => { await expect(page.getByText("marketing-site")).toHaveCount(0); }); + test("renders the special time range filter and applies from/to over the real stack list", async ({ + page, + }) => { + const stacks = waitForJson(page, "/api/v1/stack"); + const lookup = waitForLookup(page, "/api/v1/stack", {}); + + await page.goto("/stacks", { waitUntil: "domcontentloaded" }); + await Promise.all([stacks, lookup]); + + await expect(page.getByRole("button", { name: /time range filter/i })).toBeVisible(); + await expect(stackRow(page, "checkout")).toBeVisible(); + await expect(stackRow(page, "billing")).toBeVisible(); + + const rangedLookup = waitForLookup(page, "/api/v1/stack", { + from: "now-24h", + to: "now", + }); + await page.getByRole("button", { name: /time range filter/i }).click(); + await page.getByRole("button", { name: /last 24 hours/i }).click(); + await rangedLookup; + + const rangedList = waitForJson(page, "/api/v1/stack", { + from: "now-24h", + to: "now", + }); + await page.getByRole("button", { name: "Apply" }).click(); + await rangedList; + + await expect(stackRow(page, "checkout")).toBeVisible(); + await expect(stackRow(page, "billing")).toHaveCount(0); + + const adminList = waitForJson(page, "/api/v1/admin/stack"); + await page.goto("/admin-stacks", { waitUntil: "domcontentloaded" }); + await adminList; + await expect(page.getByRole("button", { name: /time range filter/i })).toBeVisible(); + }); + test("shows cluster rows and the real cluster endpoint list", async ({ page }) => { const clusters = waitForJson(page, "/api/v1/catalog/cluster"); @@ -117,6 +157,47 @@ test.describe("entity demo", () => { await expect(page.getByText("Get a cluster by ID")).toBeVisible(); }); + test("clicking a list row navigates to detail and locks the row action id", async ({ + page, + }) => { + const stacks = waitForJson(page, "/api/v1/stack"); + + await page.goto("/stacks", { waitUntil: "domcontentloaded" }); + await stacks; + + await Promise.all([ + page.waitForURL(/\/entity\/stacks\/stk-001$/), + stackRow(page, "checkout").getByRole("link", { name: "stk-001" }).click(), + ]); + + await expect(responseBody(page).first()).toContainText("stk-001"); + await expect(responseBody(page).first()).toContainText("checkout"); + await expect(page.getByRole("radiogroup", { name: /clicky view mode/i })).toBeVisible(); + await expect(page.getByRole("button", { name: /^download$/i })).toBeVisible(); + + await page.getByRole("button", { name: /restart/i }).click(); + + const dialog = page.getByRole("dialog"); + await expect(dialog.getByLabel("Id")).toHaveValue("stk-001"); + await expect(dialog.getByLabel("Id")).toBeDisabled(); + + const actionRequest = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + url.pathname === "/api/v1/stack/stk-001/restart" && + response.request().method() === "POST" && + response.ok() + ); + }); + + await dialog.getByLabel("Reason").fill("row-action-test"); + await dialog.getByLabel("Drain").uncheck(); + await dialog.getByRole("button", { name: "Execute request" }).click(); + await actionRequest; + + await expect(responseBody(dialog)).toContainText("row-action-test"); + }); + test("surfaces archived admin rows and explorer endpoints from the generated spec", async ({ page, }) => { @@ -155,8 +236,10 @@ test.describe("entity demo", () => { await expect(page).toHaveURL(/\/commands\/stack_list$/); await page.getByRole("button", { name: "Execute request" }).click(); await listRequest; - await expect(page.getByLabel("Response body")).toContainText("checkout"); - await expect(page.getByLabel("Response body")).toContainText("billing"); + await expect(responseBody(page)).toContainText("checkout"); + await expect(responseBody(page)).toContainText("billing"); + await expect(page.getByRole("radiogroup", { name: /clicky view mode/i })).toBeVisible(); + await expect(page.getByRole("button", { name: /^download$/i })).toBeVisible(); await page.goto("/explorer", { waitUntil: "domcontentloaded" }); await search.fill("get a stack by id"); @@ -168,8 +251,10 @@ test.describe("entity demo", () => { await page.getByLabel("Id").fill("stk-001"); await page.getByRole("button", { name: "Execute request" }).click(); await getRequest; - await expect(page.getByLabel("Response body")).toContainText("stk-001"); - await expect(page.getByLabel("Response body")).toContainText("checkout"); + await expect(responseBody(page)).toContainText("stk-001"); + await expect(responseBody(page)).toContainText("checkout"); + await expect(page.getByRole("radiogroup", { name: /clicky view mode/i })).toBeVisible(); + await expect(page.getByRole("button", { name: /^download$/i })).toBeVisible(); await page.goto("/explorer", { waitUntil: "domcontentloaded" }); await search.fill("restart a stack"); @@ -190,8 +275,8 @@ test.describe("entity demo", () => { await page.getByLabel("Drain").uncheck(); await page.getByRole("button", { name: "Execute request" }).click(); await actionRequest; - await expect(page.getByLabel("Response body")).toContainText('"action": "restart"'); - await expect(page.getByLabel("Response body")).toContainText('"reason": "explorer-test"'); + await expect(responseBody(page)).toContainText("restart"); + await expect(responseBody(page)).toContainText("explorer-test"); }); test("reflects a real restart mutation after reloading the stacks table", async ({ diff --git a/examples/enitity/main.go b/examples/enitity/main.go index 602376d8..ed312afe 100644 --- a/examples/enitity/main.go +++ b/examples/enitity/main.go @@ -20,7 +20,6 @@ import ( "github.com/flanksource/clicky/api" "github.com/flanksource/clicky/extensions" "github.com/flanksource/clicky/rpc" - "github.com/flanksource/commons/duration" "github.com/spf13/cobra" ) @@ -80,9 +79,9 @@ func (c cluster) GetID() string { return c.ID } func (c cluster) GetName() string { return c.Name } type stackWindowOpts struct { - Tags []string `flag:"tags" help:"Return only stacks containing all of these tags"` - UpdatedSince time.Time `flag:"updated-since" help:"Return stacks deployed after this time" default:"now-30d"` - Window duration.Duration `flag:"window" help:"Return stacks deployed within this duration" default:"720h"` + Tags []string `flag:"tags" help:"Return only stacks containing all of these tags"` + From time.Time `flag:"from" help:"Return stacks deployed on or after this time" default:"now-30d"` + To time.Time `flag:"to" help:"Return stacks deployed on or before this time" default:"now"` } type stackListOpts struct { @@ -550,10 +549,10 @@ func (d *demoStore) matchingStacksLocked(opts stackListOpts, includeArchived boo if len(opts.Tags) > 0 && !containsAll(item.Tags, opts.Tags) { continue } - if !opts.UpdatedSince.IsZero() && item.LastDeploy.Before(opts.UpdatedSince) { + if !opts.From.IsZero() && item.LastDeploy.Before(opts.From) { continue } - if time.Duration(opts.Window) > 0 && item.LastDeploy.Before(time.Now().Add(-time.Duration(opts.Window))) { + if !opts.To.IsZero() && item.LastDeploy.After(opts.To) { continue } items = append(items, item) @@ -629,10 +628,38 @@ func (stackStatusFilter) Options(opts stackListOpts) map[string]api.Textable { return options } +type stackFromFilter struct{} + +func (stackFromFilter) Key() string { return "from" } +func (stackFromFilter) Label() string { return "From" } + +func (stackFromFilter) Lookup(_ *stackListOpts) (map[string]api.Textable, error) { + return nil, nil +} + +func (stackFromFilter) Options(_ stackListOpts) map[string]api.Textable { + return nil +} + +type stackToFilter struct{} + +func (stackToFilter) Key() string { return "to" } +func (stackToFilter) Label() string { return "To" } + +func (stackToFilter) Lookup(_ *stackListOpts) (map[string]api.Textable, error) { + return nil, nil +} + +func (stackToFilter) Options(_ stackListOpts) map[string]api.Textable { + return nil +} + func registerEntities(store *demoStore) { stackFilters := []clicky.Filter[stackListOpts]{ stackTeamFilter{}, stackStatusFilter{}, + stackFromFilter{}, + stackToFilter{}, } clicky.RegisterEntity(clicky.Entity[stack, stackListOpts]{ diff --git a/examples/enitity/main_test.go b/examples/enitity/main_test.go new file mode 100644 index 00000000..cc21ee9e --- /dev/null +++ b/examples/enitity/main_test.go @@ -0,0 +1,25 @@ +package main + +import ( + "testing" + "time" +) + +func TestMatchingStacksLockedHonorsFromAndTo(t *testing.T) { + store := newDemoStore() + + items := store.matchingStacksLocked(stackListOpts{ + stackWindowOpts: stackWindowOpts{ + From: time.Now().Add(-24 * time.Hour).UTC(), + To: time.Now().UTC(), + }, + }, false) + + if len(items) != 1 { + t.Fatalf("expected exactly one stack in the last 24h window, got %d", len(items)) + } + + if items[0].ID != "stk-001" { + t.Fatalf("expected stk-001 in the last 24h window, got %s", items[0].ID) + } +} diff --git a/examples/enitity/webapp/src/App.tsx b/examples/enitity/webapp/src/App.tsx index 7c19f774..331e40bd 100644 --- a/examples/enitity/webapp/src/App.tsx +++ b/examples/enitity/webapp/src/App.tsx @@ -2,6 +2,7 @@ import { Link, Navigate, Route, Routes, useParams } from "react-router-dom"; import { OperationCatalog, OperationCommandPage, + OperationEntityPage, ThemeSwitcher, type RenderLink, } from "@flanksource/clicky-ui"; @@ -51,6 +52,35 @@ function CommandRoute() { ); } +function EntityRoute() { + const { domainKey, id } = useParams<{ domainKey: string; id: string }>(); + const spec = domainKey ? domains[domainKey] : undefined; + + if (!spec) { + return ( +
+ Unknown domain: {domainKey} +
+ ); + } + + return ( + + ); +} + function Sidebar() { return (