diff --git a/commands/create/command.go b/commands/create/command.go new file mode 100644 index 0000000..2b7cbd1 --- /dev/null +++ b/commands/create/command.go @@ -0,0 +1,28 @@ +package create + +import ( + "mzm/core" + + "github.com/spf13/cobra" +) + +var inputFormat core.InputFormatEnum = core.FORMAT.CRUD.YAML +var Command = &cobra.Command{ + Use: "create", + Short: "Create new mezmo resource", + Long: "Create new mezmo resource", + Example: core.NewExampleRenderer(). + Example( + "Create a new view", + "mzm create view", + ). + Render(), + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + Command.PersistentFlags().VarP(&inputFormat, "output", "o", "The data format used to interact (edit | create) with remote resources [yaml, json]") + Command.AddCommand(viewCommand) +} diff --git a/commands/create/view.go b/commands/create/view.go new file mode 100644 index 0000000..d8d5f16 --- /dev/null +++ b/commands/create/view.go @@ -0,0 +1,58 @@ +package create + +import ( + "fmt" + "github.com/spf13/cobra" + "mzm/core" + "mzm/core/resource" + resourceView "mzm/core/resource/v1/view" +) + +var viewCommand = &cobra.Command{ + Use: "view", + Short: "Create new mezmo view", + Long: ` + The view subcommand allows you to create a single view resource from a template. + It will open the resource in a text editor as specified by the EDITOR + Environment variable, or fallback to vi on unix platform and notepad on windows. + The default format is yaml. To edit in JSON, specifiy "-o json" + `, + Example: core.NewExampleRenderer().Render(), + RunE: func(cmd *cobra.Command, args []string) error { + resourceInterface, err := resource.Registry.GetResource("v1", "view") + if err != nil { + return fmt.Errorf("failed to get view resource: %w", err) + } + + api, ok := resourceInterface.(resource.IResource[resourceView.View, resourceView.View]) + if !ok { + return fmt.Errorf("unexpected resource type: %T", resourceInterface) + } + + templateContent := api.GetTemplate() + + // Open in editor and get the edited content + content, err := resource.FromString( + templateContent, + inputFormat, + "view", + ) + + if err != nil { + return err + } + + template, err := resource.ParseAndValidate[resourceView.View](content) + if err != nil { + return fmt.Errorf("failed to parse template: %w", err) + } + + result, err := api.Create(*template) + if err != nil { + return fmt.Errorf("failed to create view: %w", err) + } + + fmt.Printf("Successfully created view: %v\n", result) + return nil + }, +} diff --git a/commands/delete/mod.go b/commands/delete/mod.go new file mode 100644 index 0000000..0dec370 --- /dev/null +++ b/commands/delete/mod.go @@ -0,0 +1,21 @@ +package delete + +import ( + "github.com/spf13/cobra" + "mzm/core" +) + +var Command = &cobra.Command{ + Use: "delete", + Short: "Delete resources from a file or stdin.", + Long: "Delete resources from a file or stdin.", + Example: core.NewExampleRenderer(). + Render(), + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + Command.AddCommand(deleteViewCommand) +} diff --git a/commands/delete/view.go b/commands/delete/view.go new file mode 100644 index 0000000..170cb0a --- /dev/null +++ b/commands/delete/view.go @@ -0,0 +1,31 @@ +package delete + +import ( + "github.com/spf13/cobra" + "mzm/core" + "mzm/core/resource" + "mzm/core/resource/v1/view" +) + +var deleteViewCommand = &cobra.Command{ + Use: "view [flags] [view-id]", + Short: "Delete resources from a file or stdin.", + Long: "Delete resources from a file or stdin.", + Args: cobra.RangeArgs(1, 1), + ArgAliases: []string{"viewid"}, + Example: core.NewExampleRenderer().Render(), + RunE: func(cmd *cobra.Command, args []string) error { + + resource, err := resource.Registry.GetResource("v1", "view") + if err != nil { + return err + } + + err = resource.(*view.ViewResource).Remove(args[0]) + + if err != nil { + return err + } + return nil + }, +} diff --git a/commands/get/command.go b/commands/get/command.go new file mode 100644 index 0000000..ca2aa98 --- /dev/null +++ b/commands/get/command.go @@ -0,0 +1,36 @@ +package get + +import ( + "github.com/spf13/cobra" + "mzm/core" +) + +var outputFormat core.OutputFormatEnum = core.FORMAT.OUTPUT.TABLE + +var Command = &cobra.Command{ + Use: "get", + Short: "Introspect various resources.", + Long: "Prints a table of the most important information about the specified resources.", + Example: core.NewExampleRenderer(). + Example( + "Get all views", + "mzm get view", + ). + Example( + "Get a specific view by ID", + "mzm get view ", + ). + Example( + "Get account information", + "mzm get account", + ). + Render(), + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + Command.AddCommand(getViewCommand) + Command.PersistentFlags().VarP(&outputFormat, "output", "o", `output logs in specific format [json, pretty]`) +} diff --git a/commands/get/view.go b/commands/get/view.go new file mode 100644 index 0000000..6460ca6 --- /dev/null +++ b/commands/get/view.go @@ -0,0 +1,161 @@ +package get + +import ( + "cmp" + "fmt" + "mzm/core" + "mzm/core/logging" + coreResource "mzm/core/resource" + api "mzm/core/resource/v1/view" + "os" + "slices" + "strings" + + "github.com/olekukonko/tablewriter" + "github.com/olekukonko/tablewriter/renderer" + "github.com/olekukonko/tablewriter/tw" + "github.com/spf13/cobra" +) + +var defaultViewParams = make(map[string]string) +var getViewCommand = &cobra.Command{ + Use: "view [flags] [view-id]", + Short: "Display Information about view", + Long: "Displays The most infomration about view, which are predefined sets of search filters", + Args: cobra.RangeArgs(0, 1), + ArgAliases: []string{"viewid"}, + Example: core.NewExampleRenderer(). + Example( + `list all views in json format`, + `mzm get view -o json`, + ). + Example( + `Get a specific view by id`, + `mzm get view 3f4bca174`, + ). + Example( + `Get a specific view by name`, + `mzm get view "my first view"`, + ). + Render(), + RunE: func(cmd *cobra.Command, args []string) error { + var views []api.View + var viewid string = "" + var err error = nil + + log, ok := cmd.Context().Value("log").(logging.Logger) + + if ok { + log = log.Child("get.view") + } + + if len(args) > 0 { + viewid = args[0] + } + + viewRes := api.NewViewResource() + + if viewid == "" { + views, err = viewRes.List(defaultViewParams) + if err != nil { + return fmt.Errorf("Unable to get views: %s", err) + } + } else { + view, err := viewRes.Get(viewid, nil) + if err != nil { + return err + } + + if view == nil { + return nil + } + views = []api.View{*view} + } + + switch outputFormat.String() { + case "json", "yaml": + format := core.InputFormatEnum(outputFormat.String()) + if viewid != "" { + // Single view - encode just the view object + content, err := coreResource.Stringify(views[0], format) + if err != nil { + return err + } + os.Stdout.Write(content) + } else { + // Multiple views - encode the entire slice + content, err := coreResource.Stringify(views, format) + if err != nil { + return err + } + os.Stdout.Write(content) + } + case "table": + table := tablewriter.NewTable( + os.Stdout, + tablewriter.WithRenderer( + renderer.NewBlueprint( + tw.Rendition{ + Borders: tw.BorderNone, + Settings: tw.Settings{ + Separators: tw.Separators{ + ShowHeader: tw.Off, + ShowFooter: tw.Off, + BetweenRows: tw.Off, + BetweenColumns: tw.Off, + }, + Lines: tw.Lines{ + ShowTop: tw.Off, + ShowBottom: tw.Off, + ShowHeaderLine: tw.Off, + ShowFooterLine: tw.Off, + }, + }, + }, + ), + ), + tablewriter.WithConfig( + tablewriter.Config{ + Header: tw.CellConfig{ + Alignment: tw.CellAlignment{Global: tw.AlignLeft}, + }, + Row: tw.CellConfig{ + Merging: tw.CellMerging{Mode: tw.MergeHierarchical}, + }, + }, + ), + ) + + table.Header("CATEGORY", "ID", "NAME", "APPS", "HOSTS", "QUERY") + + slices.SortFunc(views, func(a, b api.View) int { + if a.GetCategory() == "Uncategorized" { + return -1 + } + if b.GetCategory() == "Uncategorized" { + return 1 + } + return cmp.Compare(a.GetCategory(), b.GetCategory()) + }) + for _, view := range views { + categories := "Uncategorized" // Default if it doesn't have any + + if len(view.Category) > 0 { + categories = view.Category[0] + } + + table.Append( + categories, + view.PK(), + view.Name, + strings.Join(view.Apps, ", "), + strings.Join(view.Hosts, ", "), + view.Query, + ) + } + + table.Render() + } + return err + }, +} diff --git a/commands/log/command.go b/commands/log/command.go index 3a00d5e..763decf 100644 --- a/commands/log/command.go +++ b/commands/log/command.go @@ -7,7 +7,7 @@ import ( var format formatEnum = pretty var Command = &cobra.Command{ Use: "log", - Short: "A brief description of your command", + Short: "Stream and Search log data", Long: `A longer description that spans multiple lines and likely contains examples and usage of using your command. For example: diff --git a/commands/log/search.go b/commands/log/search.go index c66f9de..15e5276 100644 --- a/commands/log/search.go +++ b/commands/log/search.go @@ -17,14 +17,9 @@ import ( "syscall" "time" - "github.com/phoenix-tui/phoenix/layout" "github.com/spf13/cobra" ) -var left = layout.NewBox("left") -var right = layout.NewBox("right") -var help = layout.NewBox(layout.Row().Gap(1).Add(left).Add(right).Render(80, 1)) -var examples = layout.Column().Add(help) var prefer searchDirection = tail // defined in enum.go var ( @@ -100,6 +95,7 @@ func init() { searchCmd.Flags().VarP(&prefer, "prefer", "p", "Get lines from the beginning of the interval rather than the end") } +// log/tailCmd represents the log/tail command var searchCmd = &cobra.Command{ Use: "search 'hello OR bye'", Short: "execute search queries over your data", diff --git a/commands/log/tail.go b/commands/log/tail.go index 9369d16..effbb1b 100644 --- a/commands/log/tail.go +++ b/commands/log/tail.go @@ -1,6 +1,3 @@ -/* -Copyright © 2026 NAME HERE -*/ package log import ( diff --git a/commands/root.go b/commands/root.go index 48fe393..f3c2fb0 100644 --- a/commands/root.go +++ b/commands/root.go @@ -8,6 +8,9 @@ import ( "os" "strings" + "mzm/commands/create" + "mzm/commands/delete" + "mzm/commands/get" "mzm/commands/log" "mzm/core/logging" @@ -75,4 +78,7 @@ func init() { viper.BindEnv("access-key") rootCmd.AddCommand(log.Command) + rootCmd.AddCommand(get.Command) + rootCmd.AddCommand(create.Command) + rootCmd.AddCommand(delete.Command) } diff --git a/core/constant.go b/core/constant.go new file mode 100644 index 0000000..e770184 --- /dev/null +++ b/core/constant.go @@ -0,0 +1,30 @@ +package core + +var FORMAT = struct { + OUTPUT struct { + JSON OutputFormatEnum + YAML OutputFormatEnum + TABLE OutputFormatEnum + } + CRUD struct { + JSON InputFormatEnum + YAML InputFormatEnum + } +}{ + OUTPUT: struct { + JSON OutputFormatEnum + YAML OutputFormatEnum + TABLE OutputFormatEnum + }{ + JSON: jsonOutput, + YAML: yamlOutput, + TABLE: tableOutput, + }, + CRUD: struct { + JSON InputFormatEnum + YAML InputFormatEnum + }{ + JSON: jsonInput, + YAML: yamlInput, + }, +} diff --git a/core/enum.go b/core/enum.go new file mode 100644 index 0000000..ab0ef60 --- /dev/null +++ b/core/enum.go @@ -0,0 +1,56 @@ +package core + +import ( + "errors" + "strings" +) + +const ( + jsonOutput OutputFormatEnum = "json" + yamlOutput OutputFormatEnum = "yaml" + tableOutput OutputFormatEnum = "table" + jsonInput InputFormatEnum = "json" + yamlInput InputFormatEnum = "yaml" +) + +type OutputFormatEnum string + +func (enum *OutputFormatEnum) String() string { + return string(*enum) +} + +func (enum *OutputFormatEnum) Type() string { + return "output" +} + +func (enum *OutputFormatEnum) Set(value string) error { + lower := strings.ToLower(value) + switch lower { + case "json", "yaml", "table": + *enum = OutputFormatEnum(lower) + return nil + default: + return errors.New(`must be one of "json", "yaml", "table"`) + } +} + +type InputFormatEnum string + +func (enum *InputFormatEnum) String() string { + return string(*enum) +} + +func (enum *InputFormatEnum) Type() string { + return "output" +} + +func (enum *InputFormatEnum) Set(value string) error { + lower := strings.ToLower(value) + switch lower { + case "json", "yaml": + *enum = InputFormatEnum(lower) + return nil + default: + return errors.New(`must be one of "json", "yaml"`) + } +} diff --git a/core/examples.go b/core/examples.go index 1e8b192..854ce5a 100644 --- a/core/examples.go +++ b/core/examples.go @@ -17,6 +17,7 @@ func NewExampleRenderer() *ExampleRender { render := ExampleRender{} return &render } + func (example *ExampleRender) Example(desc string, detail string) *ExampleRender { example.examples = append(example.examples, Example{ Description: desc, diff --git a/core/mod.go b/core/mod.go index 9a8bc95..3a835a3 100644 --- a/core/mod.go +++ b/core/mod.go @@ -1 +1,304 @@ package core + +import ( + "fmt" + "reflect" + "strings" +) + +func GetStructField(s interface{}, fieldName string) interface{} { + v := reflect.ValueOf(s) + + // Ensure the input is actually a struct type + if v.Kind() != reflect.Struct { + fmt.Printf("Error: %v is not a struct\n", s) + return nil + } + + // Get the field by name + field := v.FieldByName(fieldName) + + // Check if the field exists + if !field.IsValid() { + fmt.Printf("Error: Field %s not found in struct\n", fieldName) + return nil + } + + // Use .Interface() to return the actual value stored in the field + return field.Interface() +} + +// GetFieldValue is a more flexible function that can work with both structs and interfaces +// It tries multiple field name variations and can handle interface{} types +func GetFieldValue(obj interface{}, fieldName string) interface{} { + if obj == nil { + fmt.Printf("Error: Cannot get field %s from nil object\n", fieldName) + return nil + } + + v := reflect.ValueOf(obj) + + // If it's a pointer, get the element it points to + if v.Kind() == reflect.Ptr { + if v.IsNil() { + fmt.Printf("Error: Cannot get field %s from nil pointer\n", fieldName) + return nil + } + v = v.Elem() + } + + // Handle interface{} by getting the concrete value + if v.Kind() == reflect.Interface { + if v.IsNil() { + fmt.Printf("Error: Cannot get field %s from nil interface\n", fieldName) + return nil + } + v = v.Elem() + } + + // Ensure we have a struct + if v.Kind() != reflect.Struct { + fmt.Printf("Error: %v (type %T) is not a struct, cannot get field %s\n", obj, obj, fieldName) + return nil + } + + // Try different field name variations + fieldNames := []string{ + fieldName, // Original name + strings.Title(fieldName), // Title case (first letter uppercase) + strings.ToLower(fieldName), // All lowercase + strings.ToUpper(fieldName), // All uppercase + } + + for _, fname := range fieldNames { + field := v.FieldByName(fname) + if field.IsValid() { + return field.Interface() + } + } + + // If no field found, list available fields for debugging + t := v.Type() + var availableFields []string + for i := 0; i < t.NumField(); i++ { + availableFields = append(availableFields, t.Field(i).Name) + } + + fmt.Printf("Error: Field %s not found in struct. Available fields: %v\n", fieldName, availableFields) + return nil +} + +// GetValue is the most flexible function that can work with structs, maps, and interfaces +// It handles multiple data types and field name variations +func GetValue(obj interface{}, key string) interface{} { + if obj == nil { + fmt.Printf("Error: Cannot get key %s from nil object\n", key) + return nil + } + + v := reflect.ValueOf(obj) + + // Handle pointers + if v.Kind() == reflect.Ptr { + if v.IsNil() { + fmt.Printf("Error: Cannot get key %s from nil pointer\n", key) + return nil + } + v = v.Elem() + } + + // Handle interfaces + if v.Kind() == reflect.Interface { + if v.IsNil() { + fmt.Printf("Error: Cannot get key %s from nil interface\n", key) + return nil + } + v = v.Elem() + } + + switch v.Kind() { + case reflect.Struct: + // Use GetFieldValue for structs + return GetFieldValue(v.Interface(), key) + + case reflect.Map: + // Handle maps + keyVariations := []reflect.Value{ + reflect.ValueOf(key), + reflect.ValueOf(strings.Title(key)), + reflect.ValueOf(strings.ToLower(key)), + reflect.ValueOf(strings.ToUpper(key)), + } + + for _, keyVar := range keyVariations { + if keyVar.Type() == v.Type().Key() { + mapValue := v.MapIndex(keyVar) + if mapValue.IsValid() { + return mapValue.Interface() + } + } + } + + // List available keys for debugging + var availableKeys []string + for _, k := range v.MapKeys() { + availableKeys = append(availableKeys, fmt.Sprintf("%v", k.Interface())) + } + fmt.Printf("Error: Key %s not found in map. Available keys: %v\n", key, availableKeys) + return nil + + default: + fmt.Printf("Error: %v (type %T) is not a struct or map, cannot get key %s\n", obj, obj, key) + return nil + } +} + +// GetResource safely retrieves a resource and returns it as IResource +// This allows calling methods on the resource without type assertion errors +func GetResource(obj interface{}, resourceName string) interface{} { + resource := GetValue(obj, resourceName) + if resource == nil { + return nil + } + + // The resource should already be the correct interface type + // since it comes from our VERSIONS structure + return resource +} + +// AsResourceInterface safely converts an interface{} to a resource interface +// This allows calling resource methods on values retrieved as interface{} +func AsResourceInterface(obj interface{}) ResourceInterface { + if obj == nil { + return nil + } + + // Try to assert to a resource interface + if resource, ok := obj.(ResourceInterface); ok { + return resource + } + + // If direct assertion fails, create a wrapper using reflection + return &ReflectionResourceWrapper{obj: obj} +} + +// ResourceInterface defines the common methods that all resources should have +type ResourceInterface interface { + Get(string, map[string]string) (interface{}, error) + List(map[string]string) (interface{}, error) + GetBySpec(interface{}) (interface{}, error) + Create(interface{}) (interface{}, error) + Remove(string) error + RemoveBySpec(interface{}) error + Update(interface{}) (interface{}, error) + Template() []byte +} + +// ReflectionResourceWrapper wraps any object and provides resource methods via reflection +type ReflectionResourceWrapper struct { + obj interface{} +} + +func (w *ReflectionResourceWrapper) Get(pk string, params map[string]string) (interface{}, error) { + return CallResourceMethod(w.obj, "Get", pk, params) +} + +func (w *ReflectionResourceWrapper) List(params map[string]string) (interface{}, error) { + return CallResourceMethod(w.obj, "List", params) +} + +func (w *ReflectionResourceWrapper) GetBySpec(spec interface{}) (interface{}, error) { + return CallResourceMethod(w.obj, "GetBySpec", spec) +} + +func (w *ReflectionResourceWrapper) Create(spec interface{}) (interface{}, error) { + return CallResourceMethod(w.obj, "Create", spec) +} + +func (w *ReflectionResourceWrapper) Remove(pk string) error { + result, err := CallResourceMethod(w.obj, "Remove", pk) + if err != nil { + return err + } + if result != nil { + if resultErr, ok := result.(error); ok { + return resultErr + } + } + return nil +} + +func (w *ReflectionResourceWrapper) RemoveBySpec(spec interface{}) error { + result, err := CallResourceMethod(w.obj, "RemoveBySpec", spec) + if err != nil { + return err + } + if result != nil { + if resultErr, ok := result.(error); ok { + return resultErr + } + } + return nil +} + +func (w *ReflectionResourceWrapper) Update(spec interface{}) (interface{}, error) { + return CallResourceMethod(w.obj, "Update", spec) +} + +func (w *ReflectionResourceWrapper) Template() []byte { + result, err := CallResourceMethod(w.obj, "Template") + if err != nil { + return nil + } + if bytes, ok := result.([]byte); ok { + return bytes + } + return nil +} + +// CallResourceMethod safely calls a method on a resource interface +// This provides a type-safe way to call methods on resources retrieved as interface{} +func CallResourceMethod(resource interface{}, methodName string, args ...interface{}) (interface{}, error) { + if resource == nil { + return nil, fmt.Errorf("cannot call %s on nil resource", methodName) + } + + v := reflect.ValueOf(resource) + method := v.MethodByName(methodName) + + if !method.IsValid() { + return nil, fmt.Errorf("method %s not found on resource type %T", methodName, resource) + } + + // Convert arguments to reflect.Value + var reflectArgs []reflect.Value + for _, arg := range args { + reflectArgs = append(reflectArgs, reflect.ValueOf(arg)) + } + + // Call the method + results := method.Call(reflectArgs) + + // Handle different return patterns + switch len(results) { + case 0: + return nil, nil + case 1: + // Single return value (could be error or result) + result := results[0].Interface() + if err, ok := result.(error); ok { + return nil, err + } + return result, nil + case 2: + // Two return values (result, error) + result := results[0].Interface() + if results[1].Interface() != nil { + err := results[1].Interface().(error) + return result, err + } + return result, nil + default: + return results, nil + } +} diff --git a/core/resource/remote.go b/core/resource/remote.go new file mode 100644 index 0000000..d838fe9 --- /dev/null +++ b/core/resource/remote.go @@ -0,0 +1,134 @@ +package resource + +import ( + JSON "encoding/json" + "errors" + "fmt" + "log" + "mzm/core" + "mzm/core/logging" + "os" + "os/exec" + "runtime" + "strings" + + yamlDecoder "github.com/elioetibr/golang-yaml/pkg/decoder" + yamlEncoder "github.com/elioetibr/golang-yaml/pkg/encoder" +) + +func FromString(content []byte, format core.InputFormatEnum, file_name string) (string, error) { + logger := logging.Default.Child("mzm/core/remote") + var transformed []byte = []byte(content) + + if file_name == "" { + file_name = "from-string" + } + + if format == core.FORMAT.CRUD.JSON { + // Parse and re-format JSON for validation and pretty-printing + var jsonData map[string]any + err := JSON.Unmarshal(content, &jsonData) + if err != nil { + return "", fmt.Errorf("invalid JSON format: %w", err) + } + + transformed, err = JSON.MarshalIndent(jsonData, "", " ") + if err != nil { + return "", err + } + } + + dirName, err := os.MkdirTemp("", "mzm-staging") + if err != nil { + log.Fatal(err) + } + + logger.Debug("Temp dir created %s", dirName) + + file, err := os.CreateTemp( + dirName, + strings.Join([]string{file_name, "*", format.String()}, "."), + ) + + fmt.Println(file.Name()) + defer os.Remove(file.Name()) + + logger.Debug("Temp file created %s", file.Name()) + file.Write(transformed) + file.Close() + + cmd := exec.Command(getEditorCommand(), file.Name()) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + err = cmd.Run() + + if err != nil { + return "", errors.New("unable to save resource file") + } + output, err := os.ReadFile(file.Name()) + return string(output), err +} + +func getEditorCommand() string { + var defaultEditor string = "vi" + if runtime.GOOS == "windows" { + defaultEditor = "notepad.exe" + } + + editor := os.Getenv("EDITOR") + + if editor == "" { + return defaultEditor + } + + return editor +} + +// Convert a struct to a json string +func Stringify(content any, format core.InputFormatEnum) ([]byte, error) { + switch format { + case core.FORMAT.CRUD.JSON: + return JSON.MarshalIndent(content, "", " ") + case core.FORMAT.CRUD.YAML: + return yamlEncoder.Marshal(content) + default: + return []byte{}, fmt.Errorf("unknown stringify format %s", string(format)) + } +} + +// Generic Parse function - eliminates type casting by parsing directly to strongly typed spec +func Parse[T any](str string) (*IResourceTemplate[T], error) { + var definition IResourceTemplate[T] + var err error + + // Try parsing as YAML first + err = yamlDecoder.Unmarshal([]byte(str), &definition) + if err == nil { + return &definition, nil + } + + // If YAML parsing fails, try JSON + err = JSON.Unmarshal([]byte(str), &definition) + if err != nil { + return nil, err + } + + return &definition, nil +} + +// ParseAndValidate parses and validates a template with a spec that implements Validator +func ParseAndValidate[T interface{ Validate() error }](str string) (*IResourceTemplate[T], error) { + template, err := Parse[T](str) + if err != nil { + return nil, err + } + + // Validate the spec if it implements Validator + if err := template.Spec.Validate(); err != nil { + return nil, fmt.Errorf("template validation failed: %w", err) + } + + return template, nil +} diff --git a/core/resource/types.go b/core/resource/types.go new file mode 100644 index 0000000..49513da --- /dev/null +++ b/core/resource/types.go @@ -0,0 +1,30 @@ +package resource + +// Generic IResourceTemplate - eliminates type casting by making Spec strongly typed +type IResourceTemplate[T any] struct { + Version string `json:"version" yaml:"version"` // v1 | v2 | v3 + Resource string `json:"resource" yaml:"resource"` + Metadata map[string]string `json:"metadata" yaml:"metadata,flow"` + Spec T `json:"spec" yaml:"spec"` +} + +// IResourceBase is the non-generic base interface for registry storage +// This allows different resource types to be stored in the same registry +type IResourceBase interface { + GetTemplate() []byte +} + +// IResource is a common generic interface that all resources implement +// Resource represents the concrete resource type (e.g., View, Category, etc.) +// Spec represents the spec type for templates (e.g., ViewSpec, CategorySpec, etc.) +// This provides type safety while maintaining interface flexibility +type IResource[Resource any, Spec any] interface { + IResourceBase // Embed the base interface + Get(string, map[string]string) (*Resource, error) + List(map[string]string) ([]Resource, error) + GetBySpec(*Resource) (*Resource, error) + Create(IResourceTemplate[Spec]) (*Resource, error) // Now strongly typed! + Remove(string) error + RemoveBySpec(*Resource) error + Update(IResourceTemplate[Spec]) (*Resource, error) // Now strongly typed! +} diff --git a/core/resource/v1/mod.go b/core/resource/v1/mod.go new file mode 100644 index 0000000..c94c232 --- /dev/null +++ b/core/resource/v1/mod.go @@ -0,0 +1,11 @@ +package v1 + +import ( + "mzm/core/resource/v1/view" +) + +var Resources = struct { + view *view.ViewResource +}{ + view: &view.ViewResource{}, +} diff --git a/core/resource/v1/types.go b/core/resource/v1/types.go new file mode 100644 index 0000000..9ed5145 --- /dev/null +++ b/core/resource/v1/types.go @@ -0,0 +1,13 @@ +package v1 + +type JoiDetail struct { + Message string `json:"message"` + Key string `json:"key"` +} + +type JoiResponse struct { + Details []JoiDetail `json:"details"` + Error string `json:"error"` + Code string `json:"code"` + Status string `json:"status"` +} diff --git a/core/resource/v1/view/mod.go b/core/resource/v1/view/mod.go new file mode 100644 index 0000000..4644ac8 --- /dev/null +++ b/core/resource/v1/view/mod.go @@ -0,0 +1,230 @@ +package view + +import ( + _ "embed" + "errors" + "fmt" + "mzm/core/resource" + "strings" + + resty "resty.dev/v3" +) + +//go:embed template.yaml +var viewTemplate []byte + +// ViewResource implements IResource[View] for type-safe view operations +type ViewResource struct { + client *resty.Client +} + +// Ensure ViewResource implements IResource[View, View] with new generic interface +var _ resource.IResource[View, View] = (*ViewResource)(nil) + +// NewViewResource creates a new ViewResource instance +func NewViewResource() *ViewResource { + return &ViewResource{ + client: resource.Client(), + } +} + +// ViewResource methods implementing IResource[View] + +func (r *ViewResource) Get(pk string, params map[string]string) (*View, error) { + response := View{} + res, err := r.client. + R(). + SetResult(response). + SetPathParam("pk", pk). + Get("/v1/config/view/{pk}") + + if err != nil { + return nil, err + } + + switch res.StatusCode() { + case 200: + return res.Result().(*View), nil + case 401: + fmt.Println("Unauthorized") + case 403: + fmt.Println("Forbidden") + case 404: + break + default: + return nil, errors.New("unexpected error") + } + + views, err := r.List(params) + + if err != nil { + return nil, err + } + + for _, instance := range views { + if instance.PK() == pk { + return &instance, nil + } + + if strings.EqualFold(string(instance.Name), pk) { + return &instance, nil + } + } + return nil, nil +} + +func (r *ViewResource) List(params map[string]string) ([]View, error) { + var response []View + + res, err := r.client. + R(). + SetResult(response). + Get("/v1/config/view") + + if err != nil { + return nil, err + } + + result := res.Result().(*[]View) + if result == nil { + return []View{}, nil + } + return *result, nil +} + +func (r *ViewResource) GetBySpec(spec *View) (*View, error) { + return nil, errors.New("GetBySpec() Not Implemented") +} + +func (r *ViewResource) Create(template resource.IResourceTemplate[View]) (*View, error) { + // Create view from strongly typed template - NO TYPE CASTING! + view, err := ViewFromTemplate(&template) + if err != nil { + return nil, fmt.Errorf("failed to create view from template: %w", err) + } + + fmt.Println(template) + apiView := view.ToCreate() + + // Use the original view object for the API response - Resty will populate it directly + res, err := r.client. + R(). + SetResult(view). + SetBody(apiView). + Post("/v1/config/view") + + if err != nil { + return nil, err + } + + switch res.StatusCode() { + case 200, 201: + // The view object is already populated by Resty's SetResult + return view, nil + case 400: + fmt.Printf("Bad request - API response:\n%s\n", res.String()) + return nil, errors.New("bad request: check your view specification") + case 401: + return nil, errors.New("unauthorized: check your access key") + case 403: + return nil, errors.New("forbidden: insufficient permissions to create views") + default: + return nil, fmt.Errorf("unexpected error: status %d", res.StatusCode()) + } +} + +func (r *ViewResource) Remove(pk string) error { + res, err := r.client. + R(). + SetPathParam("pk", pk). + Delete("/v1/config/view/{pk}") + + if err != nil { + return err + } + + switch res.StatusCode() { + case 200, 201, 204: + return nil + case 404: + // If initial delete returns 404, try to find view by name and delete it + return r.removeByName(pk) + case 400: + fmt.Printf("Bad request - API response:\n%s\n", res.String()) + return errors.New("bad request: check your view specification") + case 401: + return errors.New( + "There was a problem authenticating the previous operation. Make sure your access key is still valid", + ) + case 403: + return errors.New( + "Make sure you have the appropriate permissions to read views in the appropriate account", + ) + default: + return fmt.Errorf("unexpected error: status %d", res.StatusCode()) + } +} + +func (r *ViewResource) RemoveBySpec(view *View) error { + return errors.New("RemoveBySpec() Not Implemented") +} + +// removeByName attempts to find a view by name (case-insensitive) and delete it by ID +func (r *ViewResource) removeByName(name string) error { + // Get all views + views, err := r.List(nil) + if err != nil { + return fmt.Errorf("failed to list views while searching by name: %w", err) + } + + // Search for a view with matching name (case-insensitive) + for _, view := range views { + if strings.EqualFold(view.Name, name) { + // Found a match, delete using the view's ID + res, err := r.client. + R(). + SetPathParam("pk", view.Viewid). + Delete("/v1/config/view/{pk}") + + if err != nil { + return fmt.Errorf("failed to delete view by ID %s: %w", view.Viewid, err) + } + + switch res.StatusCode() { + case 200, 201, 204: + return nil + case 404: + return fmt.Errorf("view with name '%s' (ID: %s) not found", name, view.Viewid) + case 400: + fmt.Printf("Bad request - API response:\n%s\n", res.String()) + return errors.New("bad request: check your view specification") + case 401: + return errors.New( + "There was a problem authenticating the previous operation. Make sure your access key is still valid", + ) + case 403: + return errors.New( + "Make sure you have the appropriate permissions to delete views in the appropriate account", + ) + default: + return fmt.Errorf("unexpected error: status %d", res.StatusCode()) + } + } + } + + // No view found with the given name + return fmt.Errorf("view with name '%s' not found", name) +} + +func (r *ViewResource) Update(template resource.IResourceTemplate[View]) (*View, error) { + return nil, errors.New("Update() Not Implemented") +} + +func (r *ViewResource) GetTemplate() []byte { + return viewTemplate +} + +// Register this resource with the main resource package +func init() { + resource.Register("v1", "view", NewViewResource()) +} diff --git a/core/resource/v1/view/spec.go b/core/resource/v1/view/spec.go new file mode 100644 index 0000000..ef1189a --- /dev/null +++ b/core/resource/v1/view/spec.go @@ -0,0 +1 @@ +package view diff --git a/core/resource/v1/view/template.yaml b/core/resource/v1/view/template.yaml new file mode 100644 index 0000000..3b1955e --- /dev/null +++ b/core/resource/v1/view/template.yaml @@ -0,0 +1,28 @@ +--- +version: v1 +resource: view +metadata: {} +spec: + # START required fields + name: string # name of the view - My New View + # END required fields + + # One of query, hosts, apps, level or tags is required + query: string # search query to apply to log lines - (foobar AND widgets) OR namespace:secret + + hosts: + - string # additional hosts to filter the results by, in addtion to `query` + apps: + - string # additional applications to filter the results by, in addtion to `query` + levels: + - string # additional log levels to filter the results by, in addtion to `query` + tags: + - string # additional tags to filter the results by, in addtion to `query` + + presetid: string # identifiers of an existing preset alert + + # Use mzm create, get and edit to manage categories + category: + - string # Name of categries to to group the view in. These must already exist + + channels: [] # alert configurations diff --git a/core/resource/v1/view/types.go b/core/resource/v1/view/types.go new file mode 100644 index 0000000..12cdb63 --- /dev/null +++ b/core/resource/v1/view/types.go @@ -0,0 +1,206 @@ +package view + +import ( + "fmt" + "golang.org/x/text/cases" + "golang.org/x/text/language" + + JSON "encoding/json" + yamlDecoder "github.com/elioetibr/golang-yaml/pkg/decoder" + yamlEncoder "github.com/elioetibr/golang-yaml/pkg/encoder" + "mzm/core/resource" +) + +// Validator interface for template validation +type Validator interface { + Validate() error +} + +type View struct { + // API-only fields (not shown in templates) + Account string `json:"account,omitempty" yaml:"account,omitempty,flow" template:"-"` + Viewid string `json:"viewid,omitempty" yaml:"viewid,omitempty,flow" template:"-"` + Description string `json:"description,omitempty" yaml:"description,omitempty,flow" template:"-"` + Orgs []any `json:"orgs,omitempty" yaml:"orgs,omitempty,flow" template:"-"` + + // Template/User-editable fields + Name string `json:"name" yaml:"name" validate:"required"` + Query string `json:"query,omitempty" yaml:"query,omitempty"` + Category []string `json:"category" yaml:"category,flow"` + Hosts []string `json:"hosts,omitempty" yaml:"hosts,omitempty,flow"` + Apps []string `json:"apps,omitempty" yaml:"apps,omitempty,flow"` + Tags []string `json:"tags,omitempty" yaml:"tags,omitempty,flow"` + Levels []string `json:"levels,omitempty" yaml:"levels,omitempty,flow"` + + // Preset handling - API uses both formats + Presetids []string `json:"presetids,omitempty" yaml:"-" template:"-"` + Presetid []string `json:"presetid,omitempty" yaml:"-" template:"-"` + PresetID string `json:"-" yaml:"presetid,omitempty"` // Template format (single string) + + Channels []map[string]interface{} `json:"channels,omitempty" yaml:"channels,omitempty,flow"` +} + +func (view *View) PK() string { + return view.Viewid +} + +func (view *View) GetCategory() string { + if len(view.Category) == 0 { + return "Uncategorized" + } + + result := cases.Title(language.English) + return result.String(view.Category[0]) +} + +// Validate ensures the View has required fields and valid combinations for template usage +func (v View) Validate() error { + if v.Name == "" { + return fmt.Errorf("name is required") + } + + // At least one filter criteria must be specified + if v.Query == "" && len(v.Hosts) == 0 && len(v.Apps) == 0 && + len(v.Levels) == 0 && len(v.Tags) == 0 { + return fmt.Errorf("at least one of query, hosts, apps, levels, or tags must be specified") + } + + return nil +} + +// ToYAML converts the View to YAML bytes (template format - user-editable fields only) +func (v *View) ToYAML() ([]byte, error) { + // Create a copy with only template-relevant fields + templateView := struct { + Name string `yaml:"name"` + Query string `yaml:"query,omitempty"` + Category []string `yaml:"category"` + Hosts []string `yaml:"hosts,omitempty"` + Apps []string `yaml:"apps,omitempty"` + Tags []string `yaml:"tags,omitempty"` + Levels []string `yaml:"levels,omitempty"` + PresetID string `yaml:"presetid,omitempty"` + Channels []map[string]interface{} `yaml:"channels,omitempty"` + }{ + Name: v.Name, + Query: v.Query, + Category: v.Category, + Hosts: v.Hosts, + Apps: v.Apps, + Tags: v.Tags, + Levels: v.Levels, + PresetID: v.PresetID, + Channels: v.Channels, + } + + // If PresetID is empty but we have presetid/presetids from API, use the first one + if templateView.PresetID == "" { + if len(v.Presetid) > 0 { + templateView.PresetID = v.Presetid[0] + } else if len(v.Presetids) > 0 { + templateView.PresetID = v.Presetids[0] + } + } + + return yamlEncoder.Marshal(templateView) +} + +// ToTemplateYAML converts the View to the full template format +func (v *View) ToTemplateYAML() ([]byte, error) { + // Get the spec YAML first + specBytes, err := v.ToYAML() + if err != nil { + return nil, err + } + + // Parse it back to get the spec structure for embedding + var spec interface{} + err = yamlDecoder.Unmarshal(specBytes, &spec) + if err != nil { + return nil, err + } + + template := struct { + Version string `yaml:"version"` + Resource string `yaml:"resource"` + Metadata map[string]string `yaml:"metadata"` + Spec interface{} `yaml:"spec"` + }{ + Version: "v1", + Resource: "view", + Metadata: map[string]string{}, + Spec: spec, + } + return yamlEncoder.Marshal(template) +} + +// ToJSON converts the View to JSON bytes (API format - all fields) +func (v *View) ToJSON() ([]byte, error) { + return JSON.Marshal(v) +} + +// ToCreate returns the View in the format needed for API creation calls +func (v *View) ToCreate() *View { + return v.ToUpdate() +} + +// ToUpdate returns the View in the format needed for API update calls +func (v *View) ToUpdate() *View { + apiView := &View{ + Name: v.Name, + Query: v.Query, + Category: v.Category, + Hosts: v.Hosts, + Apps: v.Apps, + Tags: v.Tags, + Levels: v.Levels, + Channels: v.Channels, + } + + // Ensure Category is never nil for API calls - API requires empty array, not null + if apiView.Category == nil { + apiView.Category = []string{} + } + + // Convert PresetID to the API format + if v.PresetID != "" { + apiView.Presetid = []string{v.PresetID} + apiView.Presetids = []string{v.PresetID} + } + + return apiView +} + +// ViewFromTemplate creates a View from a strongly typed resource template - NO TYPE CASTING! +func ViewFromTemplate(template *resource.IResourceTemplate[View]) (*View, error) { + // Direct field access - no casting needed! + // The template.Spec is already a View, so we create a copy for API usage + view := &View{ + Name: template.Spec.Name, + Query: template.Spec.Query, + Category: template.Spec.Category, + Hosts: template.Spec.Hosts, + Apps: template.Spec.Apps, + Tags: template.Spec.Tags, + Levels: template.Spec.Levels, + PresetID: template.Spec.PresetID, + Channels: template.Spec.Channels, + } + + // Ensure Category is never nil - API requires empty array, not null + if view.Category == nil { + view.Category = []string{} + } + + return view, view.Validate() +} + +// Convenience function for parsing and creating View from template content +func CreateViewFromTemplate(content string) (*View, error) { + template, err := resource.ParseAndValidate[View](content) + if err != nil { + return nil, err + } + + return ViewFromTemplate(template) +} diff --git a/core/resource/versions.go b/core/resource/versions.go new file mode 100644 index 0000000..0b07981 --- /dev/null +++ b/core/resource/versions.go @@ -0,0 +1,111 @@ +package resource + +import ( + "errors" + "sort" + "strings" +) + +// ResourceRegistry is a map-based registry that holds registered resources +// Keys are in the format "version:resourceType" (e.g., "v1:view", "v2:alert") +// Uses IResourceBase to ensure type safety while allowing different resource types +type ResourceRegistry map[string]IResourceBase + +// Register adds a resource to the registry +func (r ResourceRegistry) Register(version, resourceType string, resource IResourceBase) { + key := version + ":" + resourceType + r[key] = resource +} + +// GetResource returns a resource by version and type +// The caller must type assert to the specific IResource[T] they need +func (r ResourceRegistry) GetResource(version, resourceType string) (interface{}, error) { + key := version + ":" + resourceType + if resource, ok := r[key]; ok { + return resource, nil + } + return nil, errors.New("resource not found: " + key) +} + +// ListVersions returns all available API versions +func (r ResourceRegistry) ListVersions() []string { + versionSet := make(map[string]bool) + for key := range r { + parts := strings.Split(key, ":") + if len(parts) == 2 { + versionSet[parts[0]] = true + } + } + + var versions []string + for version := range versionSet { + versions = append(versions, version) + } + sort.Strings(versions) + return versions +} + +// ListResourceTypes returns all resource types available in a specific version +func (r ResourceRegistry) ListResourceTypes(version string) []string { + var resourceTypes []string + prefix := version + ":" + for key := range r { + if strings.HasPrefix(key, prefix) { + resourceType := strings.TrimPrefix(key, prefix) + resourceTypes = append(resourceTypes, resourceType) + } + } + sort.Strings(resourceTypes) + return resourceTypes +} + +// ListAllResourceTypes returns all resource types across all versions +func (r ResourceRegistry) ListAllResourceTypes() []string { + resourceTypeSet := make(map[string]bool) + for key := range r { + parts := strings.Split(key, ":") + if len(parts) == 2 { + resourceTypeSet[parts[1]] = true + } + } + + var resourceTypes []string + for resourceType := range resourceTypeSet { + resourceTypes = append(resourceTypes, resourceType) + } + sort.Strings(resourceTypes) + return resourceTypes +} + +// HasResource checks if a specific resource exists +func (r ResourceRegistry) HasResource(version, resourceType string) bool { + key := version + ":" + resourceType + _, exists := r[key] + return exists +} + +// GetLatestVersion returns the latest version that supports the given resource type +func (r ResourceRegistry) GetLatestVersion(resourceType string) (string, error) { + var versions []string + for key := range r { + parts := strings.Split(key, ":") + if len(parts) == 2 && parts[1] == resourceType { + versions = append(versions, parts[0]) + } + } + + if len(versions) == 0 { + return "", errors.New("resource type not found: " + resourceType) + } + + sort.Strings(versions) + return versions[len(versions)-1], nil +} + +// Global registry instance +var Registry = make(ResourceRegistry) + +// Register is a convenience function to register resources in the global registry +func Register(version, resourceType string, resource IResourceBase) { + Registry.Register(version, resourceType, resource) +} diff --git a/go.mod b/go.mod index 8857ed0..3d13158 100644 --- a/go.mod +++ b/go.mod @@ -4,25 +4,36 @@ go 1.25.1 require ( github.com/a-h/sqlitekv v0.0.0-20250411170825-1020591797e9 + github.com/elioetibr/golang-yaml v0.1.3 github.com/gorilla/websocket v1.5.3 + github.com/olekukonko/tablewriter v1.1.3 github.com/phoenix-tui/phoenix/layout v0.2.0 github.com/phoenix-tui/phoenix/style v0.2.0 github.com/rs/zerolog v1.34.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 + golang.org/x/text v0.33.0 resty.dev/v3 v3.0.0-beta.6 zombiezen.com/go/sqlite v1.4.2 ) require ( + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect 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.19 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect + github.com/olekukonko/errors v1.2.0 // indirect + github.com/olekukonko/ll v0.1.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/phoenix-tui/phoenix/core v0.2.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect @@ -38,7 +49,6 @@ require ( golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect modernc.org/libc v1.67.7 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index dcd292a..b3f2645 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,21 @@ github.com/a-h/sqlitekv v0.0.0-20250411170825-1020591797e9 h1:ljG7pqhxGHVeGSEnd37ANDPT6f9Tz6avOh9pQyTGjWA= github.com/a-h/sqlitekv v0.0.0-20250411170825-1020591797e9/go.mod h1:Wg4Z7J9JtnoyAZ0lUBi1Qb6441kf6yxWjCbkuQrhqQo= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= 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/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/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/elioetibr/golang-yaml v0.1.3 h1:7F4XQUnqld5JBi0LAeuH3G+CYk2zZQE+Y0wGxFn+HUI= +github.com/elioetibr/golang-yaml v0.1.3/go.mod h1:8QgcXRXuN9iXrrvVlZQEfVsEu03RYEPsU8O6UMU94ig= +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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -34,8 +44,18 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +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.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= +github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.1.4 h1:QcDaO9quz213xqHZr0gElOcYeOSnFeq7HTQ9Wu4O1wE= +github.com/olekukonko/ll v0.1.4/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= +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/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/phoenix-tui/phoenix/core v0.2.0 h1:LIZWe8stROSDKWOEjKs+Prqz/8OAzUMoQGB5QO05JL0=