diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml index 43c925f..eda5cb7 100644 --- a/.github/workflows/pr-lint.yml +++ b/.github/workflows/pr-lint.yml @@ -27,5 +27,5 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v6 with: - version: v1.60 + version: v1.64 only-new-issues: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0aec209..c3f0380 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ go.work.sum # env file .env dist + +# openfeature cli config +.openfeature.yaml \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8b19c1c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,96 @@ +TODO: Add contributing guidelines + +## Templates + +### Data + +The `TemplateData` struct is used to pass data to the templates. + +### Built-in template functions + +The following functions are automatically included in the templates: + +#### ToPascal + +Converts a string to `PascalCase` + +```go +{{ "hello world" | ToPascal }} // HelloWorld +``` + +#### ToCamel + +Converts a string to `camelCase` + +```go +{{ "hello world" | ToCamel }} // helloWorld +``` + +#### ToKebab + +Converts a string to `kebab-case` + +```go +{{ "hello world" | ToKebab }} // hello-world +``` + +#### ToSnake + +Converts a string to `snake_case` + +```go +{{ "hello world" | ToSnake }} // hello_world +``` + +#### ToScreamingSnake + +Converts a string to `SCREAMING_SNAKE_CASE` + +```go +{{ "hello world" | ToScreamingSnake }} // HELLO_WORLD +``` + +#### ToUpper + +Converts a string to `UPPER CASE` + +```go +{{ "hello world" | ToUpper }} // HELLO WORLD +``` + +#### ToLower + +Converts a string to `lower case` + +```go +{{ "HELLO WORLD" | ToLower }} // hello world +``` + +#### ToTitle + +Converts a string to `Title Case` + +```go +{{ "hello world" | ToTitle }} // Hello World +``` + +#### Quote + +Wraps a string in double quotes + +```go +{{ "hello world" | Quote }} // "hello world" +``` + +#### QuoteString + +Wraps only strings in double quotes + +```go +{{ "hello world" | QuoteString }} // "hello world" +{{ 123 | QuoteString }} // 123 +``` + +### Custom template functions + +You can add custom template functions by passing a `FuncMap` to the `GenerateFile` function. diff --git a/Makefile b/Makefile index e8c8563..010c97e 100644 --- a/Makefile +++ b/Makefile @@ -2,10 +2,15 @@ .PHONY: test test: @echo "Running tests..." - go test -v ./... + @go test -v ./... @echo "Tests passed successfully!" generate-docs: @echo "Generating documentation..." - go run ./docs/generate-commands.go - @echo "Documentation generated successfully!" \ No newline at end of file + @go run ./docs/generate-commands.go + @echo "Documentation generated successfully!" + +generate-schema: + @echo "Generating schema..." + @go run ./schema/generate-schema.go + @echo "Schema generated successfully!" \ No newline at end of file diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..ba635bd --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,71 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +// initializeConfig reads in config file and ENV variables if set. +// It applies configuration values to command flags based on hierarchical priority. +func initializeConfig(cmd *cobra.Command, bindPrefix string) error { + v := viper.New() + + // Set the config file name and path + v.SetConfigName(".openfeature") + v.AddConfigPath(".") + + // Read the config file + if err := v.ReadInConfig(); err != nil { + // It's okay if there isn't a config file + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return err + } + } + + // Track which flags were set directly via command line + cmdLineFlags := make(map[string]bool) + cmd.Flags().Visit(func(f *pflag.Flag) { + cmdLineFlags[f.Name] = true + }) + + // Apply the configuration values + cmd.Flags().VisitAll(func(f *pflag.Flag) { + // Skip if flag was set on command line + if cmdLineFlags[f.Name] { + return + } + + // Build configuration paths from most specific to least specific + configPaths := []string{} + + // Check the most specific path (e.g., generate.go.package-name) + if bindPrefix != "" { + configPaths = append(configPaths, bindPrefix + "." + f.Name) + + // Check parent paths (e.g., generate.package-name) + parts := strings.Split(bindPrefix, ".") + for i := len(parts) - 1; i > 0; i-- { + parentPath := strings.Join(parts[:i], ".") + "." + f.Name + configPaths = append(configPaths, parentPath) + } + } + + // Check the base path (e.g., package-name) + configPaths = append(configPaths, f.Name) + + // Try each path in order until we find a match + for _, path := range configPaths { + if v.IsSet(path) { + val := v.Get(path) + _ = f.Value.Set(fmt.Sprintf("%v", val)) + break + } + } + }) + + return nil +} diff --git a/cmd/config_test.go b/cmd/config_test.go new file mode 100644 index 0000000..6920a0a --- /dev/null +++ b/cmd/config_test.go @@ -0,0 +1,172 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func setupTestCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "test", + } + + // Add some test flags + cmd.Flags().String("output", "", "output path") + cmd.Flags().String("package-name", "default", "package name") + + return cmd +} + +// setupConfigFileForTest creates a temporary directory with a config file +// and changes the working directory to it. +// Returns the original working directory and temp directory path for cleanup. +func setupConfigFileForTest(t *testing.T, configContent string) (string, string) { + // Create a temporary config file + tmpDir, err := os.MkdirTemp("", "config-test") + if err != nil { + t.Fatal(err) + } + + configPath := filepath.Join(tmpDir, ".openfeature.yaml") + err = os.WriteFile(configPath, []byte(configContent), 0644) + if err != nil { + t.Fatal(err) + } + + // Change to the temporary directory so the config file can be found + originalDir, _ := os.Getwd() + err = os.Chdir(tmpDir) + if err != nil { + t.Fatal(err) + } + + return originalDir, tmpDir +} + +func TestRootCommandIgnoresUnrelatedConfig(t *testing.T) { + configContent := ` +generate: + output: output-from-generate +` + originalDir, tmpDir := setupConfigFileForTest(t, configContent) + defer func() { + _ = os.Chdir(originalDir) + _ = os.RemoveAll(tmpDir) + }() + + rootCmd := setupTestCommand() + err := initializeConfig(rootCmd, "") + + assert.NoError(t, err) + assert.Equal(t, "", rootCmd.Flag("output").Value.String(), + "Root command should not get output config from unrelated sections") +} + +func TestGenerateCommandGetsGenerateConfig(t *testing.T) { + configContent := ` +generate: + output: output-from-generate +` + originalDir, tmpDir := setupConfigFileForTest(t, configContent) + defer func() { + _ = os.Chdir(originalDir) + _ = os.RemoveAll(tmpDir) + }() + + generateCmd := setupTestCommand() + err := initializeConfig(generateCmd, "generate") + + assert.NoError(t, err) + assert.Equal(t, "output-from-generate", generateCmd.Flag("output").Value.String(), + "Generate command should get generate.output value") +} + +func TestSubcommandGetsSpecificConfig(t *testing.T) { + configContent := ` +generate: + output: output-from-generate + go: + output: output-from-go + package-name: fromconfig +` + originalDir, tmpDir := setupConfigFileForTest(t, configContent) + defer func() { + _ = os.Chdir(originalDir) + _ = os.RemoveAll(tmpDir) + }() + + goCmd := setupTestCommand() + err := initializeConfig(goCmd, "generate.go") + + assert.NoError(t, err) + assert.Equal(t, "output-from-go", goCmd.Flag("output").Value.String(), + "Go command should get generate.go.output, not generate.output") + assert.Equal(t, "fromconfig", goCmd.Flag("package-name").Value.String(), + "Go command should get generate.go.package-name") +} + +func TestSubcommandInheritsFromParent(t *testing.T) { + configContent := ` +generate: + output: output-from-generate +` + originalDir, tmpDir := setupConfigFileForTest(t, configContent) + defer func() { + _ = os.Chdir(originalDir) + _ = os.RemoveAll(tmpDir) + }() + + otherCmd := setupTestCommand() + err := initializeConfig(otherCmd, "generate.other") + + assert.NoError(t, err) + assert.Equal(t, "output-from-generate", otherCmd.Flag("output").Value.String(), + "Other command should inherit generate.output when no specific config exists") +} + +func TestCommandLineOverridesConfig(t *testing.T) { + // Create a temporary config file + tmpDir, err := os.MkdirTemp("", "config-test") + if err != nil { + t.Fatal(err) + } + defer func() { + _ = os.RemoveAll(tmpDir) + }() + + configPath := filepath.Join(tmpDir, ".openfeature.yaml") + configContent := ` +generate: + output: output-from-config +` + err = os.WriteFile(configPath, []byte(configContent), 0644) + if err != nil { + t.Fatal(err) + } + + // Change to the temporary directory so the config file can be found + originalDir, _ := os.Getwd() + err = os.Chdir(tmpDir) + if err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(originalDir) + }() + + // Set up a command with a flag value already set via command line + cmd := setupTestCommand() + _ = cmd.Flags().Set("output", "output-from-cmdline") + + // Initialize config + err = initializeConfig(cmd, "generate") + assert.NoError(t, err) + + // Command line value should take precedence + assert.Equal(t, "output-from-cmdline", cmd.Flag("output").Value.String(), + "Command line value should override config file") +} diff --git a/cmd/docs.go b/cmd/docs.go deleted file mode 100644 index 4ae3175..0000000 --- a/cmd/docs.go +++ /dev/null @@ -1,23 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra/doc" -) - -// GenerateDoc generates cobra docs of the cmd -func GenerateDoc(path string) error { - linkHandler := func(name string) string { - return name - } - - filePrepender := func(filename string) string { - return "\n\n" - } - - if err := doc.GenMarkdownTreeCustom(rootCmd, path, filePrepender, linkHandler); err != nil { - return fmt.Errorf("error generating docs: %w", err) - } - return nil -} diff --git a/cmd/generate.go b/cmd/generate.go new file mode 100644 index 0000000..bb40938 --- /dev/null +++ b/cmd/generate.go @@ -0,0 +1,148 @@ +package cmd + +import ( + "strings" + + "github.com/open-feature/cli/internal/config" + "github.com/open-feature/cli/internal/flagset" + "github.com/open-feature/cli/internal/generators" + "github.com/open-feature/cli/internal/generators/golang" + "github.com/open-feature/cli/internal/generators/react" + "github.com/spf13/cobra" +) + +// addStabilityInfo adds stability information to the command's help template before "Usage:" +func addStabilityInfo(cmd *cobra.Command) { + // Only modify commands that have a stability annotation + if stability, ok := cmd.Annotations["stability"]; ok { + originalTemplate := cmd.UsageTemplate() + + // Find the "Usage:" section and insert stability info before it + if strings.Contains(originalTemplate, "Usage:") { + customTemplate := strings.Replace( + originalTemplate, + "Usage:", + "Stability: " + stability + "\n\nUsage:", + 1, // Replace only the first occurrence + ) + cmd.SetUsageTemplate(customTemplate) + } else { + // Fallback if "Usage:" not found - prepend to the template + customTemplate := "Stability: " + stability + "\n\n" + originalTemplate + cmd.SetUsageTemplate(customTemplate) + } + } +} + +func GetGenerateReactCmd() *cobra.Command { + reactCmd := &cobra.Command{ + Use: "react", + Short: "Generate typesafe React Hooks.", + Long: `Generate typesafe React Hooks compatible with the OpenFeature React SDK.`, + Annotations: map[string]string{ + "stability": string(generators.Alpha), + }, + PreRunE: func(cmd *cobra.Command, args []string) error { + return initializeConfig(cmd, "generate.react") + }, + RunE: func(cmd *cobra.Command, args []string) error { + manifestPath := config.GetManifestPath(cmd) + outputPath := config.GetOutputPath(cmd) + + params := generators.Params[react.Params]{ + OutputPath: outputPath, + Custom: react.Params{}, + } + flagset, err := flagset.Load(manifestPath) + if err != nil { + return err + } + + generator := react.NewGenerator(flagset) + err = generator.Generate(¶ms) + if err != nil { + return err + } + return nil + }, + } + + addStabilityInfo(reactCmd) + + return reactCmd +} + +func GetGenerateGoCmd() *cobra.Command { + goCmd := &cobra.Command{ + Use: "go", + Short: "Generate typesafe accessors for OpenFeature.", + Long: `Generate typesafe accessors compatible with the OpenFeature Go SDK.`, + Annotations: map[string]string{ + "stability": string(generators.Alpha), + }, + PreRunE: func(cmd *cobra.Command, args []string) error { + return initializeConfig(cmd, "generate.go") + }, + RunE: func(cmd *cobra.Command, args []string) error { + goPackageName := config.GetGoPackageName(cmd) + manifestPath := config.GetManifestPath(cmd) + outputPath := config.GetOutputPath(cmd) + + params := generators.Params[golang.Params]{ + OutputPath: outputPath, + Custom: golang.Params{ + GoPackage: goPackageName, + }, + } + + flagset, err := flagset.Load(manifestPath) + if err != nil { + return err + } + + generator := golang.NewGenerator(flagset) + err = generator.Generate(¶ms) + if err != nil { + return err + } + return nil + }, + } + + // Add Go-specific flags + config.AddGoGenerateFlags(goCmd) + + addStabilityInfo(goCmd) + + return goCmd +} + +func init() { + // Register generators with the manager + generators.DefaultManager.Register(GetGenerateReactCmd) + generators.DefaultManager.Register(GetGenerateGoCmd) +} + +func GetGenerateCmd() *cobra.Command { + generateCmd := &cobra.Command{ + Use: "generate", + Short: "Generate typesafe OpenFeature accessors.", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return initializeConfig(cmd, "generate") + }, + RunE: func(cmd *cobra.Command, args []string) error { + cmd.Println("Available generators:") + return generators.DefaultManager.PrintGeneratorsTable() + }, + } + + // Add generate flags using the config package + config.AddGenerateFlags(generateCmd) + + // Add all registered generator commands + for _, subCmd := range generators.DefaultManager.GetCommands() { + generateCmd.AddCommand(subCmd) + } + + return generateCmd +} diff --git a/cmd/generate/generate.go b/cmd/generate/generate.go deleted file mode 100644 index 39640d5..0000000 --- a/cmd/generate/generate.go +++ /dev/null @@ -1,31 +0,0 @@ -package generate - -import ( - "github.com/open-feature/cli/cmd/generate/golang" - "github.com/open-feature/cli/cmd/generate/react" - "github.com/open-feature/cli/internal/flagkeys" - - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -// Root for `generate“ sub-commands, handling code generation for flag accessors. -var Root = &cobra.Command{ - Use: "generate", - Short: "Code generation for flag accessors for OpenFeature.", - Long: `Code generation for flag accessors for OpenFeature.`, -} - -func init() { - // Add subcommands. - Root.AddCommand(golang.Cmd) - Root.AddCommand(react.Cmd) - - // Add flags. - Root.PersistentFlags().String(flagkeys.FlagManifestPath, "", "Path to the flag manifest.") - Root.MarkPersistentFlagRequired(flagkeys.FlagManifestPath) - viper.BindPFlag(flagkeys.FlagManifestPath, Root.PersistentFlags().Lookup(flagkeys.FlagManifestPath)) - Root.PersistentFlags().String(flagkeys.OutputPath, "", "Output path for the codegen") - viper.BindPFlag(flagkeys.OutputPath, Root.PersistentFlags().Lookup(flagkeys.OutputPath)) - Root.MarkPersistentFlagRequired(flagkeys.OutputPath) -} diff --git a/cmd/generate/generate_test.go b/cmd/generate/generate_test.go deleted file mode 100644 index 6500386..0000000 --- a/cmd/generate/generate_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package generate - -import ( - "os" - "path/filepath" - "testing" - - "github.com/open-feature/cli/internal/flagkeys" - - "github.com/google/go-cmp/cmp" - - "github.com/spf13/afero" - "github.com/spf13/viper" -) - -func TestGenerateGoSuccess(t *testing.T) { - // Constant paths. - const memoryManifestPath = "manifest/path.json" - const memoryOutputPath = "output/path.go" - const packageName = "testpackage" - const testFileManifest = "testdata/success_manifest.golden" - const testFileGo = "testdata/success_go.golden" - - // Prepare in-memory files. - fs := afero.NewMemMapFs() - viper.Set(flagkeys.FileSystem, fs) - readOsFileAndWriteToMemMap(t, testFileManifest, memoryManifestPath, fs) - - // Prepare command. - Root.SetArgs([]string{"go", - "--flag_manifest_path", memoryManifestPath, - "--output_path", memoryOutputPath, - "--package_name", packageName, - }) - - // Run command. - Root.Execute() - - // Compare result. - compareOutput(t, testFileGo, memoryOutputPath, fs) -} - -func TestGenerateReactSuccess(t *testing.T) { - // Constant paths. - const memoryManifestPath = "manifest/path.json" - const memoryOutputPath = "output/path.ts" - const testFileManifest = "testdata/success_manifest.golden" - const testFileReact = "testdata/success_react.golden" - - // Prepare in-memory files. - fs := afero.NewMemMapFs() - viper.Set(flagkeys.FileSystem, fs) - readOsFileAndWriteToMemMap(t, testFileManifest, memoryManifestPath, fs) - - // Prepare command. - Root.SetArgs([]string{"react", - "--flag_manifest_path", memoryManifestPath, - "--output_path", memoryOutputPath, - }) - - // Run command. - Root.Execute() - - // Compare result. - compareOutput(t, testFileReact, memoryOutputPath, fs) -} - -func readOsFileAndWriteToMemMap(t *testing.T, inputPath string, memPath string, memFs afero.Fs) { - data, err := os.ReadFile(inputPath) - if err != nil { - t.Fatalf("error reading file %q: %v", inputPath, err) - } - if err := memFs.MkdirAll(filepath.Dir(memPath), os.ModePerm); err != nil { - t.Fatalf("error creating directory %q: %v", filepath.Dir(memPath), err) - } - f, err := memFs.Create(memPath) - if err != nil { - t.Fatalf("error creating file %q: %v", memPath, err) - } - defer f.Close() - writtenBytes, err := f.Write(data) - if err != nil { - t.Fatalf("error writing contents to file %q: %v", memPath, err) - } - if writtenBytes != len(data) { - t.Fatalf("error writing entire file %v: writtenBytes != expectedWrittenBytes", memPath) - } -} - -func compareOutput(t *testing.T, testFile, memoryOutputPath string, fs afero.Fs) { - want, err := os.ReadFile(testFile) - if err != nil { - t.Fatalf("error reading file %q: %v", testFile, err) - - } - got, err := afero.ReadFile(fs, memoryOutputPath) - if err != nil { - t.Fatalf("error reading file %q: %v", memoryOutputPath, err) - } - if diff := cmp.Diff(want, got); diff != "" { - t.Errorf("output mismatch (-want +got):\n%s", diff) - } -} diff --git a/cmd/generate/golang/golang.go b/cmd/generate/golang/golang.go deleted file mode 100644 index 76e5b5f..0000000 --- a/cmd/generate/golang/golang.go +++ /dev/null @@ -1,32 +0,0 @@ -package golang - -import ( - "github.com/open-feature/cli/internal/flagkeys" - "github.com/open-feature/cli/internal/generate" - "github.com/open-feature/cli/internal/generate/plugins/golang" - - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -// Cmd for `generate“ command, handling code generation for flag accessors -var Cmd = &cobra.Command{ - Use: "go", - Short: "Generate Golang flag accessors for OpenFeature.", - Long: `Generate Golang flag accessors for OpenFeature.`, - RunE: func(cmd *cobra.Command, args []string) error { - params := golang.Params{ - GoPackage: viper.GetString(flagkeys.GoPackageName), - } - gen := golang.NewGenerator(params) - err := generate.CreateFlagAccessors(gen) - return err - }, -} - -func init() { - Cmd.Flags().String(flagkeys.GoPackageName, "", "Name of the Go package to be generated.") - Cmd.MarkFlagRequired(flagkeys.GoPackageName) - viper.BindPFlag(flagkeys.GoPackageName, Cmd.Flags().Lookup(flagkeys.GoPackageName)) - -} diff --git a/cmd/generate/react/react.go b/cmd/generate/react/react.go deleted file mode 100644 index 9ef2239..0000000 --- a/cmd/generate/react/react.go +++ /dev/null @@ -1,24 +0,0 @@ -package react - -import ( - "github.com/open-feature/cli/internal/generate" - "github.com/open-feature/cli/internal/generate/plugins/react" - - "github.com/spf13/cobra" -) - -// Cmd for "generate" command, handling code generation for flag accessors -var Cmd = &cobra.Command{ - Use: "react", - Short: "Generate typesafe React Hooks.", - Long: `Generate typesafe React Hooks compatible with the OpenFeature React SDK.`, - RunE: func(cmd *cobra.Command, args []string) error { - params := react.Params{} - gen := react.NewGenerator(params) - err := generate.CreateFlagAccessors(gen) - return err - }, -} - -func init() { -} diff --git a/cmd/generate_test.go b/cmd/generate_test.go new file mode 100644 index 0000000..0071b40 --- /dev/null +++ b/cmd/generate_test.go @@ -0,0 +1,128 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/open-feature/cli/internal/config" + "github.com/open-feature/cli/internal/filesystem" + + "github.com/spf13/afero" +) + +// generateTestCase holds the configuration for each generate test +type generateTestCase struct { + name string // test case name + command string // generator to run + manifestGolden string // path to the golden manifest file + outputGolden string // path to the golden output file + outputPath string // output directory (optional, defaults to "output") + outputFile string // output file name + packageName string // optional, only used for Go +} + +func TestGenerate(t *testing.T) { + testCases := []generateTestCase{ + { + name: "Go generation success", + command: "go", + manifestGolden: "testdata/success_manifest.golden", + outputGolden: "testdata/success_go.golden", + outputFile: "testpackage.go", + packageName: "testpackage", + }, + { + name: "React generation success", + command: "react", + manifestGolden: "testdata/success_manifest.golden", + outputGolden: "testdata/success_react.golden", + outputFile: "openfeature.ts", + }, + // Add more test cases here as needed + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cmd := GetGenerateCmd() + + // global flag exists on root only. + config.AddRootFlags(cmd) + + // Constant paths + const memoryManifestPath = "manifest/path.json" + + // Use default output path if not specified + outputPath := tc.outputPath + if outputPath == "" { + outputPath = "output" + } + + // Prepare in-memory files + fs := afero.NewMemMapFs() + filesystem.SetFileSystem(fs) + readOsFileAndWriteToMemMap(t, tc.manifestGolden, memoryManifestPath, fs) + + // Prepare command arguments + args := []string{ + tc.command, + "--manifest", memoryManifestPath, + "--output", outputPath, + } + + // Add package name if provided (for Go) + if tc.packageName != "" { + args = append(args, "--package-name", tc.packageName) + } + + cmd.SetArgs(args) + + // Run command + err := cmd.Execute() + if err != nil { + t.Error(err) + } + + // Compare result + compareOutput(t, tc.outputGolden, filepath.Join(outputPath, tc.outputFile), fs) + }) + } +} + +func readOsFileAndWriteToMemMap(t *testing.T, inputPath string, memPath string, memFs afero.Fs) { + data, err := os.ReadFile(inputPath) + if (err != nil) { + t.Fatalf("error reading file %q: %v", inputPath, err) + } + if err := memFs.MkdirAll(filepath.Dir(memPath), os.ModePerm); err != nil { + t.Fatalf("error creating directory %q: %v", filepath.Dir(memPath), err) + } + f, err := memFs.Create(memPath) + if err != nil { + t.Fatalf("error creating file %q: %v", memPath, err) + } + defer f.Close() + writtenBytes, err := f.Write(data) + if err != nil { + t.Fatalf("error writing contents to file %q: %v", memPath, err) + } + if writtenBytes != len(data) { + t.Fatalf("error writing entire file %v: writtenBytes != expectedWrittenBytes", memPath) + } +} + +func compareOutput(t *testing.T, testFile, memoryOutputPath string, fs afero.Fs) { + want, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("error reading file %q: %v", testFile, err) + + } + got, err := afero.ReadFile(fs, memoryOutputPath) + if err != nil { + t.Fatalf("error reading file %q: %v", memoryOutputPath, err) + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("output mismatch (-want +got):\n%s", diff) + } +} diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 0000000..045bead --- /dev/null +++ b/cmd/init.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "fmt" + + "github.com/open-feature/cli/internal/config" + "github.com/open-feature/cli/internal/filesystem" + "github.com/open-feature/cli/internal/manifest" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +func GetInitCmd() *cobra.Command { + initCmd := &cobra.Command{ + Use: "init", + Short: "Initialize a new project", + Long: "Initialize a new project for OpenFeature CLI.", + PreRunE: func(cmd *cobra.Command, args []string) error { + return initializeConfig(cmd, "init") + }, + RunE: func(cmd *cobra.Command, args []string) error { + manifestPath := config.GetManifestPath(cmd) + override := config.GetOverride(cmd) + + manifestExists, _ := filesystem.Exists(manifestPath) + if (manifestExists && !override) { + confirmMessage := fmt.Sprintf("An existing manifest was found at %s. Would you like to override it?", manifestPath) + shouldOverride, _ := pterm.DefaultInteractiveConfirm.Show(confirmMessage) + // Print a blank line for better readability. + pterm.Println() + if (!shouldOverride) { + pterm.Info.Println("No changes were made.") + return nil + } + } + + pterm.Info.Println("Initializing project...") + err := manifest.Create(manifestPath) + if err != nil { + return err + } + pterm.Info.Printfln("Manifest created at %s", pterm.LightWhite(manifestPath)) + pterm.Success.Println("Project initialized.") + return nil + }, + } + + config.AddInitFlags(initCmd) + + return initCmd +} diff --git a/cmd/root.go b/cmd/root.go index f4d71e8..57abe2f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,10 +1,10 @@ package cmd import ( - "fmt" "os" - "github.com/open-feature/cli/cmd/generate" + "github.com/open-feature/cli/internal/config" + "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -15,27 +15,58 @@ var ( Date string ) -// rootCmd represents the base command when called without any subcommands -var rootCmd = &cobra.Command{ - Use: "openfeature", - Short: "CLI for OpenFeature.", - Long: `CLI for OpenFeature related functionalities.`, - DisableAutoGenTag: true, -} - // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute(version string, commit string, date string) { Version = version Commit = commit Date = date - if err := rootCmd.Execute(); err != nil { - fmt.Println(err) + if err := GetRootCmd().Execute(); err != nil { + pterm.Error.Println(err) os.Exit(1) } } -func init() { - rootCmd.AddCommand(generate.Root) - rootCmd.AddCommand(versionCmd) +func GetRootCmd() *cobra.Command { + // Execute all parent's persistent hooks + cobra.EnableTraverseRunHooks =true + + rootCmd := &cobra.Command{ + Use: "openfeature", + Short: "CLI for OpenFeature.", + Long: `CLI for OpenFeature related functionalities.`, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return initializeConfig(cmd, "") + }, + RunE: func(cmd *cobra.Command, args []string) error { + printBanner() + pterm.Println() + pterm.Println("To see all the options, try 'openfeature --help'") + pterm.Println() + + return nil + }, + SilenceErrors: true, + SilenceUsage: true, + DisableSuggestions: false, + SuggestionsMinimumDistance: 2, + DisableAutoGenTag: true, + } + + // Add global flags using the config package + config.AddRootFlags(rootCmd) + + // Add subcommands + rootCmd.AddCommand(GetVersionCmd()) + rootCmd.AddCommand(GetInitCmd()) + rootCmd.AddCommand(GetGenerateCmd()) + + // Add a custom error handler after the command is created + rootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { + pterm.Error.Printf("Invalid flag: %s", err) + pterm.Println("Run 'openfeature --help' for usage information") + return err + }) + + return rootCmd } diff --git a/cmd/generate/testdata/success_go.golden b/cmd/testdata/success_go.golden similarity index 97% rename from cmd/generate/testdata/success_go.golden rename to cmd/testdata/success_go.golden index e9ec890..5c02d02 100644 --- a/cmd/generate/testdata/success_go.golden +++ b/cmd/testdata/success_go.golden @@ -1,4 +1,4 @@ -// AUTOMATICALLY GENERATED BY OPENFEATURE CODEGEN, DO NOT EDIT. +// AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. package testpackage import ( @@ -86,5 +86,5 @@ var UsernameMaxLength = struct { } func init() { - client = openfeature.GetApiInstance().GetClient() + client = openfeature.GetApiInstance().GetClient() } diff --git a/cmd/generate/testdata/success_manifest.golden b/cmd/testdata/success_manifest.golden similarity index 100% rename from cmd/generate/testdata/success_manifest.golden rename to cmd/testdata/success_manifest.golden diff --git a/cmd/generate/testdata/success_react.golden b/cmd/testdata/success_react.golden similarity index 80% rename from cmd/generate/testdata/success_react.golden rename to cmd/testdata/success_react.golden index 4d2a173..c00172f 100644 --- a/cmd/generate/testdata/success_react.golden +++ b/cmd/testdata/success_react.golden @@ -15,7 +15,7 @@ import { * - default value: `0.15` * - type: `number` */ -export const useDiscountPercentage = (options: ReactFlagEvaluationOptions) => { +export const useDiscountPercentage = (options?: ReactFlagEvaluationOptions) => { return useFlag("discountPercentage", 0.15, options); }; @@ -30,7 +30,7 @@ export const useDiscountPercentage = (options: ReactFlagEvaluationOptions) => { * Equivalent to useFlag with options: `{ suspend: true }` * @experimental — Suspense is an experimental feature subject to change in future versions. */ -export const useSuspenseDiscountPercentage = (options: ReactFlagEvaluationNoSuspenseOptions) => { +export const useSuspenseDiscountPercentage = (options?: ReactFlagEvaluationNoSuspenseOptions) => { return useSuspenseFlag("discountPercentage", 0.15, options); }; @@ -42,7 +42,7 @@ export const useSuspenseDiscountPercentage = (options: ReactFlagEvaluationNoSusp * - default value: `false` * - type: `boolean` */ -export const useEnableFeatureA = (options: ReactFlagEvaluationOptions) => { +export const useEnableFeatureA = (options?: ReactFlagEvaluationOptions) => { return useFlag("enableFeatureA", false, options); }; @@ -57,7 +57,7 @@ export const useEnableFeatureA = (options: ReactFlagEvaluationOptions) => { * Equivalent to useFlag with options: `{ suspend: true }` * @experimental — Suspense is an experimental feature subject to change in future versions. */ -export const useSuspenseEnableFeatureA = (options: ReactFlagEvaluationNoSuspenseOptions) => { +export const useSuspenseEnableFeatureA = (options?: ReactFlagEvaluationNoSuspenseOptions) => { return useSuspenseFlag("enableFeatureA", false, options); }; @@ -69,7 +69,7 @@ export const useSuspenseEnableFeatureA = (options: ReactFlagEvaluationNoSuspense * - default value: `Hello there!` * - type: `string` */ -export const useGreetingMessage = (options: ReactFlagEvaluationOptions) => { +export const useGreetingMessage = (options?: ReactFlagEvaluationOptions) => { return useFlag("greetingMessage", "Hello there!", options); }; @@ -84,7 +84,7 @@ export const useGreetingMessage = (options: ReactFlagEvaluationOptions) => { * Equivalent to useFlag with options: `{ suspend: true }` * @experimental — Suspense is an experimental feature subject to change in future versions. */ -export const useSuspenseGreetingMessage = (options: ReactFlagEvaluationNoSuspenseOptions) => { +export const useSuspenseGreetingMessage = (options?: ReactFlagEvaluationNoSuspenseOptions) => { return useSuspenseFlag("greetingMessage", "Hello there!", options); }; @@ -96,7 +96,7 @@ export const useSuspenseGreetingMessage = (options: ReactFlagEvaluationNoSuspens * - default value: `50` * - type: `number` */ -export const useUsernameMaxLength = (options: ReactFlagEvaluationOptions) => { +export const useUsernameMaxLength = (options?: ReactFlagEvaluationOptions) => { return useFlag("usernameMaxLength", 50, options); }; @@ -111,6 +111,6 @@ export const useUsernameMaxLength = (options: ReactFlagEvaluationOptions) => { * Equivalent to useFlag with options: `{ suspend: true }` * @experimental — Suspense is an experimental feature subject to change in future versions. */ -export const useSuspenseUsernameMaxLength = (options: ReactFlagEvaluationNoSuspenseOptions) => { +export const useSuspenseUsernameMaxLength = (options?: ReactFlagEvaluationNoSuspenseOptions) => { return useSuspenseFlag("usernameMaxLength", 50, options); }; diff --git a/cmd/utils.go b/cmd/utils.go new file mode 100644 index 0000000..c5ef3b8 --- /dev/null +++ b/cmd/utils.go @@ -0,0 +1,20 @@ +package cmd + +import "github.com/pterm/pterm" + +func printBanner() { + ivrit := ` + ___ _____ _ + / _ \ _ __ ___ _ __ | ___|__ __ _| |_ _ _ _ __ ___ + | | | | '_ \ / _ \ '_ \| |_ / _ \/ _` + "`" + ` | __| | | | '__/ _ \ + | |_| | |_) | __/ | | | _| __/ (_| | |_| |_| | | | __/ + \___/| .__/ \___|_| |_|_| \___|\__,_|\__|\__,_|_| \___| + |_| + CLI +` + + pterm.Println(ivrit) + pterm.Println() + pterm.Printf("version: %s | compiled: %s\n", pterm.LightGreen(Version), pterm.LightGreen(Date)) + pterm.Println(pterm.Cyan("🔗 https://openfeature.dev | https://github.com/open-feature/cli")) +} \ No newline at end of file diff --git a/cmd/version.go b/cmd/version.go index 6a719c1..ade0898 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -7,25 +7,30 @@ import ( "github.com/spf13/cobra" ) -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Print the version number of the OpenFeature CLI", - Long: ``, - Run: func(cmd *cobra.Command, args []string) { - if Version == "dev" { - details, ok := debug.ReadBuildInfo() - if ok && details.Main.Version != "" && details.Main.Version != "(devel)" { - Version = details.Main.Version - for _, i := range details.Settings { - if i.Key == "vcs.time" { - Date = i.Value - } - if i.Key == "vcs.revision" { - Commit = i.Value +func GetVersionCmd() *cobra.Command { + versionCmd := &cobra.Command{ + Use: "version", + Short: "Print the version number of the OpenFeature CLI", + Long: ``, + Run: func(cmd *cobra.Command, args []string) { + if Version == "dev" { + details, ok := debug.ReadBuildInfo() + if ok && details.Main.Version != "" && details.Main.Version != "(devel)" { + Version = details.Main.Version + for _, i := range details.Settings { + if i.Key == "vcs.time" { + Date = i.Value + } + if i.Key == "vcs.revision" { + Commit = i.Value + } } } } - } - fmt.Printf("OpenFeature CLI: %s (%s), built at: %s\n", Version, Commit, Date) - }, + + fmt.Printf("OpenFeature CLI: %s (%s), built at: %s\n", Version, Commit, Date) + }, + } + + return versionCmd } \ No newline at end of file diff --git a/docs/commands/openfeature.md b/docs/commands/openfeature.md index 08d5ce1..2283500 100644 --- a/docs/commands/openfeature.md +++ b/docs/commands/openfeature.md @@ -8,14 +8,21 @@ CLI for OpenFeature. CLI for OpenFeature related functionalities. +``` +openfeature [flags] +``` + ### Options ``` - -h, --help help for openfeature + -h, --help help for openfeature + -m, --manifest string Path to the flag manifest (default "flags.json") + --no-input Disable interactive prompts ``` ### SEE ALSO -* [openfeature generate](openfeature_generate.md) - Code generation for flag accessors for OpenFeature. +* [openfeature generate](openfeature_generate.md) - Generate typesafe OpenFeature accessors. +* [openfeature init](openfeature_init.md) - Initialize a new project * [openfeature version](openfeature_version.md) - Print the version number of the OpenFeature CLI diff --git a/docs/commands/openfeature_generate.md b/docs/commands/openfeature_generate.md index 68f9447..7d16b09 100644 --- a/docs/commands/openfeature_generate.md +++ b/docs/commands/openfeature_generate.md @@ -2,23 +2,29 @@ ## openfeature generate -Code generation for flag accessors for OpenFeature. +Generate typesafe OpenFeature accessors. -### Synopsis - -Code generation for flag accessors for OpenFeature. +``` +openfeature generate [flags] +``` ### Options ``` - --flag_manifest_path string Path to the flag manifest. - -h, --help help for generate - --output_path string Output path for the codegen + -h, --help help for generate + -o, --output string Path to where the generated files should be saved +``` + +### Options inherited from parent commands + +``` + -m, --manifest string Path to the flag manifest (default "flags.json") + --no-input Disable interactive prompts ``` ### SEE ALSO * [openfeature](openfeature.md) - CLI for OpenFeature. -* [openfeature generate go](openfeature_generate_go.md) - Generate Golang flag accessors for OpenFeature. +* [openfeature generate go](openfeature_generate_go.md) - Generate typesafe accessors for OpenFeature. * [openfeature generate react](openfeature_generate_react.md) - Generate typesafe React Hooks. diff --git a/docs/commands/openfeature_generate_go.md b/docs/commands/openfeature_generate_go.md index bac03e5..f33e268 100644 --- a/docs/commands/openfeature_generate_go.md +++ b/docs/commands/openfeature_generate_go.md @@ -2,11 +2,11 @@ ## openfeature generate go -Generate Golang flag accessors for OpenFeature. +Generate typesafe accessors for OpenFeature. ### Synopsis -Generate Golang flag accessors for OpenFeature. +Generate typesafe accessors compatible with the OpenFeature Go SDK. ``` openfeature generate go [flags] @@ -16,17 +16,18 @@ openfeature generate go [flags] ``` -h, --help help for go - --package_name string Name of the Go package to be generated. + --package-name string Name of the generated Go package (default "openfeature") ``` ### Options inherited from parent commands ``` - --flag_manifest_path string Path to the flag manifest. - --output_path string Output path for the codegen + -m, --manifest string Path to the flag manifest (default "flags.json") + --no-input Disable interactive prompts + -o, --output string Path to where the generated files should be saved ``` ### SEE ALSO -* [openfeature generate](openfeature_generate.md) - Code generation for flag accessors for OpenFeature. +* [openfeature generate](openfeature_generate.md) - Generate typesafe OpenFeature accessors. diff --git a/docs/commands/openfeature_generate_react.md b/docs/commands/openfeature_generate_react.md index 2514b18..56d00cc 100644 --- a/docs/commands/openfeature_generate_react.md +++ b/docs/commands/openfeature_generate_react.md @@ -21,11 +21,12 @@ openfeature generate react [flags] ### Options inherited from parent commands ``` - --flag_manifest_path string Path to the flag manifest. - --output_path string Output path for the codegen + -m, --manifest string Path to the flag manifest (default "flags.json") + --no-input Disable interactive prompts + -o, --output string Path to where the generated files should be saved ``` ### SEE ALSO -* [openfeature generate](openfeature_generate.md) - Code generation for flag accessors for OpenFeature. +* [openfeature generate](openfeature_generate.md) - Generate typesafe OpenFeature accessors. diff --git a/docs/commands/openfeature_init.md b/docs/commands/openfeature_init.md new file mode 100644 index 0000000..5769fb7 --- /dev/null +++ b/docs/commands/openfeature_init.md @@ -0,0 +1,32 @@ + + +## openfeature init + +Initialize a new project + +### Synopsis + +Initialize a new project for OpenFeature CLI. + +``` +openfeature init [flags] +``` + +### Options + +``` + -h, --help help for init + --override Override an existing configuration +``` + +### Options inherited from parent commands + +``` + -m, --manifest string Path to the flag manifest (default "flags.json") + --no-input Disable interactive prompts +``` + +### SEE ALSO + +* [openfeature](openfeature.md) - CLI for OpenFeature. + diff --git a/docs/commands/openfeature_version.md b/docs/commands/openfeature_version.md index 44eba8e..abb85dd 100644 --- a/docs/commands/openfeature_version.md +++ b/docs/commands/openfeature_version.md @@ -14,6 +14,13 @@ openfeature version [flags] -h, --help help for version ``` +### Options inherited from parent commands + +``` + -m, --manifest string Path to the flag manifest (default "flags.json") + --no-input Disable interactive prompts +``` + ### SEE ALSO * [openfeature](openfeature.md) - CLI for OpenFeature. diff --git a/docs/generate-commands.go b/docs/generate-commands.go index f420f0f..a57d20f 100644 --- a/docs/generate-commands.go +++ b/docs/generate-commands.go @@ -1,16 +1,27 @@ package main import ( - "log" + "fmt" + "os" "github.com/open-feature/cli/cmd" + "github.com/spf13/cobra/doc" ) const docPath = "./docs/commands" -// GenerateDoc generates cobra docs of the cmd +// Generates cobra docs of the cmd func main() { - if err := cmd.GenerateDoc(docPath); err != nil { - log.Fatal(err) + linkHandler := func(name string) string { + return name + } + + filePrepender := func(filename string) string { + return "\n\n" + } + + if err := doc.GenMarkdownTreeCustom(cmd.GetRootCmd(), docPath, filePrepender, linkHandler); err != nil { + fmt.Fprintf(os.Stderr, "error generating docs: %v\n", err) + os.Exit(1) } } diff --git a/go.mod b/go.mod index 2eae4f6..072ced5 100644 --- a/go.mod +++ b/go.mod @@ -1,32 +1,55 @@ module github.com/open-feature/cli -go 1.22.5 +go 1.23.0 + +toolchain go1.24.0 require ( github.com/iancoleman/strcase v0.3.0 - github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 + github.com/invopop/jsonschema v0.13.0 + github.com/pterm/pterm v0.12.80 github.com/spf13/afero v1.11.0 + github.com/spf13/pflag v1.0.5 + github.com/stretchr/testify v1.10.0 + github.com/xeipuuv/gojsonschema v1.2.0 + golang.org/x/text v0.23.0 ) require ( + atomicgo.dev/cursor v0.2.0 // indirect + atomicgo.dev/keyboard v0.2.9 // indirect + atomicgo.dev/schedule v0.1.0 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/containerd/console v1.0.3 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gookit/color v1.5.4 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/magiconair/properties v1.8.7 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/cast v1.6.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect - golang.org/x/sys v0.18.0 // indirect - golang.org/x/text v0.18.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/term v0.30.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ee6e884..6ace66f 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,27 @@ +atomicgo.dev/assert v0.0.2 h1:FiKeMiZSgRrZsPo9qn/7vmr7mCsh5SZyXY4YGYiYwrg= +atomicgo.dev/assert v0.0.2/go.mod h1:ut4NcI3QDdJtlmAxQULOmA13Gz6e2DWbSAS8RUOmNYQ= +atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw= +atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU= +atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8= +atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ= +atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs= +atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU= +github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= +github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8= +github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII= +github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k= +github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI= +github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c= +github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= +github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= +github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= +github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -10,18 +34,40 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= +github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= +github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= +github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= +github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= +github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= +github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= +github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= @@ -29,6 +75,18 @@ github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h 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= +github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= +github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg= +github.com/pterm/pterm v0.12.30/go.mod h1:MOqLIyMOgmTDz9yorcYbcw+HsgoZo3BQfg2wtl3HEFE= +github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEejaWgXU= +github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE= +github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8= +github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s= +github.com/pterm/pterm v0.12.80 h1:mM55B+GnKUnLMUSqhdINe4s6tOuVQIetQ3my8JGyAIg= +github.com/pterm/pterm v0.12.80/go.mod h1:c6DeF9bSnOSeFPZlfs4ZRAFcf5SCoTwvwQ5xaKGQlHo= +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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= @@ -37,8 +95,8 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= -github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= +github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= @@ -56,28 +114,85 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS 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.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= +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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +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/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/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/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +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.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +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= +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.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/flags.go b/internal/config/flags.go new file mode 100644 index 0000000..34bfa2a --- /dev/null +++ b/internal/config/flags.go @@ -0,0 +1,72 @@ +package config + +import ( + "github.com/spf13/cobra" +) + +// Flag name constants to avoid duplication +const ( + ManifestFlagName = "manifest" + OutputFlagName = "output" + NoInputFlagName = "no-input" + GoPackageFlagName = "package-name" + OverrideFlagName = "override" +) + +// Default values for flags +const ( + DefaultManifestPath = "flags.json" + DefaultOutputPath = "" + DefaultGoPackageName = "openfeature" +) + +// AddRootFlags adds the common flags to the given command +func AddRootFlags(cmd *cobra.Command) { + cmd.PersistentFlags().StringP(ManifestFlagName, "m", DefaultManifestPath, "Path to the flag manifest") + cmd.PersistentFlags().Bool(NoInputFlagName, false, "Disable interactive prompts") +} + +// AddGenerateFlags adds the common generate flags to the given command +func AddGenerateFlags(cmd *cobra.Command) { + cmd.PersistentFlags().StringP(OutputFlagName, "o", DefaultOutputPath, "Path to where the generated files should be saved") +} + +// AddGoGenerateFlags adds the go generator specific flags to the given command +func AddGoGenerateFlags(cmd *cobra.Command) { + cmd.Flags().String(GoPackageFlagName, DefaultGoPackageName, "Name of the generated Go package") +} + +// AddInitFlags adds the init command specific flags +func AddInitFlags(cmd *cobra.Command) { + cmd.Flags().Bool(OverrideFlagName, false, "Override an existing configuration") +} + +// GetManifestPath gets the manifest path from the given command +func GetManifestPath(cmd *cobra.Command) string { + manifestPath, _ := cmd.Flags().GetString(ManifestFlagName) + return manifestPath +} + +// GetOutputPath gets the output path from the given command +func GetOutputPath(cmd *cobra.Command) string { + outputPath, _ := cmd.Flags().GetString(OutputFlagName) + return outputPath +} + +// GetGoPackageName gets the Go package name from the given command +func GetGoPackageName(cmd *cobra.Command) string { + goPackageName, _ := cmd.Flags().GetString(GoPackageFlagName) + return goPackageName +} + +// GetNoInput gets the no-input flag from the given command +func GetNoInput(cmd *cobra.Command) bool { + noInput, _ := cmd.Flags().GetBool(NoInputFlagName) + return noInput +} + +// GetOverride gets the override flag from the given command +func GetOverride(cmd *cobra.Command) bool { + override, _ := cmd.Flags().GetBool(OverrideFlagName) + return override +} diff --git a/internal/filesystem/filesystem.go b/internal/filesystem/filesystem.go index fdab105..cf7fad0 100644 --- a/internal/filesystem/filesystem.go +++ b/internal/filesystem/filesystem.go @@ -2,16 +2,65 @@ package filesystem import ( - "github.com/open-feature/cli/internal/flagkeys" + "fmt" + "os" + "path/filepath" "github.com/spf13/afero" "github.com/spf13/viper" ) +var viperKey = "filesystem" + +// Get the filesystem interface from the viper configuration. +// If the filesystem interface is not set, the default filesystem interface is returned. func FileSystem() afero.Fs { - return viper.Get(flagkeys.FileSystem).(afero.Fs) + return viper.Get(viperKey).(afero.Fs) +} + +// Set the filesystem interface in the viper configuration. +// This is useful for testing purposes. +func SetFileSystem(fs afero.Fs) { + viper.Set(viperKey, fs) +} + +// Writes data to a file at the given path using the filesystem interface. +// If the file does not exist, it will be created, including all necessary directories. +// If the file exists, it will be overwritten. +func WriteFile(path string, data []byte) error { + fs := FileSystem() + if err := fs.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { + return err + } + f, err := fs.Create(path) + if err != nil { + return fmt.Errorf("error creating file %q: %v", path, err) + } + defer f.Close() + writtenBytes, err := f.Write(data) + if err != nil { + return fmt.Errorf("error writing contents to file %q: %v", path, err) + } + if writtenBytes != len(data) { + return fmt.Errorf("error writing entire file %v: writtenBytes != expectedWrittenBytes", path) + } + + return nil +} + +// Checks if a file exists at the given path using the filesystem interface. +func Exists(path string) (bool, error) { + fs := FileSystem() + _, err := fs.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return true, nil } func init() { - viper.SetDefault(flagkeys.FileSystem, afero.NewOsFs()) + viper.SetDefault(viperKey, afero.NewOsFs()) } diff --git a/internal/flagkeys/flagkeys.go b/internal/flagkeys/flagkeys.go deleted file mode 100644 index 90f1976..0000000 --- a/internal/flagkeys/flagkeys.go +++ /dev/null @@ -1,24 +0,0 @@ -// Package commonflags contains keys for all command-line flags related to openfeature CLI. -package flagkeys - -import "github.com/spf13/viper" - -const ( - // `generate` flags: - // FlagManifestPath is the key for the flag that stores the flag manifest path. - FlagManifestPath = "flag_manifest_path" - // OutputPath is the key for the flag that stores the output path. - OutputPath = "output_path" - - // `generate go` flags: - // GoPackageName is the key for the flag that stores the Golang package name. - GoPackageName = "package_name" - - //internal keys: - // FileSystem is the key for the flag that stores the filesystem interface. - FileSystem = "filesystem" -) - -func init() { - viper.SetDefault(FileSystem, "local") -} diff --git a/internal/flagset/flagset.go b/internal/flagset/flagset.go new file mode 100644 index 0000000..8b4aff0 --- /dev/null +++ b/internal/flagset/flagset.go @@ -0,0 +1,134 @@ +package flagset + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + + "github.com/open-feature/cli/internal/filesystem" + "github.com/open-feature/cli/internal/manifest" + "github.com/spf13/afero" +) + +// FlagType are the primitive types of flags. +type FlagType int + +// Collection of the different kinds of flag types +const ( + UnknownFlagType FlagType = iota + IntType + FloatType + BoolType + StringType + ObjectType +) + +func (f FlagType) String() string { + switch f { + case IntType: + return "int" + case FloatType: + return "float" + case BoolType: + return "bool" + case StringType: + return "string" + case ObjectType: + return "object" + default: + return "unknown" + } +} + +type Flag struct { + Key string + Type FlagType + Description string + DefaultValue any +} + +type Flagset struct { + Flags []Flag +} + +// Loads, validates, and unmarshals the manifest file at the given path into a flagset +func Load(manifestPath string) (*Flagset, error) { + fs := filesystem.FileSystem() + data, err := afero.ReadFile(fs, manifestPath) + if err != nil { + return nil, fmt.Errorf("error reading contents from file %q", manifestPath) + } + + validationErrors, err := manifest.Validate(data) + if err != nil { + return nil, err + } else if len(validationErrors) > 0 { + return nil, fmt.Errorf("validation failed: %v", validationErrors) + } + + var flagset Flagset + if err := json.Unmarshal(data, &flagset); err != nil { + return nil, fmt.Errorf("error unmarshaling JSON: %v", validationErrors) + } + + return &flagset, nil +} + +// Filter removes flags from the Flagset that are of unsupported types. +func (fs *Flagset) Filter(unsupportedFlagTypes map[FlagType]bool) *Flagset { + var filtered Flagset + for _, flag := range fs.Flags { + if !unsupportedFlagTypes[flag.Type] { + filtered.Flags = append(filtered.Flags, flag) + } + } + return &filtered +} + +// UnmarshalJSON unmarshals the JSON data into a Flagset. It is used by json.Unmarshal. +func (fs *Flagset) UnmarshalJSON(data []byte) error { + var manifest struct { + Flags map[string]struct { + FlagType string `json:"flagType"` + Description string `json:"description"` + DefaultValue any `json:"defaultValue"` + } `json:"flags"` + } + + if err := json.Unmarshal(data, &manifest); err != nil { + return err + } + + for key, flag := range manifest.Flags { + var flagType FlagType + switch flag.FlagType { + case "integer": + flagType = IntType + case "float": + flagType = FloatType + case "boolean": + flagType = BoolType + case "string": + flagType = StringType + case "object": + flagType = ObjectType + default: + return errors.New("unknown flag type") + } + + fs.Flags = append(fs.Flags, Flag{ + Key: key, + Type: flagType, + Description: flag.Description, + DefaultValue: flag.DefaultValue, + }) + } + + // Ensure consistency of order of flag generation. + sort.Slice(fs.Flags, func(i, j int) bool { + return fs.Flags[i].Key < fs.Flags[j].Key + }) + + return nil +} \ No newline at end of file diff --git a/internal/generate/generate.go b/internal/generate/generate.go deleted file mode 100644 index 38a3a95..0000000 --- a/internal/generate/generate.go +++ /dev/null @@ -1,64 +0,0 @@ -// Package generate contains the top level functions used for generating flag accessors. -package generate - -import ( - "bytes" - "fmt" - "os" - "path" - "path/filepath" - "text/template" - - "github.com/open-feature/cli/internal/filesystem" - "github.com/open-feature/cli/internal/flagkeys" - "github.com/open-feature/cli/internal/generate/manifestutils" - "github.com/open-feature/cli/internal/generate/types" - - "github.com/spf13/viper" -) - -// GenerateFile receives data for the Go template engine and outputs the contents to the file. -// Intended to be invoked by each language generator with appropriate data. -func GenerateFile(funcs template.FuncMap, contents string, data types.TmplDataInterface) error { - contentsTmpl, err := template.New("contents").Funcs(funcs).Parse(contents) - if err != nil { - return fmt.Errorf("error initializing template: %v", err) - } - - var buf bytes.Buffer - if err := contentsTmpl.Execute(&buf, data); err != nil { - return fmt.Errorf("error executing template: %v", err) - } - outputPath := data.BaseTmplDataInfo().OutputPath - fs := filesystem.FileSystem() - if err := fs.MkdirAll(filepath.Dir(outputPath), os.ModePerm); err != nil { - return err - } - f, err := fs.Create(path.Join(outputPath)) - if err != nil { - return fmt.Errorf("error creating file %q: %v", outputPath, err) - } - defer f.Close() - writtenBytes, err := f.Write(buf.Bytes()) - if err != nil { - return fmt.Errorf("error writing contents to file %q: %v", outputPath, err) - } - if writtenBytes != buf.Len() { - return fmt.Errorf("error writing entire file %v: writtenBytes != expectedWrittenBytes", outputPath) - } - - return nil -} - -// Takes as input a generator and outputs file with the appropriate flag accessors. -// The flag data is taken from the provided flag manifest. -func CreateFlagAccessors(gen types.Generator) error { - bt, err := manifestutils.LoadData(viper.GetString(flagkeys.FlagManifestPath), gen.SupportedFlagTypes()) - if err != nil { - return fmt.Errorf("error loading flag manifest: %v", err) - } - input := types.Input{ - BaseData: bt, - } - return gen.Generate(input) -} diff --git a/internal/generate/manifestutils/manifestutils.go b/internal/generate/manifestutils/manifestutils.go deleted file mode 100644 index 7669ba0..0000000 --- a/internal/generate/manifestutils/manifestutils.go +++ /dev/null @@ -1,113 +0,0 @@ -// Package manifestutils contains useful functions for loading the flag manifest. -package manifestutils - -import ( - "encoding/json" - "fmt" - "sort" - "strconv" - - "github.com/open-feature/cli/internal/filesystem" - "github.com/open-feature/cli/internal/flagkeys" - "github.com/open-feature/cli/internal/generate/types" - flagmanifest "github.com/open-feature/cli/schema/v0" - - "github.com/santhosh-tekuri/jsonschema/v5" - "github.com/spf13/afero" - "github.com/spf13/viper" -) - -// LoadData loads the data from the flag manifest. -func LoadData(manifestPath string, supportedFlagTypes map[types.FlagType]bool) (*types.BaseTmplData, error) { - fs := filesystem.FileSystem() - data, err := afero.ReadFile(fs, manifestPath) - if err != nil { - return nil, fmt.Errorf("error reading contents from file %q", manifestPath) - } - unfilteredData, err := unmarshalFlagManifest(data) - if err != nil { - return nil, err - } - - filteredData := filterUnsupportedFlags(unfilteredData, supportedFlagTypes) - - return filteredData, nil -} - -func filterUnsupportedFlags(unfilteredData *types.BaseTmplData, supportedFlagTypes map[types.FlagType]bool) *types.BaseTmplData { - filteredData := &types.BaseTmplData{ - OutputPath: unfilteredData.OutputPath, - } - for _, flagData := range unfilteredData.Flags { - if supportedFlagTypes[flagData.Type] { - filteredData.Flags = append(filteredData.Flags, flagData) - } - } - return filteredData -} - -var stringToFlagType = map[string]types.FlagType{ - "string": types.StringType, - "boolean": types.BoolType, - "float": types.FloatType, - "integer": types.IntType, - "object": types.ObjectType, -} - -func getDefaultValue(defaultValue interface{}, flagType types.FlagType) string { - switch flagType { - case types.BoolType: - return strconv.FormatBool(defaultValue.(bool)) - case types.IntType: - //the conversion to float64 instead of integer typically occurs - //due to how JSON is parsed in Go. In Go's encoding/json package, - //all JSON numbers are unmarshaled into float64 by default when decoding into an interface{}. - return strconv.FormatFloat(defaultValue.(float64), 'g', -1, 64) - case types.FloatType: - return strconv.FormatFloat(defaultValue.(float64), 'g', -1, 64) - case types.StringType: - return defaultValue.(string) - default: - return "" - } -} - -func unmarshalFlagManifest(data []byte) (*types.BaseTmplData, error) { - dynamic := make(map[string]interface{}) - err := json.Unmarshal(data, &dynamic) - if err != nil { - return nil, fmt.Errorf("error unmarshalling JSON: %v", err) - } - - sch, err := jsonschema.CompileString(flagmanifest.SchemaPath, flagmanifest.Schema) - if err != nil { - return nil, fmt.Errorf("error compiling JSON schema: %v", err) - } - if err = sch.Validate(dynamic); err != nil { - return nil, fmt.Errorf("error validating JSON schema: %v", err) - } - // All casts can be done directly since the JSON is already validated by the schema. - iFlags := dynamic["flags"] - flags := iFlags.(map[string]interface{}) - btData := types.BaseTmplData{ - OutputPath: viper.GetString(flagkeys.OutputPath), - } - for flagKey, iFlagData := range flags { - flagData := iFlagData.(map[string]interface{}) - flagTypeString := flagData["flagType"].(string) - flagType := stringToFlagType[flagTypeString] - docs := flagData["description"].(string) - defaultValue := getDefaultValue(flagData["defaultValue"], flagType) - btData.Flags = append(btData.Flags, &types.FlagTmplData{ - Name: flagKey, - Type: flagType, - DefaultValue: defaultValue, - Docs: docs, - }) - } - // Ensure consistency of order of flag generation. - sort.Slice(btData.Flags, func(i, j int) bool { - return btData.Flags[i].Name < btData.Flags[j].Name - }) - return &btData, nil -} diff --git a/internal/generate/plugins/golang/golang.go b/internal/generate/plugins/golang/golang.go deleted file mode 100644 index 8f12f30..0000000 --- a/internal/generate/plugins/golang/golang.go +++ /dev/null @@ -1,134 +0,0 @@ -package golang - -import ( - _ "embed" - "sort" - "strconv" - "text/template" - - "github.com/open-feature/cli/internal/generate" - "github.com/open-feature/cli/internal/generate/types" - - "github.com/iancoleman/strcase" -) - -// TmplData contains the Golang-specific data and the base data for the codegen. -type TmplData struct { - *types.BaseTmplData - GoPackage string -} - -type genImpl struct { - goPackage string -} - -// BaseTmplDataInfo provides the base template data for the codegen. -func (td *TmplData) BaseTmplDataInfo() *types.BaseTmplData { - return td.BaseTmplData -} - -// supportedFlagTypes is the flag types supported by the Go template. -var supportedFlagTypes = map[types.FlagType]bool{ - types.FloatType: true, - types.StringType: true, - types.IntType: true, - types.BoolType: true, - types.ObjectType: false, -} - -func (*genImpl) SupportedFlagTypes() map[types.FlagType]bool { - return supportedFlagTypes -} - -//go:embed golang.tmpl -var golangTmpl string - -// Go Funcs BEGIN - -func flagVarName(flagName string) string { - return strcase.ToCamel(flagName) -} - -func flagInitParam(flagName string) string { - return strconv.Quote(flagName) -} - -func openFeatureType(t types.FlagType) string { - switch t { - case types.IntType: - return "Int" - case types.FloatType: - return "Float" - case types.BoolType: - return "Boolean" - case types.StringType: - return "String" - default: - return "" - } -} - -func supportImports(flags []*types.FlagTmplData) []string { - var res []string - if len(flags) > 0 { - res = append(res, "\"context\"") - res = append(res, "\"github.com/open-feature/go-sdk/openfeature\"") - } - sort.Strings(res) - return res -} - -func defaultValueLiteral(flag *types.FlagTmplData) string { - switch flag.Type { - case types.StringType: - return strconv.Quote(flag.DefaultValue) - default: - return flag.DefaultValue - } -} - -func typeString(flagType types.FlagType) string { - switch flagType { - case types.StringType: - return "string" - case types.IntType: - return "int64" - case types.BoolType: - return "bool" - case types.FloatType: - return "float64" - default: - return "" - } -} - -// Go Funcs END - -// Generate generates the Go flag accessors for OpenFeature. -func (g *genImpl) Generate(input types.Input) error { - funcs := template.FuncMap{ - "FlagVarName": flagVarName, - "FlagInitParam": flagInitParam, - "OpenFeatureType": openFeatureType, - "SupportImports": supportImports, - "DefaultValueLiteral": defaultValueLiteral, - "TypeString": typeString, - } - td := TmplData{ - BaseTmplData: input.BaseData, - GoPackage: g.goPackage, - } - return generate.GenerateFile(funcs, golangTmpl, &td) -} - -// Params are parameters for creating a Generator -type Params struct { - GoPackage string -} - -// NewGenerator creates a generator for Go. -func NewGenerator(params Params) types.Generator { - return &genImpl{ - goPackage: params.GoPackage, - } -} diff --git a/internal/generate/plugins/react/react.go b/internal/generate/plugins/react/react.go deleted file mode 100644 index 827f25c..0000000 --- a/internal/generate/plugins/react/react.go +++ /dev/null @@ -1,92 +0,0 @@ -package react - -import ( - _ "embed" - "strconv" - "text/template" - - "github.com/open-feature/cli/internal/generate" - "github.com/open-feature/cli/internal/generate/types" - - "github.com/iancoleman/strcase" -) - -type TmplData struct { - *types.BaseTmplData -} - -type genImpl struct { -} - -// BaseTmplDataInfo provides the base template data for the codegen. -func (td *TmplData) BaseTmplDataInfo() *types.BaseTmplData { - return td.BaseTmplData -} - -// supportedFlagTypes is the flag types supported by the Go template. -var supportedFlagTypes = map[types.FlagType]bool{ - types.FloatType: true, - types.StringType: true, - types.IntType: true, - types.BoolType: true, - types.ObjectType: false, -} - -func (*genImpl) SupportedFlagTypes() map[types.FlagType]bool { - return supportedFlagTypes -} - -//go:embed react.tmpl -var reactTmpl string - -func flagVarName(flagName string) string { - return strcase.ToCamel(flagName) -} - -func flagInitParam(flagName string) string { - return strconv.Quote(flagName) -} - -func defaultValueLiteral(flag *types.FlagTmplData) string { - switch flag.Type { - case types.StringType: - return strconv.Quote(flag.DefaultValue) - default: - return flag.DefaultValue - } -} - -func typeString(flagType types.FlagType) string { - switch flagType { - case types.StringType: - return "string" - case types.IntType, types.FloatType: - return "number" - case types.BoolType: - return "boolean" - default: - return "" - } -} - -func (g *genImpl) Generate(input types.Input) error { - funcs := template.FuncMap{ - "FlagVarName": flagVarName, - "FlagInitParam": flagInitParam, - "DefaultValueLiteral": defaultValueLiteral, - "TypeString": typeString, - } - td := TmplData{ - BaseTmplData: input.BaseData, - } - return generate.GenerateFile(funcs, reactTmpl, &td) -} - -// Params are parameters for creating a Generator -type Params struct { -} - -// NewGenerator creates a generator for React. -func NewGenerator(params Params) types.Generator { - return &genImpl{} -} diff --git a/internal/generate/plugins/react/react.tmpl b/internal/generate/plugins/react/react.tmpl deleted file mode 100644 index d4927e4..0000000 --- a/internal/generate/plugins/react/react.tmpl +++ /dev/null @@ -1,36 +0,0 @@ -'use client'; - -import { - type ReactFlagEvaluationOptions, - type ReactFlagEvaluationNoSuspenseOptions, - useFlag, - useSuspenseFlag, -} from "@openfeature/react-sdk"; -{{ range .Flags}} -/** -* {{.Docs}} -* -* **Details:** -* - flag key: `{{ .Name}}` -* - default value: `{{ .DefaultValue }}` -* - type: `{{TypeString .Type}}` -*/ -export const use{{FlagVarName .Name}} = (options: ReactFlagEvaluationOptions) => { - return useFlag({{FlagInitParam .Name}}, {{DefaultValueLiteral .}}, options); -}; - -/** -* {{.Docs}} -* -* **Details:** -* - flag key: `{{ .Name}}` -* - default value: `{{ .DefaultValue }}` -* - type: `{{TypeString .Type}}` -* -* Equivalent to useFlag with options: `{ suspend: true }` -* @experimental — Suspense is an experimental feature subject to change in future versions. -*/ -export const useSuspense{{FlagVarName .Name}} = (options: ReactFlagEvaluationNoSuspenseOptions) => { - return useSuspenseFlag({{FlagInitParam .Name}}, {{DefaultValueLiteral .}}, options); -}; -{{ end}} \ No newline at end of file diff --git a/internal/generate/types/types.go b/internal/generate/types/types.go deleted file mode 100644 index 861d2e7..0000000 --- a/internal/generate/types/types.go +++ /dev/null @@ -1,46 +0,0 @@ -// Package types contains all the common types and interfaces for generating flag accessors. -package types - -// FlagType are the primitive types of flags. -type FlagType int - -// Collection of the different kinds of flag types -const ( - UnknownFlagType FlagType = iota - IntType - FloatType - BoolType - StringType - ObjectType -) - -// FlagTmplData is the per-flag specific data. -// It represents a common interface between Mendel source and codegen file output. -type FlagTmplData struct { - Name string - Type FlagType - DefaultValue string - Docs string -} - -// BaseTmplData is the base for all OpenFeature code generation. -type BaseTmplData struct { - OutputPath string - Flags []*FlagTmplData -} - -type TmplDataInterface interface { - // BaseTmplDataInfo returns a pointer to a BaseTmplData struct containing - // all the relevant information needed for metadata construction. - BaseTmplDataInfo() *BaseTmplData -} - -type Input struct { - BaseData *BaseTmplData -} - -// Generator provides interface to generate language specific, strongly-typed flag accessors. -type Generator interface { - Generate(input Input) error - SupportedFlagTypes() map[FlagType]bool -} diff --git a/internal/generators/func.go b/internal/generators/func.go new file mode 100644 index 0000000..ac7b877 --- /dev/null +++ b/internal/generators/func.go @@ -0,0 +1,42 @@ +package generators + +import ( + "strconv" + "strings" + "text/template" + + "github.com/iancoleman/strcase" + "golang.org/x/text/cases" +) + +func defaultFuncs() template.FuncMap { + // Update the contributing doc when adding a new function + return template.FuncMap{ + // Remapping ToCamel to ToPascal to match the expected behavior + // Ref: https://github.com/iancoleman/strcase/issues/53 + "ToPascal": strcase.ToCamel, + // Remapping ToLowerCamel to ToCamel to match the expected behavior + // Ref: See above + "ToCamel": strcase.ToLowerCamel, + "ToKebab": strcase.ToKebab, + "ToScreamingKebab": strcase.ToScreamingKebab, + "ToSnake": strcase.ToSnake, + "ToScreamingSnake": strcase.ToScreamingSnake, + "ToUpper": strings.ToUpper, + "ToLower": strings.ToLower, + "Title": cases.Title, + "Quote": strconv.Quote, + "QuoteString": func (input any) any { + if str, ok := input.(string); ok { + return strconv.Quote(str) + } + return input + }, + } +} + +func init() { + // results in "Api" using ToCamel("API") + // results in "api" using ToLowerCamel("API") + strcase.ConfigureAcronym("API", "api") +} \ No newline at end of file diff --git a/internal/generators/generators.go b/internal/generators/generators.go new file mode 100644 index 0000000..d075b6f --- /dev/null +++ b/internal/generators/generators.go @@ -0,0 +1,64 @@ +package generators + +import ( + "bytes" + "fmt" + "path/filepath" + "text/template" + + "maps" + + "github.com/open-feature/cli/internal/filesystem" + "github.com/open-feature/cli/internal/flagset" +) + +// Represents the stability level of a generator +type Stability string + +const ( + Alpha Stability = "alpha" + Beta Stability = "beta" + Stable Stability = "stable" +) + +type CommonGenerator struct { + Flagset *flagset.Flagset +} + +type Params[T any] struct { + OutputPath string + Custom T +} + +type TemplateData struct { + CommonGenerator + Params[any] +} + +// NewGenerator creates a new generator +func NewGenerator(flagset *flagset.Flagset, UnsupportedFlagTypes map[flagset.FlagType]bool) *CommonGenerator { + return &CommonGenerator{ + Flagset: flagset.Filter(UnsupportedFlagTypes), + } +} + +func (g *CommonGenerator) GenerateFile(customFunc template.FuncMap, tmpl string, params *Params[any], name string) error { + funcs := defaultFuncs() + maps.Copy(funcs, customFunc) + + generatorTemplate, err := template.New("generator").Funcs(funcs).Parse(tmpl) + if err != nil { + return fmt.Errorf("error initializing template: %v", err) + } + + var buf bytes.Buffer + data := TemplateData{ + CommonGenerator: *g, + Params: *params, + } + if err := generatorTemplate.Execute(&buf, data); err != nil { + return fmt.Errorf("error executing template: %v", err) + } + + return filesystem.WriteFile(filepath.Join(params.OutputPath, name), buf.Bytes()) +} diff --git a/internal/generators/golang/golang.go b/internal/generators/golang/golang.go new file mode 100644 index 0000000..1e13dd3 --- /dev/null +++ b/internal/generators/golang/golang.go @@ -0,0 +1,87 @@ +package golang + +import ( + _ "embed" + "sort" + "text/template" + + "github.com/open-feature/cli/internal/flagset" + "github.com/open-feature/cli/internal/generators" +) + +type GolangGenerator struct { + generators.CommonGenerator +} + +type Params struct { + GoPackage string +} + +//go:embed golang.tmpl +var golangTmpl string + +func openFeatureType(t flagset.FlagType) string { + switch t { + case flagset.IntType: + return "Int" + case flagset.FloatType: + return "Float" + case flagset.BoolType: + return "Boolean" + case flagset.StringType: + return "String" + default: + return "" + } +} + +func typeString(flagType flagset.FlagType) string { + switch flagType { + case flagset.StringType: + return "string" + case flagset.IntType: + return "int64" + case flagset.BoolType: + return "bool" + case flagset.FloatType: + return "float64" + default: + return "" + } +} + +func supportImports(flags []flagset.Flag) []string { + var res []string + if len(flags) > 0 { + res = append(res, "\"context\"") + res = append(res, "\"github.com/open-feature/go-sdk/openfeature\"") + } + sort.Strings(res) + return res +} + +func (g *GolangGenerator) Generate(params *generators.Params[Params]) error { + funcs := template.FuncMap{ + "SupportImports": supportImports, + "OpenFeatureType": openFeatureType, + "TypeString": typeString, + } + + newParams := &generators.Params[any]{ + OutputPath: params.OutputPath, + Custom: Params{ + GoPackage: params.Custom.GoPackage, + }, + } + + return g.GenerateFile(funcs, golangTmpl, newParams, params.Custom.GoPackage+".go") +} + +// NewGenerator creates a generator for Go. +func NewGenerator(fs *flagset.Flagset) *GolangGenerator { + return &GolangGenerator{ + CommonGenerator: *generators.NewGenerator(fs, map[flagset.FlagType]bool{ + flagset.ObjectType: true, + }), + } +} diff --git a/internal/generate/plugins/golang/golang.tmpl b/internal/generators/golang/golang.tmpl similarity index 58% rename from internal/generate/plugins/golang/golang.tmpl rename to internal/generators/golang/golang.tmpl index be1ee8a..d297d76 100644 --- a/internal/generate/plugins/golang/golang.tmpl +++ b/internal/generators/golang/golang.tmpl @@ -1,8 +1,8 @@ -// AUTOMATICALLY GENERATED BY OPENFEATURE CODEGEN, DO NOT EDIT. -package {{.GoPackage}} +// AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. +package {{ .Params.Custom.GoPackage }} import ( -{{- range $_, $p := SupportImports .Flags}} +{{- range $_, $p := SupportImports .Flagset.Flags}} {{$p}} {{- end}} ) @@ -18,27 +18,26 @@ type StringProviderDetails func(ctx context.Context, evalCtx openfeature.Evaluat var client openfeature.IClient = nil - -{{- range .Flags}} -// {{.Docs}} -var {{FlagVarName .Name}} = struct { - // Value returns the value of the flag {{FlagVarName .Name}}, +{{- range .Flagset.Flags }} +// {{.Description}} +var {{ .Key | ToPascal }} = struct { + // Value returns the value of the flag {{ .Key | ToPascal }}, // as well as the evaluation error, if present. - Value {{OpenFeatureType .Type}}Provider + Value {{ .Type | OpenFeatureType }}Provider - // ValueWithDetails returns the value of the flag {{FlagVarName .Name}}, + // ValueWithDetails returns the value of the flag {{ .Key | ToPascal }}, // the evaluation error, if any, and the evaluation details. - ValueWithDetails {{OpenFeatureType .Type}}ProviderDetails + ValueWithDetails {{ .Type | OpenFeatureType }}ProviderDetails }{ - Value: func(ctx context.Context, evalCtx openfeature.EvaluationContext) ({{TypeString .Type}}, error) { - return client.{{OpenFeatureType .Type}}Value(ctx, {{FlagInitParam .Name}}, {{DefaultValueLiteral .}}, evalCtx) + Value: func(ctx context.Context, evalCtx openfeature.EvaluationContext) ({{ .Type | TypeString }}, error) { + return client.{{ .Type | OpenFeatureType }}Value(ctx, {{ .Key | Quote }}, {{ .DefaultValue | QuoteString }}, evalCtx) }, - ValueWithDetails: func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.{{OpenFeatureType .Type}}EvaluationDetails, error){ - return client.{{OpenFeatureType .Type}}ValueDetails(ctx, {{FlagInitParam .Name}}, {{DefaultValueLiteral .}}, evalCtx) + ValueWithDetails: func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.{{ .Type | OpenFeatureType }}EvaluationDetails, error){ + return client.{{ .Type | OpenFeatureType }}ValueDetails(ctx, {{ .Key | Quote }}, {{ .DefaultValue | QuoteString }}, evalCtx) }, } {{- end}} func init() { - client = openfeature.GetApiInstance().GetClient() + client = openfeature.GetApiInstance().GetClient() } diff --git a/internal/generators/manager.go b/internal/generators/manager.go new file mode 100644 index 0000000..d6c1c8f --- /dev/null +++ b/internal/generators/manager.go @@ -0,0 +1,86 @@ +package generators + +import ( + "sort" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +// GeneratorCreator is a function that creates a generator command +type GeneratorCreator func() *cobra.Command + +// GeneratorInfo contains metadata about a generator +type GeneratorInfo struct { + Name string + Description string + Stability Stability + Creator GeneratorCreator +} + +// GeneratorManager maintains a registry of available generators +type GeneratorManager struct { + generators map[string]GeneratorInfo +} + +// NewGeneratorManager creates a new generator manager +func NewGeneratorManager() *GeneratorManager { + return &GeneratorManager{ + generators: make(map[string]GeneratorInfo), + } +} + +// Register adds a generator to the registry +func (m *GeneratorManager) Register(cmdCreator func() *cobra.Command) { + cmd := cmdCreator() + m.generators[cmd.Use] = GeneratorInfo{ + Name: cmd.Use, + Description: cmd.Short, + Stability: Stability(cmd.Annotations["stability"]), + Creator: cmdCreator, + } +} + +// GetAll returns all registered generators +func (m *GeneratorManager) GetAll() map[string]GeneratorInfo { + return m.generators +} + +// GetCommands returns cobra commands for all registered generators +func (m *GeneratorManager) GetCommands() []*cobra.Command { + var commands []*cobra.Command + + for _, info := range m.generators { + commands = append(commands, info.Creator()) + } + + return commands +} + +// PrintGeneratorsTable prints a table of all available generators with their stability +func (m *GeneratorManager) PrintGeneratorsTable() error { + tableData := [][]string{ + {"Generator", "Description", "Stability"}, + } + + // Get generator names for consistent ordering + var names []string + for name := range m.generators { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + info := m.generators[name] + tableData = append(tableData, []string{ + name, + info.Description, + string(info.Stability), + }) + } + + return pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() +} + +// DefaultManager is the default instance of the generator manager +var DefaultManager = NewGeneratorManager() diff --git a/internal/generators/react/react.go b/internal/generators/react/react.go new file mode 100644 index 0000000..0f8c3cc --- /dev/null +++ b/internal/generators/react/react.go @@ -0,0 +1,56 @@ +package react + +import ( + _ "embed" + "text/template" + + "github.com/open-feature/cli/internal/flagset" + "github.com/open-feature/cli/internal/generators" +) + +type ReactGenerator struct { + generators.CommonGenerator +} + +type Params struct { +} + +//go:embed react.tmpl +var reactTmpl string + +func openFeatureType(t flagset.FlagType) string { + switch t { + case flagset.IntType: + fallthrough + case flagset.FloatType: + return "number" + case flagset.BoolType: + return "boolean" + case flagset.StringType: + return "string" + default: + return "" + } +} + +func (g *ReactGenerator) Generate(params *generators.Params[Params]) error { + funcs := template.FuncMap{ + "OpenFeatureType": openFeatureType, + } + + newParams := &generators.Params[any]{ + OutputPath: params.OutputPath, + Custom: Params{}, + } + + return g.GenerateFile(funcs, reactTmpl, newParams, "openfeature.ts") +} + +// NewGenerator creates a generator for React. +func NewGenerator(fs *flagset.Flagset) *ReactGenerator { + return &ReactGenerator{ + CommonGenerator: *generators.NewGenerator(fs, map[flagset.FlagType]bool{ + flagset.ObjectType: true, + }), + } +} diff --git a/internal/generators/react/react.tmpl b/internal/generators/react/react.tmpl new file mode 100644 index 0000000..e1c40cf --- /dev/null +++ b/internal/generators/react/react.tmpl @@ -0,0 +1,36 @@ +'use client'; + +import { + type ReactFlagEvaluationOptions, + type ReactFlagEvaluationNoSuspenseOptions, + useFlag, + useSuspenseFlag, +} from "@openfeature/react-sdk"; +{{ range .Flagset.Flags }} +/** +* {{ .Description }} +* +* **Details:** +* - flag key: `{{ .Key }}` +* - default value: `{{ .DefaultValue }}` +* - type: `{{ .Type | OpenFeatureType }}` +*/ +export const use{{ .Key | ToPascal }} = (options?: ReactFlagEvaluationOptions) => { + return useFlag({{ .Key | Quote }}, {{ .DefaultValue | QuoteString }}, options); +}; + +/** +* {{ .Description }} +* +* **Details:** +* - flag key: `{{ .Key }}` +* - default value: `{{ .DefaultValue }}` +* - type: `{{ .Type | OpenFeatureType }}` +* +* Equivalent to useFlag with options: `{ suspend: true }` +* @experimental — Suspense is an experimental feature subject to change in future versions. +*/ +export const useSuspense{{ .Key | ToPascal }} = (options?: ReactFlagEvaluationNoSuspenseOptions) => { + return useSuspenseFlag({{ .Key | Quote }}, {{ .DefaultValue | QuoteString }}, options); +}; +{{ end}} \ No newline at end of file diff --git a/internal/manifest/compare.go b/internal/manifest/compare.go new file mode 100644 index 0000000..a59ec13 --- /dev/null +++ b/internal/manifest/compare.go @@ -0,0 +1,50 @@ +package manifest + +import ( + "fmt" + "reflect" +) + +type Change struct { + Type string `json:"type"` + Path string `json:"path"` + OldValue any `json:"oldValue,omitempty"` + NewValue any `json:"newValue,omitempty"` +} + +func Compare(oldManifest, newManifest *Manifest) ([]Change, error) { + var changes []Change + oldFlags := oldManifest.Flags + newFlags := newManifest.Flags + + for key, newFlag := range newFlags { + if oldFlag, exists := oldFlags[key]; exists { + if !reflect.DeepEqual(oldFlag, newFlag) { + changes = append(changes, Change{ + Type: "change", + Path: fmt.Sprintf("flags.%s", key), + OldValue: oldFlag, + NewValue: newFlag, + }) + } + } else { + changes = append(changes, Change{ + Type: "add", + Path: fmt.Sprintf("flags.%s", key), + NewValue: newFlag, + }) + } + } + + for key, oldFlag := range oldFlags { + if _, exists := newFlags[key]; !exists { + changes = append(changes, Change{ + Type: "remove", + Path: fmt.Sprintf("flags.%s", key), + OldValue: oldFlag, + }) + } + } + + return changes, nil +} diff --git a/internal/manifest/json-schema.go b/internal/manifest/json-schema.go new file mode 100644 index 0000000..4ba383b --- /dev/null +++ b/internal/manifest/json-schema.go @@ -0,0 +1,124 @@ +package manifest + +import ( + "log" + + "github.com/invopop/jsonschema" + "github.com/pterm/pterm" +) + +type BooleanFlag struct { + BaseFlag + // The type of feature flag (e.g., boolean, string, integer, float) + Type string `json:"flagType,omitempty" jsonschema:"enum=boolean"` + // The value returned from an unsuccessful flag evaluation + DefaultValue bool `json:"defaultValue,omitempty"` +} + +type StringFlag struct { + BaseFlag + // The type of feature flag (e.g., boolean, string, integer, float) + Type string `json:"flagType,omitempty" jsonschema:"enum=string"` + // The value returned from an unsuccessful flag evaluation + DefaultValue string `json:"defaultValue,omitempty"` +} + +type IntegerFlag struct { + BaseFlag + // The type of feature flag (e.g., boolean, string, integer, float) + Type string `json:"flagType,omitempty" jsonschema:"enum=integer"` + // The value returned from an unsuccessful flag evaluation + DefaultValue int `json:"defaultValue,omitempty"` +} + +type FloatFlag struct { + BaseFlag + // The type of feature flag (e.g., boolean, string, integer, float) + Type string `json:"flagType,omitempty" jsonschema:"enum=float"` + // The value returned from an unsuccessful flag evaluation + DefaultValue float64 `json:"defaultValue,omitempty"` +} + +type ObjectFlag struct { + BaseFlag + // The type of feature flag (e.g., boolean, string, integer, float) + Type string `json:"flagType,omitempty" jsonschema:"enum=object"` + // The value returned from an unsuccessful flag evaluation + DefaultValue any `json:"defaultValue,omitempty"` +} + +type BaseFlag struct { + // The type of feature flag (e.g., boolean, string, integer, float) + Type string `json:"flagType,omitempty" jsonschema:"required"` + // A concise description of this feature flag's purpose. + Description string `json:"description,omitempty"` +} + +// Feature flag manifest for the OpenFeature CLI +type Manifest struct { + // Collection of feature flag definitions + Flags map[string]any `json:"flags" jsonschema:"title=Flags,required"` +} + +// Converts the Manifest struct to a JSON schema. +func ToJSONSchema() *jsonschema.Schema { + reflector := &jsonschema.Reflector{ + ExpandedStruct: true, + AllowAdditionalProperties: true, + BaseSchemaID: "openfeature-cli", + } + + if err := reflector.AddGoComments("github.com/open-feature/cli", "./internal/manifest"); err != nil { + pterm.Error.Printf("Error extracting comments from types.go: %v\n", err) + } + + schema := reflector.Reflect(Manifest{}) + schema.Version = "http://json-schema.org/draft-07/schema#" + schema.Title = "OpenFeature CLI Manifest" + flags, ok := schema.Properties.Get("flags") + if !ok { + log.Fatal("flags not found") + } + flags.PatternProperties = map[string]*jsonschema.Schema{ + "^.{1,}$": { + Ref: "#/$defs/flag", + }, + } + // We only want flags keys that matches the pattern properties + flags.AdditionalProperties = jsonschema.FalseSchema + + schema.Definitions = jsonschema.Definitions{ + "flag": &jsonschema.Schema{ + OneOf: []*jsonschema.Schema{ + {Ref: "#/$defs/booleanFlag"}, + {Ref: "#/$defs/stringFlag"}, + {Ref: "#/$defs/integerFlag"}, + {Ref: "#/$defs/floatFlag"}, + {Ref: "#/$defs/objectFlag"}, + }, + Required: []string{"flagType", "defaultValue"}, + }, + "booleanFlag": &jsonschema.Schema{ + Type: "object", + Properties: reflector.Reflect(BooleanFlag{}).Properties, + }, + "stringFlag": &jsonschema.Schema{ + Type: "object", + Properties: reflector.Reflect(StringFlag{}).Properties, + }, + "integerFlag": &jsonschema.Schema{ + Type: "object", + Properties: reflector.Reflect(IntegerFlag{}).Properties, + }, + "floatFlag": &jsonschema.Schema{ + Type: "object", + Properties: reflector.Reflect(FloatFlag{}).Properties, + }, + "objectFlag": &jsonschema.Schema{ + Type: "object", + Properties: reflector.Reflect(ObjectFlag{}).Properties, + }, + } + + return schema +} \ No newline at end of file diff --git a/internal/manifest/manage.go b/internal/manifest/manage.go new file mode 100644 index 0000000..29dc053 --- /dev/null +++ b/internal/manifest/manage.go @@ -0,0 +1,27 @@ +package manifest + +import ( + "encoding/json" + + "github.com/open-feature/cli/internal/filesystem" +) + +type initManifest struct { + Schema string `json:"$schema,omitempty"` + Manifest +} + +// Create creates a new manifest file at the given path. +func Create(path string) error { + m := &initManifest{ + Schema: "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag_manifest.json", + Manifest: Manifest{ + Flags: map[string]any{}, + }, + } + formattedInitManifest, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + return filesystem.WriteFile(path, formattedInitManifest) +} diff --git a/internal/manifest/validate.go b/internal/manifest/validate.go new file mode 100644 index 0000000..1f2be4d --- /dev/null +++ b/internal/manifest/validate.go @@ -0,0 +1,44 @@ +package manifest + +import ( + "fmt" + "strings" + + "github.com/open-feature/cli/schema/v0" + "github.com/xeipuuv/gojsonschema" +) + +type ValidationError struct { + Type string `json:"type"` + Path string `json:"path"` + Message string `json:"message"` +} + +func Validate(data []byte) ([]ValidationError, error) { + schemaLoader := gojsonschema.NewStringLoader(schema.SchemaFile) + manifestLoader := gojsonschema.NewBytesLoader(data) + + result, err := gojsonschema.Validate(schemaLoader, manifestLoader) + if err != nil { + return nil, fmt.Errorf("failed to validate manifest: %w", err) + } + + var issues []ValidationError + for _, err := range result.Errors() { + if strings.HasPrefix(err.Field(), "flags") && err.Type() == "number_one_of" { + issues = append(issues, ValidationError{ + Type: err.Type(), + Path: err.Field(), + Message: "flagType must be 'boolean', 'string', 'integer', 'float', or 'object'", + }) + } else { + issues = append(issues, ValidationError{ + Type: err.Type(), + Path: err.Field(), + Message: err.Description(), + }) + } + } + + return issues, nil +} diff --git a/sample/sample_manifest.json b/sample/sample_manifest.json index 1a65d68..81a86dc 100644 --- a/sample/sample_manifest.json +++ b/sample/sample_manifest.json @@ -1,4 +1,5 @@ { + "$schema": "../schema/v0/flag-manifest.json", "flags": { "enableFeatureA": { "flagType": "boolean", diff --git a/schema/generate-schema.go b/schema/generate-schema.go new file mode 100644 index 0000000..7c5f31c --- /dev/null +++ b/schema/generate-schema.go @@ -0,0 +1,36 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + + "github.com/open-feature/cli/internal/manifest" +) + +const schemaPath = "schema/v0/flag-manifest.json" + +func main() { + schema := manifest.ToJSONSchema() + data, err := json.MarshalIndent(schema, "", " ") + if err != nil { + log.Fatal(fmt.Errorf("failed to marshal JSON schema: %w", err)) + } + + if err := os.MkdirAll("schema/v0", os.ModePerm); err != nil { + log.Fatal(fmt.Errorf("failed to create directory: %w", err)) + } + + file, err := os.Create(schemaPath) + if err != nil { + log.Fatal(fmt.Errorf("failed to create file: %w", err)) + } + defer file.Close() + + if _, err := file.Write(data); err != nil { + log.Fatal(fmt.Errorf("failed to write JSON schema to file: %w", err)); + } + + fmt.Println("JSON schema generated successfully at " + schemaPath) +} \ No newline at end of file diff --git a/schema/v0/flag-manifest.json b/schema/v0/flag-manifest.json new file mode 100644 index 0000000..261d84e --- /dev/null +++ b/schema/v0/flag-manifest.json @@ -0,0 +1,147 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "openfeature-cli/manifest", + "$defs": { + "booleanFlag": { + "properties": { + "flagType": { + "type": "string", + "enum": [ + "boolean" + ], + "description": "The type of feature flag (e.g., boolean, string, integer, float)" + }, + "description": { + "type": "string", + "description": "A concise description of this feature flag's purpose." + }, + "defaultValue": { + "type": "boolean", + "description": "The value returned from an unsuccessful flag evaluation" + } + }, + "type": "object" + }, + "flag": { + "oneOf": [ + { + "$ref": "#/$defs/booleanFlag" + }, + { + "$ref": "#/$defs/stringFlag" + }, + { + "$ref": "#/$defs/integerFlag" + }, + { + "$ref": "#/$defs/floatFlag" + }, + { + "$ref": "#/$defs/objectFlag" + } + ], + "required": [ + "flagType", + "defaultValue" + ] + }, + "floatFlag": { + "properties": { + "flagType": { + "type": "string", + "enum": [ + "float" + ], + "description": "The type of feature flag (e.g., boolean, string, integer, float)" + }, + "description": { + "type": "string", + "description": "A concise description of this feature flag's purpose." + }, + "defaultValue": { + "type": "number", + "description": "The value returned from an unsuccessful flag evaluation" + } + }, + "type": "object" + }, + "integerFlag": { + "properties": { + "flagType": { + "type": "string", + "enum": [ + "integer" + ], + "description": "The type of feature flag (e.g., boolean, string, integer, float)" + }, + "description": { + "type": "string", + "description": "A concise description of this feature flag's purpose." + }, + "defaultValue": { + "type": "integer", + "description": "The value returned from an unsuccessful flag evaluation" + } + }, + "type": "object" + }, + "objectFlag": { + "properties": { + "flagType": { + "type": "string", + "enum": [ + "object" + ], + "description": "The type of feature flag (e.g., boolean, string, integer, float)" + }, + "description": { + "type": "string", + "description": "A concise description of this feature flag's purpose." + }, + "defaultValue": { + "description": "The value returned from an unsuccessful flag evaluation" + } + }, + "type": "object" + }, + "stringFlag": { + "properties": { + "flagType": { + "type": "string", + "enum": [ + "string" + ], + "description": "The type of feature flag (e.g., boolean, string, integer, float)" + }, + "description": { + "type": "string", + "description": "A concise description of this feature flag's purpose." + }, + "defaultValue": { + "type": "string", + "description": "The value returned from an unsuccessful flag evaluation" + } + }, + "type": "object" + } + }, + "properties": { + "flags": { + "patternProperties": { + "^.{1,}$": { + "$ref": "#/$defs/flag" + } + }, + "additionalProperties": false, + "type": "object", + "title": "Flags", + "description": "Collection of feature flag definitions" + } + }, + "type": "object", + "required": [ + "flags" + ], + "title": "OpenFeature CLI Manifest", + "description": "Feature flag manifest for the OpenFeature CLI" +} \ No newline at end of file diff --git a/schema/v0/flag_manifest.json b/schema/v0/flag_manifest.json deleted file mode 100644 index 24eb811..0000000 --- a/schema/v0/flag_manifest.json +++ /dev/null @@ -1,135 +0,0 @@ -{ - "$id": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag_manifest.json", - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Flag Manifest", - "description": "Describes a configuration of OpenFeature flags, including info such as their types and default values.", - "type": "object", - "properties": { - "flags": { - "description": "Object containing the flags in the config", - "type": "object", - "patternProperties": { - "^.{1,}$": { - "description": "The definition of one flag", - "$ref": "#/$defs/flag" - } - }, - "additionalProperties": false, - "minProperties": 1 - } - }, - "required": [ - "flags" - ], - "$defs": { - "flag": { - "oneOf": [ - { - "$ref": "#/$defs/booleanType" - }, - { - "$ref": "#/$defs/stringType" - }, - { - "$ref": "#/$defs/integerType" - }, - { - "$ref": "#/$defs/floatType" - }, - { - "$ref": "#/$defs/objectType" - } - ], - "required": [ - "flagType", - "defaultValue" - ] - }, - "booleanType": { - "type": "object", - "properties": { - "flagType": { - "type": "string", - "enum": [ - "boolean" - ] - }, - "defaultValue": { - "description": "The default value returned in code if a flag evaluation is unsuccessful", - "type": "boolean" - }, - "description": { - "type": "string" - } - } - }, - "stringType": { - "type": "object", - "properties": { - "flagType": { - "type": "string", - "enum": [ - "string" - ] - }, - "defaultValue": { - "type": "string" - }, - "description": { - "type": "string" - } - } - }, - "integerType": { - "type": "object", - "properties": { - "flagType": { - "type": "string", - "enum": [ - "integer" - ] - }, - "defaultValue": { - "type": "integer" - }, - "description": { - "type": "string" - } - } - }, - "floatType": { - "type": "object", - "properties": { - "flagType": { - "type": "string", - "enum": [ - "float" - ] - }, - "defaultValue": { - "type": "number" - }, - "description": { - "type": "string" - } - } - }, - "objectType": { - "type": "object", - "properties": { - "flagType": { - "type": "string", - "enum": [ - "object" - ] - }, - "defaultValue": { - "type": "object" - }, - "description": { - "type": "string" - } - } - } - } -} \ No newline at end of file diff --git a/schema/v0/flagmanifest.go b/schema/v0/flagmanifest.go deleted file mode 100644 index 8d0a596..0000000 --- a/schema/v0/flagmanifest.go +++ /dev/null @@ -1,12 +0,0 @@ -// Package flagmanifest embeds the flag manifest into a code module. -package flagmanifest - -import _ "embed" - -// Schema contains the embedded flag manifest schema. -// -//go:embed flag_manifest.json -var Schema string - -// SchemaPath provides the current path and version of flag manifest. -const SchemaPath = "github.com/open-feature/cli/schema/v0/flag_manifest.json" diff --git a/schema/v0/flagmanifest_test.go b/schema/v0/flagmanifest_test.go deleted file mode 100644 index cfffa27..0000000 --- a/schema/v0/flagmanifest_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package flagmanifest - -import ( - "encoding/json" - "fmt" - "log" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/santhosh-tekuri/jsonschema/v5" -) - -var compiledFlagManifestSchema *jsonschema.Schema - -func init() { - sch, err := jsonschema.CompileString(SchemaPath, Schema) - if err != nil { - log.Fatal(fmt.Errorf("error compiling JSON schema: %v", err)) - } - compiledFlagManifestSchema = sch -} - -func TestPositiveFlagManifest(t *testing.T) { - if err := walkPath(true, "./tests/positive"); err != nil { - t.Error(err) - t.FailNow() - } -} - -func TestNegativeFlagManifest(t *testing.T) { - if err := walkPath(false, "./tests/negative"); err != nil { - t.Error(err) - t.FailNow() - } -} - - -func walkPath(shouldPass bool, root string) error { - return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - ps := strings.Split(path, ".") - if ps[len(ps)-1] != "json" { - return nil - } - - file, err := os.ReadFile(path) - if err != nil { - return err - } - - var v interface{} - if err := json.Unmarshal([]byte(file), &v); err != nil { - log.Fatal(err) - } - - err = compiledFlagManifestSchema.Validate(v) - - if (err != nil && shouldPass == true) { - return fmt.Errorf("file %s should not have failed validation, but did: %s", path, err) - } - - if (err == nil && shouldPass == false) { - return fmt.Errorf("file %s should have failed validation, but did not", path) - } - - return nil - }) -} diff --git a/schema/v0/schema.go b/schema/v0/schema.go new file mode 100644 index 0000000..5253cf9 --- /dev/null +++ b/schema/v0/schema.go @@ -0,0 +1,9 @@ +// Package schema embeds the flag manifest into a code module. +package schema + +import _ "embed" + +// Schema contains the embedded flag manifest schema. +// +//go:embed flag-manifest.json +var SchemaFile string diff --git a/schema/v0/schema_test.go b/schema/v0/schema_test.go new file mode 100644 index 0000000..05d039f --- /dev/null +++ b/schema/v0/schema_test.go @@ -0,0 +1,72 @@ +package schema + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/xeipuuv/gojsonschema" +) + +func TestPositiveFlagManifest(t *testing.T) { + if err := walkPath(true, "./testdata/positive"); err != nil { + t.Error(err) + t.FailNow() + } +} + +func TestNegativeFlagManifest(t *testing.T) { + if err := walkPath(false, "./testdata/negative"); err != nil { + t.Error(err) + t.FailNow() + } +} + +func walkPath(shouldPass bool, root string) error { + return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + ps := strings.Split(path, ".") + if ps[len(ps)-1] != "json" { + return nil + } + + file, err := os.ReadFile(path) + if err != nil { + return err + } + + var v any + if err := json.Unmarshal([]byte(file), &v); err != nil { + log.Fatal(err) + } + + schemaLoader := gojsonschema.NewStringLoader(SchemaFile) + manifestLoader := gojsonschema.NewGoLoader(v) + result, err := gojsonschema.Validate(schemaLoader, manifestLoader) + if (err != nil) { + return fmt.Errorf("Error validating json schema: %v", err) + } + + if (len(result.Errors()) >= 1 && shouldPass == true) { + var errorMessage strings.Builder + + errorMessage.WriteString("file " + path + " should be valid, but had the following issues:\n") + for _, error := range result.Errors() { + errorMessage.WriteString(" - " + error.String() + "\n") + } + return fmt.Errorf("%s", errorMessage.String()) + } + + if (len(result.Errors()) == 0 && shouldPass == false) { + return fmt.Errorf("file %s should be invalid, but no issues were detected", path) + } + + return nil + }) +} diff --git a/schema/v0/testdata/negative/empty-flag-key.json b/schema/v0/testdata/negative/empty-flag-key.json new file mode 100644 index 0000000..a7ebfb8 --- /dev/null +++ b/schema/v0/testdata/negative/empty-flag-key.json @@ -0,0 +1,10 @@ +{ + "$schema": "../../flag-manifest.json", + "flags": { + "": { + "flagType": "boolean", + "defaultValue": true, + "description": "A flag that tests the invalid character case in flag keys." + } + } +} \ No newline at end of file diff --git a/schema/v0/tests/negative/missing-flag-type.json b/schema/v0/testdata/negative/missing-flag-type.json similarity index 63% rename from schema/v0/tests/negative/missing-flag-type.json rename to schema/v0/testdata/negative/missing-flag-type.json index 3e37e76..e0855dd 100644 --- a/schema/v0/tests/negative/missing-flag-type.json +++ b/schema/v0/testdata/negative/missing-flag-type.json @@ -1,5 +1,5 @@ { - "$schema": "../../flag_manifest.json", + "$schema": "../../flag-manifest.json", "flags": { "booleanFlag": { "codeDefault": true diff --git a/schema/v0/tests/positive/min-flag-manifest.json b/schema/v0/testdata/positive/min-flag-manifest.json similarity index 92% rename from schema/v0/tests/positive/min-flag-manifest.json rename to schema/v0/testdata/positive/min-flag-manifest.json index 2a91cff..9831d3a 100644 --- a/schema/v0/tests/positive/min-flag-manifest.json +++ b/schema/v0/testdata/positive/min-flag-manifest.json @@ -1,5 +1,5 @@ { - "$schema": "../../flag_manifest.json", + "$schema": "../../flag-manifest.json", "flags": { "booleanFlag": { "flagType": "boolean", diff --git a/schema/v0/tests/negative/no-flags-in-manifest.json b/schema/v0/tests/negative/no-flags-in-manifest.json deleted file mode 100644 index 818c777..0000000 --- a/schema/v0/tests/negative/no-flags-in-manifest.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "../../flag_manifest.json", - "flags": {} -} \ No newline at end of file