From 2965aacb899a13ae7f0dba1ed7b3dc817f31a2b0 Mon Sep 17 00:00:00 2001 From: Michael Beemer Date: Fri, 28 Mar 2025 16:34:49 -0400 Subject: [PATCH 1/7] feat: add nodejs generator Signed-off-by: Michael Beemer --- cmd/generate.go | 50 ++++++++++++++++++++--- internal/generators/nodejs/nodejs.go | 56 ++++++++++++++++++++++++++ internal/generators/nodejs/nodejs.tmpl | 37 +++++++++++++++++ 3 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 internal/generators/nodejs/nodejs.go create mode 100644 internal/generators/nodejs/nodejs.tmpl diff --git a/cmd/generate.go b/cmd/generate.go index bb40938..38dab3d 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -7,6 +7,7 @@ import ( "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/nodejs" "github.com/open-feature/cli/internal/generators/react" "github.com/spf13/cobra" ) @@ -16,13 +17,13 @@ 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:", + "Stability: "+stability+"\n\nUsage:", 1, // Replace only the first occurrence ) cmd.SetUsageTemplate(customTemplate) @@ -34,6 +35,44 @@ func addStabilityInfo(cmd *cobra.Command) { } } +func GetGenerateNodeJSCmd() *cobra.Command { + reactCmd := &cobra.Command{ + Use: "nodejs", + Short: "Generate typesafe Node.js client.", + Long: `Generate typesafe Node.js client compatible with the OpenFeature JavaScript Server SDK.`, + Annotations: map[string]string{ + "stability": string(generators.Alpha), + }, + PreRunE: func(cmd *cobra.Command, args []string) error { + return initializeConfig(cmd, "generate.nodejs") + }, + RunE: func(cmd *cobra.Command, args []string) error { + manifestPath := config.GetManifestPath(cmd) + outputPath := config.GetOutputPath(cmd) + + params := generators.Params[nodejs.Params]{ + OutputPath: outputPath, + Custom: nodejs.Params{}, + } + flagset, err := flagset.Load(manifestPath) + if err != nil { + return err + } + + generator := nodejs.NewGenerator(flagset) + err = generator.Generate(¶ms) + if err != nil { + return err + } + return nil + }, + } + + addStabilityInfo(reactCmd) + + return reactCmd +} + func GetGenerateReactCmd() *cobra.Command { reactCmd := &cobra.Command{ Use: "react", @@ -51,7 +90,7 @@ func GetGenerateReactCmd() *cobra.Command { params := generators.Params[react.Params]{ OutputPath: outputPath, - Custom: react.Params{}, + Custom: react.Params{}, } flagset, err := flagset.Load(manifestPath) if err != nil { @@ -66,7 +105,7 @@ func GetGenerateReactCmd() *cobra.Command { return nil }, } - + addStabilityInfo(reactCmd) return reactCmd @@ -111,7 +150,7 @@ func GetGenerateGoCmd() *cobra.Command { // Add Go-specific flags config.AddGoGenerateFlags(goCmd) - + addStabilityInfo(goCmd) return goCmd @@ -121,6 +160,7 @@ func init() { // Register generators with the manager generators.DefaultManager.Register(GetGenerateReactCmd) generators.DefaultManager.Register(GetGenerateGoCmd) + generators.DefaultManager.Register(GetGenerateNodeJSCmd) } func GetGenerateCmd() *cobra.Command { diff --git a/internal/generators/nodejs/nodejs.go b/internal/generators/nodejs/nodejs.go new file mode 100644 index 0000000..e5b646a --- /dev/null +++ b/internal/generators/nodejs/nodejs.go @@ -0,0 +1,56 @@ +package nodejs + +import ( + _ "embed" + "text/template" + + "github.com/open-feature/cli/internal/flagset" + "github.com/open-feature/cli/internal/generators" +) + +type NodejsGenerator struct { + generators.CommonGenerator +} + +type Params struct { +} + +//go:embed nodejs.tmpl +var nodejsTmpl 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 *NodejsGenerator) Generate(params *generators.Params[Params]) error { + funcs := template.FuncMap{ + "OpenFeatureType": openFeatureType, + } + + newParams := &generators.Params[any]{ + OutputPath: params.OutputPath, + Custom: Params{}, + } + + return g.GenerateFile(funcs, nodejsTmpl, newParams, "openfeature.ts") +} + +// NewGenerator creates a generator for NodeJS. +func NewGenerator(fs *flagset.Flagset) *NodejsGenerator { + return &NodejsGenerator{ + CommonGenerator: *generators.NewGenerator(fs, map[flagset.FlagType]bool{ + flagset.ObjectType: true, + }), + } +} diff --git a/internal/generators/nodejs/nodejs.tmpl b/internal/generators/nodejs/nodejs.tmpl new file mode 100644 index 0000000..0a9559d --- /dev/null +++ b/internal/generators/nodejs/nodejs.tmpl @@ -0,0 +1,37 @@ +import { + OpenFeature, +} from "@openfeature/server-sdk"; +import type { + EvaluationContext, + EvaluationDetails, +} from "@openfeature/server-sdk"; + +export interface GeneratedClient { +{{ range .Flagset.Flags }} + /** + * {{ .Description }} + * + * **Details:** + * - flag key: `{{ .Key }}` + * - default value: `{{ .DefaultValue }}` + * - type: `{{ .Type | OpenFeatureType }}` + */ + {{ .Key | ToCamel }}(context?: EvaluationContext): Promise<{{ .Type | OpenFeatureType }}>; +{{ end}} +} + +/** + * A client generated by the OpenFeature cli that's compatible + * with the `@openfeature/server-sdk` and supported providers. + */ +export function getGeneratedClient(context?: EvaluationContext): GeneratedClient { + const client = OpenFeature.getClient(context) + + return { +{{ range .Flagset.Flags }} + {{ .Key | ToCamel }}: (context?: EvaluationContext): Promise<{{ .Type | OpenFeatureType }}> => { + client.getBooleanValue({{ .Key | Quote }}, {{ .DefaultValue | QuoteString }}, context); + } +{{ end}} + } +} \ No newline at end of file From 105289586d7923aa9abd73f7459268d98c38db83 Mon Sep 17 00:00:00 2001 From: Michael Beemer Date: Sun, 30 Mar 2025 00:14:46 +0000 Subject: [PATCH 2/7] feat: add a nodejs generator Signed-off-by: Michael Beemer --- cmd/generate_test.go | 17 +- cmd/testdata/success_nodejs.golden | 196 +++++++++++++++++++ docs/commands/openfeature_generate.md | 1 + docs/commands/openfeature_generate_nodejs.md | 32 +++ internal/generators/nodejs/nodejs.tmpl | 75 +++++-- 5 files changed, 306 insertions(+), 15 deletions(-) create mode 100644 cmd/testdata/success_nodejs.golden create mode 100644 docs/commands/openfeature_generate_nodejs.md diff --git a/cmd/generate_test.go b/cmd/generate_test.go index 0071b40..086d18b 100644 --- a/cmd/generate_test.go +++ b/cmd/generate_test.go @@ -3,6 +3,7 @@ package cmd import ( "os" "path/filepath" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -40,6 +41,13 @@ func TestGenerate(t *testing.T) { outputGolden: "testdata/success_react.golden", outputFile: "openfeature.ts", }, + { + name: "NodeJS generation success", + command: "nodejs", + manifestGolden: "testdata/success_manifest.golden", + outputGolden: "testdata/success_nodejs.golden", + outputFile: "openfeature.ts", + }, // Add more test cases here as needed } @@ -116,13 +124,18 @@ 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 != "" { + + // Convert to string arrays by splitting on newlines + wantLines := strings.Split(string(want), "\n") + gotLines := strings.Split(string(got), "\n") + + if diff := cmp.Diff(wantLines, gotLines); diff != "" { t.Errorf("output mismatch (-want +got):\n%s", diff) } } diff --git a/cmd/testdata/success_nodejs.golden b/cmd/testdata/success_nodejs.golden new file mode 100644 index 0000000..0082564 --- /dev/null +++ b/cmd/testdata/success_nodejs.golden @@ -0,0 +1,196 @@ +// AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. +import { + OpenFeature, + stringOrUndefined, + objectOrUndefined, +} from "@openfeature/server-sdk"; +import type { + EvaluationContext, + EvaluationDetails, + FlagEvaluationOptions, +} from "@openfeature/server-sdk"; + +export interface GeneratedClient { + /** + * Discount percentage applied to purchases. + * + * **Details:** + * - flag key: `discountPercentage` + * - default value: `0.15` + * - type: `number` + * + * Performs a flag evaluation that returns a number. + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + discountPercentage(context?: EvaluationContext, options?: FlagEvaluationOptions): Promise; + + /** + * Discount percentage applied to purchases. + * + * **Details:** + * - flag key: `discountPercentage` + * - default value: `0.15` + * - type: `number` + * + * Performs a flag evaluation that a returns an evaluation details object. + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + discountPercentageDetails(context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; + + /** + * Controls whether Feature A is enabled. + * + * **Details:** + * - flag key: `enableFeatureA` + * - default value: `false` + * - type: `boolean` + * + * Performs a flag evaluation that returns a boolean. + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + enableFeatureA(context?: EvaluationContext, options?: FlagEvaluationOptions): Promise; + + /** + * Controls whether Feature A is enabled. + * + * **Details:** + * - flag key: `enableFeatureA` + * - default value: `false` + * - type: `boolean` + * + * Performs a flag evaluation that a returns an evaluation details object. + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + enableFeatureADetails(context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; + + /** + * The message to use for greeting users. + * + * **Details:** + * - flag key: `greetingMessage` + * - default value: `Hello there!` + * - type: `string` + * + * Performs a flag evaluation that returns a string. + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + greetingMessage(context?: EvaluationContext, options?: FlagEvaluationOptions): Promise; + + /** + * The message to use for greeting users. + * + * **Details:** + * - flag key: `greetingMessage` + * - default value: `Hello there!` + * - type: `string` + * + * Performs a flag evaluation that a returns an evaluation details object. + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + greetingMessageDetails(context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; + + /** + * Maximum allowed length for usernames. + * + * **Details:** + * - flag key: `usernameMaxLength` + * - default value: `50` + * - type: `number` + * + * Performs a flag evaluation that returns a number. + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + usernameMaxLength(context?: EvaluationContext, options?: FlagEvaluationOptions): Promise; + + /** + * Maximum allowed length for usernames. + * + * **Details:** + * - flag key: `usernameMaxLength` + * - default value: `50` + * - type: `number` + * + * Performs a flag evaluation that a returns an evaluation details object. + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + usernameMaxLengthDetails(context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; +} + +/** + * A factory function that returns a generated client that not bound to a domain. + * It was generated using the OpenFeature CLI and is compatible with `@openfeature/server-sdk`. + * + * All domainless or unbound clients use the default provider set via {@link OpenFeature.setProvider}. + * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations + * @returns {GeneratedClient} Generated OpenFeature Client + */ +export function getGeneratedClient(context?: EvaluationContext): GeneratedClient +/** + * A factory function that returns a domain-bound generated client that was + * created using the OpenFeature CLI and is compatible with the `@openfeature/server-sdk`. + * + * If there is already a provider bound to this domain via {@link OpenFeature.setProvider}, this provider will be used. + * Otherwise, the default provider is used until a provider is assigned to that domain. + * @param {string} domain An identifier which logically binds clients with providers + * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations + * @returns {GeneratedClient} Generated OpenFeature Client + */ +export function getGeneratedClient(domain: string, context?: EvaluationContext): GeneratedClient +export function getGeneratedClient(domainOrContext?: string | EvaluationContext, contextOrUndefined?: EvaluationContext): GeneratedClient { + const domain = stringOrUndefined(domainOrContext); + const context = + objectOrUndefined(domainOrContext) ?? + objectOrUndefined(contextOrUndefined); + + const client = domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context) + + return { + discountPercentage: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise => { + return client.getNumberValue("discountPercentage", 0.15, context, options); + }, + + discountPercentageDetails: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise> => { + return client.getNumberDetails("discountPercentage", 0.15, context, options); + }, + + enableFeatureA: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise => { + return client.getBooleanValue("enableFeatureA", false, context, options); + }, + + enableFeatureADetails: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise> => { + return client.getBooleanDetails("enableFeatureA", false, context, options); + }, + + greetingMessage: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise => { + return client.getStringValue("greetingMessage", "Hello there!", context, options); + }, + + greetingMessageDetails: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise> => { + return client.getStringDetails("greetingMessage", "Hello there!", context, options); + }, + + usernameMaxLength: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise => { + return client.getNumberValue("usernameMaxLength", 50, context, options); + }, + + usernameMaxLengthDetails: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise> => { + return client.getNumberDetails("usernameMaxLength", 50, context, options); + }, + } +} \ No newline at end of file diff --git a/docs/commands/openfeature_generate.md b/docs/commands/openfeature_generate.md index 7d16b09..e1e4075 100644 --- a/docs/commands/openfeature_generate.md +++ b/docs/commands/openfeature_generate.md @@ -26,5 +26,6 @@ openfeature generate [flags] * [openfeature](openfeature.md) - CLI for OpenFeature. * [openfeature generate go](openfeature_generate_go.md) - Generate typesafe accessors for OpenFeature. +* [openfeature generate nodejs](openfeature_generate_nodejs.md) - Generate typesafe Node.js client. * [openfeature generate react](openfeature_generate_react.md) - Generate typesafe React Hooks. diff --git a/docs/commands/openfeature_generate_nodejs.md b/docs/commands/openfeature_generate_nodejs.md new file mode 100644 index 0000000..99ba209 --- /dev/null +++ b/docs/commands/openfeature_generate_nodejs.md @@ -0,0 +1,32 @@ + + +## openfeature generate nodejs + +Generate typesafe Node.js client. + +### Synopsis + +Generate typesafe Node.js client compatible with the OpenFeature JavaScript Server SDK. + +``` +openfeature generate nodejs [flags] +``` + +### Options + +``` + -h, --help help for nodejs +``` + +### Options inherited from parent commands + +``` + -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) - Generate typesafe OpenFeature accessors. + diff --git a/internal/generators/nodejs/nodejs.tmpl b/internal/generators/nodejs/nodejs.tmpl index 0a9559d..e1c54c8 100644 --- a/internal/generators/nodejs/nodejs.tmpl +++ b/internal/generators/nodejs/nodejs.tmpl @@ -1,13 +1,17 @@ +// AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. import { OpenFeature, + stringOrUndefined, + objectOrUndefined, } from "@openfeature/server-sdk"; import type { EvaluationContext, EvaluationDetails, + FlagEvaluationOptions, } from "@openfeature/server-sdk"; export interface GeneratedClient { -{{ range .Flagset.Flags }} +{{- range .Flagset.Flags }} /** * {{ .Description }} * @@ -15,23 +19,68 @@ export interface GeneratedClient { * - flag key: `{{ .Key }}` * - default value: `{{ .DefaultValue }}` * - type: `{{ .Type | OpenFeatureType }}` + * + * Performs a flag evaluation that returns a {{ .Type | OpenFeatureType }}. + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise<{{ .Type | OpenFeatureType }}>} Flag evaluation response */ - {{ .Key | ToCamel }}(context?: EvaluationContext): Promise<{{ .Type | OpenFeatureType }}>; -{{ end}} + {{ .Key | ToCamel }}(context?: EvaluationContext, options?: FlagEvaluationOptions): Promise<{{ .Type | OpenFeatureType }}>; + + /** + * {{ .Description }} + * + * **Details:** + * - flag key: `{{ .Key }}` + * - default value: `{{ .DefaultValue }}` + * - type: `{{ .Type | OpenFeatureType }}` + * + * Performs a flag evaluation that a returns an evaluation details object. + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + {{ .Key | ToCamel }}Details(context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; +{{ end -}} } /** - * A client generated by the OpenFeature cli that's compatible - * with the `@openfeature/server-sdk` and supported providers. + * A factory function that returns a generated client that not bound to a domain. + * It was generated using the OpenFeature CLI and is compatible with `@openfeature/server-sdk`. + * + * All domainless or unbound clients use the default provider set via {@link OpenFeature.setProvider}. + * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations + * @returns {GeneratedClient} Generated OpenFeature Client */ -export function getGeneratedClient(context?: EvaluationContext): GeneratedClient { - const client = OpenFeature.getClient(context) +export function getGeneratedClient(context?: EvaluationContext): GeneratedClient +/** + * A factory function that returns a domain-bound generated client that was + * created using the OpenFeature CLI and is compatible with the `@openfeature/server-sdk`. + * + * If there is already a provider bound to this domain via {@link OpenFeature.setProvider}, this provider will be used. + * Otherwise, the default provider is used until a provider is assigned to that domain. + * @param {string} domain An identifier which logically binds clients with providers + * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations + * @returns {GeneratedClient} Generated OpenFeature Client + */ +export function getGeneratedClient(domain: string, context?: EvaluationContext): GeneratedClient +export function getGeneratedClient(domainOrContext?: string | EvaluationContext, contextOrUndefined?: EvaluationContext): GeneratedClient { + const domain = stringOrUndefined(domainOrContext); + const context = + objectOrUndefined(domainOrContext) ?? + objectOrUndefined(contextOrUndefined); + + const client = domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context) return { -{{ range .Flagset.Flags }} - {{ .Key | ToCamel }}: (context?: EvaluationContext): Promise<{{ .Type | OpenFeatureType }}> => { - client.getBooleanValue({{ .Key | Quote }}, {{ .DefaultValue | QuoteString }}, context); - } -{{ end}} - } +{{- range .Flagset.Flags }} + {{ .Key | ToCamel }}: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise<{{ .Type | OpenFeatureType }}> => { + return client.get{{ .Type | OpenFeatureType | ToPascal }}Value({{ .Key | Quote }}, {{ .DefaultValue | QuoteString }}, context, options); + }, + + {{ .Key | ToCamel }}Details: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise> => { + return client.get{{ .Type | OpenFeatureType | ToPascal }}Details({{ .Key | Quote }}, {{ .DefaultValue | QuoteString }}, context, options); + }, +{{ end -}} +{{ printf " " }}} } \ No newline at end of file From 009bb78121a296b49a796914ffe176f3344e165f Mon Sep 17 00:00:00 2001 From: Michael Beemer Date: Sun, 30 Mar 2025 00:34:39 +0000 Subject: [PATCH 3/7] improve doc diff output Signed-off-by: Michael Beemer --- .github/workflows/pr-test.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index 202d847..e8385c2 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -38,4 +38,8 @@ jobs: - run: make generate-docs - name: Check no diff run: | - if [ ! -z "$(git status --porcelain)" ]; then echo "::error file=Makefile::Doc generation produced diff. Run 'make generate-docs' and commit results."; exit 1; fi \ No newline at end of file + if [ ! -z "$(git status --porcelain)" ]; then + echo "::error file=Makefile::Doc generation produced diff. Run 'make generate-docs' and commit results." + git diff + exit 1 + fi \ No newline at end of file From 7aacc1eae7512d736972273002b8af2398015e0e Mon Sep 17 00:00:00 2001 From: Michael Beemer Date: Sun, 30 Mar 2025 00:40:36 +0000 Subject: [PATCH 4/7] check out expected sha Signed-off-by: Michael Beemer --- .github/workflows/pr-test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index e8385c2..15098d9 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -31,6 +31,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }} - uses: actions/setup-go@v5 with: go-version-file: 'go.mod' From be53765fef7c4dcbaf86f4ab7cd9e856c75bc6cf Mon Sep 17 00:00:00 2001 From: Michael Beemer Date: Sun, 30 Mar 2025 14:55:57 +0000 Subject: [PATCH 5/7] fix variable name Signed-off-by: Michael Beemer --- cmd/generate.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/generate.go b/cmd/generate.go index 38dab3d..05a226a 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -36,7 +36,7 @@ func addStabilityInfo(cmd *cobra.Command) { } func GetGenerateNodeJSCmd() *cobra.Command { - reactCmd := &cobra.Command{ + nodeJSCmd := &cobra.Command{ Use: "nodejs", Short: "Generate typesafe Node.js client.", Long: `Generate typesafe Node.js client compatible with the OpenFeature JavaScript Server SDK.`, @@ -68,9 +68,9 @@ func GetGenerateNodeJSCmd() *cobra.Command { }, } - addStabilityInfo(reactCmd) + addStabilityInfo(nodeJSCmd) - return reactCmd + return nodeJSCmd } func GetGenerateReactCmd() *cobra.Command { From 34da0d6b2bd881fbddd3c8d986972e81dbd4c214 Mon Sep 17 00:00:00 2001 From: Michael Beemer Date: Sun, 30 Mar 2025 14:59:00 +0000 Subject: [PATCH 6/7] feat: consolidate logging and support debug flag Signed-off-by: Michael Beemer --- cmd/config.go | 23 +++- cmd/generate.go | 21 +++- cmd/init.go | 19 ++- cmd/root.go | 20 ++-- cmd/version.go | 7 +- docs/commands/openfeature.md | 1 + docs/commands/openfeature_generate.md | 1 + docs/commands/openfeature_generate_go.md | 1 + docs/commands/openfeature_generate_nodejs.md | 1 + docs/commands/openfeature_generate_react.md | 1 + docs/commands/openfeature_init.md | 1 + docs/commands/openfeature_version.md | 1 + internal/config/flags.go | 2 + internal/generators/generators.go | 14 ++- internal/logger/logger.go | 117 +++++++++++++++++++ 15 files changed, 210 insertions(+), 20 deletions(-) create mode 100644 internal/logger/logger.go diff --git a/cmd/config.go b/cmd/config.go index ba635bd..54dc44b 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/open-feature/cli/internal/logger" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -17,6 +18,8 @@ func initializeConfig(cmd *cobra.Command, bindPrefix string) error { // Set the config file name and path v.SetConfigName(".openfeature") v.AddConfigPath(".") + + logger.Default.Debug("Looking for .openfeature config file in current directory") // Read the config file if err := v.ReadInConfig(); err != nil { @@ -24,18 +27,24 @@ func initializeConfig(cmd *cobra.Command, bindPrefix string) error { if _, ok := err.(viper.ConfigFileNotFoundError); !ok { return err } + logger.Default.Debug("No config file found, using defaults and environment variables") + } else { + logger.Default.Debug(fmt.Sprintf("Using config file: %s", v.ConfigFileUsed())) } + // 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 + logger.Default.Debug(fmt.Sprintf("Flag set via command line: %s=%s", f.Name, f.Value.String())) }) // Apply the configuration values cmd.Flags().VisitAll(func(f *pflag.Flag) { // Skip if flag was set on command line if cmdLineFlags[f.Name] { + logger.Default.Debug(fmt.Sprintf("Skipping config for %s: already set via command line", f.Name)) return } @@ -57,14 +66,24 @@ func initializeConfig(cmd *cobra.Command, bindPrefix string) error { // Check the base path (e.g., package-name) configPaths = append(configPaths, f.Name) + logger.Default.Debug(fmt.Sprintf("Looking for config value for flag %s in paths: %s", f.Name, strings.Join(configPaths, ", "))) + // 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 + err := f.Value.Set(fmt.Sprintf("%v", val)) + if err != nil { + logger.Default.Debug(fmt.Sprintf("Error setting flag %s from config: %v", f.Name, err)) + } else { + logger.Default.Debug(fmt.Sprintf("Set flag %s=%s from config path %s", f.Name, val, path)) + break + } } } + + // Log the final value for the flag + logger.Default.Debug(fmt.Sprintf("Final flag value: %s=%s", f.Name, f.Value.String())) }) return nil diff --git a/cmd/generate.go b/cmd/generate.go index 05a226a..ac1cb4a 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -9,6 +9,7 @@ import ( "github.com/open-feature/cli/internal/generators/golang" "github.com/open-feature/cli/internal/generators/nodejs" "github.com/open-feature/cli/internal/generators/react" + "github.com/open-feature/cli/internal/logger" "github.com/spf13/cobra" ) @@ -49,7 +50,9 @@ func GetGenerateNodeJSCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { manifestPath := config.GetManifestPath(cmd) outputPath := config.GetOutputPath(cmd) - + + logger.Default.GenerationStarted("Node.js") + params := generators.Params[nodejs.Params]{ OutputPath: outputPath, Custom: nodejs.Params{}, @@ -60,10 +63,14 @@ func GetGenerateNodeJSCmd() *cobra.Command { } generator := nodejs.NewGenerator(flagset) + logger.Default.Debug("Executing Node.js generator") err = generator.Generate(¶ms) if err != nil { return err } + + logger.Default.GenerationComplete("Node.js") + return nil }, } @@ -87,6 +94,8 @@ func GetGenerateReactCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { manifestPath := config.GetManifestPath(cmd) outputPath := config.GetOutputPath(cmd) + + logger.Default.GenerationStarted("React") params := generators.Params[react.Params]{ OutputPath: outputPath, @@ -98,10 +107,14 @@ func GetGenerateReactCmd() *cobra.Command { } generator := react.NewGenerator(flagset) + logger.Default.Debug("Executing React generator") err = generator.Generate(¶ms) if err != nil { return err } + + logger.Default.GenerationComplete("React") + return nil }, } @@ -126,6 +139,8 @@ func GetGenerateGoCmd() *cobra.Command { goPackageName := config.GetGoPackageName(cmd) manifestPath := config.GetManifestPath(cmd) outputPath := config.GetOutputPath(cmd) + + logger.Default.GenerationStarted("Go") params := generators.Params[golang.Params]{ OutputPath: outputPath, @@ -140,10 +155,14 @@ func GetGenerateGoCmd() *cobra.Command { } generator := golang.NewGenerator(flagset) + logger.Default.Debug("Executing Go generator") err = generator.Generate(¶ms) if err != nil { return err } + + logger.Default.GenerationComplete("Go") + return nil }, } diff --git a/cmd/init.go b/cmd/init.go index 045bead..86c7462 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -5,6 +5,7 @@ import ( "github.com/open-feature/cli/internal/config" "github.com/open-feature/cli/internal/filesystem" + "github.com/open-feature/cli/internal/logger" "github.com/open-feature/cli/internal/manifest" "github.com/pterm/pterm" "github.com/spf13/cobra" @@ -23,24 +24,30 @@ func GetInitCmd() *cobra.Command { override := config.GetOverride(cmd) manifestExists, _ := filesystem.Exists(manifestPath) - if (manifestExists && !override) { + if manifestExists && !override { + logger.Default.Debug(fmt.Sprintf("Manifest file already exists at %s", manifestPath)) + 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.") + if !shouldOverride { + logger.Default.Info("No changes were made.") return nil } + + logger.Default.Debug("User confirmed override of existing manifest") } - pterm.Info.Println("Initializing project...") + logger.Default.Info("Initializing project...") err := manifest.Create(manifestPath) if err != nil { + logger.Default.Error(fmt.Sprintf("Failed to create manifest: %v", err)) return err } - pterm.Info.Printfln("Manifest created at %s", pterm.LightWhite(manifestPath)) - pterm.Success.Println("Project initialized.") + + logger.Default.FileCreated(manifestPath) + logger.Default.Success("Project initialized.") return nil }, } diff --git a/cmd/root.go b/cmd/root.go index 57abe2f..25f38ff 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,10 +1,11 @@ package cmd import ( + "fmt" "os" "github.com/open-feature/cli/internal/config" - "github.com/pterm/pterm" + "github.com/open-feature/cli/internal/logger" "github.com/spf13/cobra" ) @@ -22,28 +23,29 @@ func Execute(version string, commit string, date string) { Commit = commit Date = date if err := GetRootCmd().Execute(); err != nil { - pterm.Error.Println(err) + logger.Default.Error(err.Error()) os.Exit(1) } } func GetRootCmd() *cobra.Command { // Execute all parent's persistent hooks - cobra.EnableTraverseRunHooks =true + 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 { + debug, _ := cmd.Flags().GetBool("debug") + logger.Default.SetDebug(debug) + logger.Default.Debug("Debug logging enabled") 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() - + logger.Default.Println(""); + logger.Default.Println("To see all the options, try 'openfeature --help'") return nil }, SilenceErrors: true, @@ -63,8 +65,8 @@ func GetRootCmd() *cobra.Command { // 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") + logger.Default.Error(fmt.Sprintf("Invalid flag: %s", err)) + logger.Default.Info("Run 'openfeature --help' for usage information") return err }) diff --git a/cmd/version.go b/cmd/version.go index ade0898..dab3c41 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -4,6 +4,7 @@ import ( "fmt" "runtime/debug" + "github.com/open-feature/cli/internal/logger" "github.com/spf13/cobra" ) @@ -14,21 +15,25 @@ func GetVersionCmd() *cobra.Command { Long: ``, Run: func(cmd *cobra.Command, args []string) { if Version == "dev" { + logger.Default.Debug("Development version detected, attempting to get build info") 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 + logger.Default.Debug(fmt.Sprintf("Found build date: %s", Date)) } if i.Key == "vcs.revision" { Commit = i.Value + logger.Default.Debug(fmt.Sprintf("Found commit: %s", Commit)) } } } } - fmt.Printf("OpenFeature CLI: %s (%s), built at: %s\n", Version, Commit, Date) + versionInfo := fmt.Sprintf("OpenFeature CLI: %s (%s), built at: %s", Version, Commit, Date) + logger.Default.Info(versionInfo) }, } diff --git a/docs/commands/openfeature.md b/docs/commands/openfeature.md index 2283500..f9c8efc 100644 --- a/docs/commands/openfeature.md +++ b/docs/commands/openfeature.md @@ -15,6 +15,7 @@ openfeature [flags] ### Options ``` + --debug Enable debug logging -h, --help help for openfeature -m, --manifest string Path to the flag manifest (default "flags.json") --no-input Disable interactive prompts diff --git a/docs/commands/openfeature_generate.md b/docs/commands/openfeature_generate.md index e1e4075..99fb3ad 100644 --- a/docs/commands/openfeature_generate.md +++ b/docs/commands/openfeature_generate.md @@ -18,6 +18,7 @@ openfeature generate [flags] ### Options inherited from parent commands ``` + --debug Enable debug logging -m, --manifest string Path to the flag manifest (default "flags.json") --no-input Disable interactive prompts ``` diff --git a/docs/commands/openfeature_generate_go.md b/docs/commands/openfeature_generate_go.md index f33e268..aac2a80 100644 --- a/docs/commands/openfeature_generate_go.md +++ b/docs/commands/openfeature_generate_go.md @@ -22,6 +22,7 @@ openfeature generate go [flags] ### Options inherited from parent commands ``` + --debug Enable debug logging -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 diff --git a/docs/commands/openfeature_generate_nodejs.md b/docs/commands/openfeature_generate_nodejs.md index 99ba209..5a15422 100644 --- a/docs/commands/openfeature_generate_nodejs.md +++ b/docs/commands/openfeature_generate_nodejs.md @@ -21,6 +21,7 @@ openfeature generate nodejs [flags] ### Options inherited from parent commands ``` + --debug Enable debug logging -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 diff --git a/docs/commands/openfeature_generate_react.md b/docs/commands/openfeature_generate_react.md index 56d00cc..df5b996 100644 --- a/docs/commands/openfeature_generate_react.md +++ b/docs/commands/openfeature_generate_react.md @@ -21,6 +21,7 @@ openfeature generate react [flags] ### Options inherited from parent commands ``` + --debug Enable debug logging -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 diff --git a/docs/commands/openfeature_init.md b/docs/commands/openfeature_init.md index 5769fb7..22eb382 100644 --- a/docs/commands/openfeature_init.md +++ b/docs/commands/openfeature_init.md @@ -22,6 +22,7 @@ openfeature init [flags] ### Options inherited from parent commands ``` + --debug Enable debug logging -m, --manifest string Path to the flag manifest (default "flags.json") --no-input Disable interactive prompts ``` diff --git a/docs/commands/openfeature_version.md b/docs/commands/openfeature_version.md index abb85dd..bc12f7f 100644 --- a/docs/commands/openfeature_version.md +++ b/docs/commands/openfeature_version.md @@ -17,6 +17,7 @@ openfeature version [flags] ### Options inherited from parent commands ``` + --debug Enable debug logging -m, --manifest string Path to the flag manifest (default "flags.json") --no-input Disable interactive prompts ``` diff --git a/internal/config/flags.go b/internal/config/flags.go index 34bfa2a..86e5dfb 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -6,6 +6,7 @@ import ( // Flag name constants to avoid duplication const ( + DebugFlagName = "debug" ManifestFlagName = "manifest" OutputFlagName = "output" NoInputFlagName = "no-input" @@ -24,6 +25,7 @@ const ( func AddRootFlags(cmd *cobra.Command) { cmd.PersistentFlags().StringP(ManifestFlagName, "m", DefaultManifestPath, "Path to the flag manifest") cmd.PersistentFlags().Bool(NoInputFlagName, false, "Disable interactive prompts") + cmd.PersistentFlags().Bool(DebugFlagName, false, "Enable debug logging") } // AddGenerateFlags adds the common generate flags to the given command diff --git a/internal/generators/generators.go b/internal/generators/generators.go index d075b6f..7a11fba 100644 --- a/internal/generators/generators.go +++ b/internal/generators/generators.go @@ -10,6 +10,7 @@ import ( "github.com/open-feature/cli/internal/filesystem" "github.com/open-feature/cli/internal/flagset" + "github.com/open-feature/cli/internal/logger" ) // Represents the stability level of a generator @@ -46,6 +47,8 @@ func (g *CommonGenerator) GenerateFile(customFunc template.FuncMap, tmpl string, funcs := defaultFuncs() maps.Copy(funcs, customFunc) + logger.Default.Debug(fmt.Sprintf("Generating file: %s", name)) + generatorTemplate, err := template.New("generator").Funcs(funcs).Parse(tmpl) if err != nil { return fmt.Errorf("error initializing template: %v", err) @@ -60,5 +63,14 @@ func (g *CommonGenerator) GenerateFile(customFunc template.FuncMap, tmpl string, return fmt.Errorf("error executing template: %v", err) } - return filesystem.WriteFile(filepath.Join(params.OutputPath, name), buf.Bytes()) + fullPath := filepath.Join(params.OutputPath, name) + if err := filesystem.WriteFile(fullPath, buf.Bytes()); err != nil { + logger.Default.FileFailed(fullPath, err) + return err + } + + // Log successful file creation + logger.Default.FileCreated(fullPath) + + return nil } diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..bb63d24 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,117 @@ +package logger + +import ( + "path/filepath" + + "github.com/pterm/pterm" +) + +// Logger provides methods for logging different types of messages +type Logger interface { + // Println logs a message without logging level + Println(message string) + // Info logs general information + Info(message string) + // Success logs successful operations + Success(message string) + // Warning logs warnings + Warning(message string) + // Error logs errors + Error(message string) + // Debug logs debug information (only when debug mode is enabled) + Debug(message string) + // SetDebug enables or disables debug mode + SetDebug(enabled bool) + // IsDebugEnabled returns whether debug mode is enabled + IsDebugEnabled() bool + // FileCreated logs a file creation event + FileCreated(path string) + // FileFailed logs a file creation failure + FileFailed(path string, err error) + // GenerationStarted logs the start of a generation process + GenerationStarted(generatorType string) + // GenerationComplete logs the completion of a generation process + GenerationComplete(generatorType string) +} + +// DefaultLogger is the default implementation of Logger +type DefaultLogger struct { + debugEnabled bool +} + +// New creates a new DefaultLogger +func New() *DefaultLogger { + return &DefaultLogger{ + debugEnabled: false, + } +} + +// SetDebug enables or disables debug mode +func (l *DefaultLogger) SetDebug(enabled bool) { + l.debugEnabled = enabled + if enabled { + pterm.EnableDebugMessages() + } +} + +// IsDebugEnabled returns whether debug mode is enabled +func (l *DefaultLogger) IsDebugEnabled() bool { + return l.debugEnabled +} + +// Println logs a message without logging level +func (l *DefaultLogger) Println(message string) { + pterm.Println(message) +} + +// Info logs general information +func (l *DefaultLogger) Info(message string) { + pterm.Info.Println(message) +} + +// Success logs successful operations +func (l *DefaultLogger) Success(message string) { + pterm.Success.Println(message) +} + +// Warning logs warnings +func (l *DefaultLogger) Warning(message string) { + pterm.Warning.Println(message) +} + +// Error logs errors +func (l *DefaultLogger) Error(message string) { + pterm.Error.Println(message) +} + +// Debug logs debug information (only when debug mode is enabled) +func (l *DefaultLogger) Debug(message string) { + if l.debugEnabled { + pterm.Debug.Println(message) + } +} + +// FileCreated logs a file creation event +func (l *DefaultLogger) FileCreated(path string) { + prettyPath := pterm.LightWhite(filepath.Clean(path)) + pterm.Success.Printf("Created %s\n", prettyPath) +} + +// FileFailed logs a file creation failure +func (l *DefaultLogger) FileFailed(path string, err error) { + prettyPath := pterm.LightWhite(filepath.Clean(path)) + pterm.Error.Printf("Failed to create %s: %v\n", prettyPath, err) +} + +// GenerationStarted logs the start of a generation process +func (l *DefaultLogger) GenerationStarted(generatorType string) { + pterm.Info.Printf("Generating a typesafe client for %s\n", generatorType) +} + +// GenerationComplete logs the completion of a generation process +func (l *DefaultLogger) GenerationComplete(generatorType string) { + pterm.Success.Printf("Successfully generated client. Happy coding!\n") +} + +// Default is a singleton instance of DefaultLogger +var Default Logger = New() From 8aad87f129a92a12390ba24f6dcf84101e427f61 Mon Sep 17 00:00:00 2001 From: Michael Beemer Date: Sun, 30 Mar 2025 15:08:12 +0000 Subject: [PATCH 7/7] regenerate docs Signed-off-by: Michael Beemer --- docs/commands/openfeature_generate_nodejs.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/commands/openfeature_generate_nodejs.md b/docs/commands/openfeature_generate_nodejs.md index 5a15422..d4cf458 100644 --- a/docs/commands/openfeature_generate_nodejs.md +++ b/docs/commands/openfeature_generate_nodejs.md @@ -4,6 +4,9 @@ Generate typesafe Node.js client. + +> **Stability**: alpha + ### Synopsis Generate typesafe Node.js client compatible with the OpenFeature JavaScript Server SDK.