├── .github └── workflows │ ├── dco-exceptions.yaml │ ├── lint-pr.yaml │ ├── pr-lint.yml │ ├── pr-test.yml │ └── release.yaml ├── .gitignore ├── .goreleaser.yaml ├── .release-please-manifest.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── DESIGN.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── bin └── install.sh ├── cmd └── openfeature │ └── main.go ├── docs ├── commands │ ├── openfeature.md │ ├── openfeature_compare.md │ ├── openfeature_generate.md │ ├── openfeature_generate_csharp.md │ ├── openfeature_generate_go.md │ ├── openfeature_generate_java.md │ ├── openfeature_generate_nestjs.md │ ├── openfeature_generate_nodejs.md │ ├── openfeature_generate_python.md │ ├── openfeature_generate_react.md │ ├── openfeature_init.md │ └── openfeature_version.md └── generate-commands.go ├── go.mod ├── go.sum ├── internal ├── cmd │ ├── compare.go │ ├── compare_test.go │ ├── config.go │ ├── config_test.go │ ├── generate.go │ ├── generate_test.go │ ├── init.go │ ├── init_test.go │ ├── root.go │ ├── testdata │ │ ├── source_manifest.json │ │ ├── success_csharp.golden │ │ ├── success_go.golden │ │ ├── success_init.golden │ │ ├── success_java.golden │ │ ├── success_manifest.golden │ │ ├── success_nestjs.golden │ │ ├── success_nodejs.golden │ │ ├── success_python.golden │ │ ├── success_react.golden │ │ └── target_manifest.json │ ├── utils.go │ └── version.go ├── config │ └── flags.go ├── filesystem │ └── filesystem.go ├── flagset │ ├── flagset.go │ └── flagset_test.go ├── generators │ ├── README.md │ ├── csharp │ │ ├── csharp.go │ │ └── csharp.tmpl │ ├── func.go │ ├── generators.go │ ├── golang │ │ ├── golang.go │ │ └── golang.tmpl │ ├── java │ │ ├── java.go │ │ └── java.tmpl │ ├── manager.go │ ├── nestjs │ │ ├── nestjs.go │ │ └── nestjs.tmpl │ ├── nodejs │ │ ├── nodejs.go │ │ └── nodejs.tmpl │ ├── python │ │ ├── python.go │ │ └── python.tmpl │ └── react │ │ ├── react.go │ │ └── react.tmpl ├── logger │ └── logger.go └── manifest │ ├── compare.go │ ├── compare_test.go │ ├── json-schema.go │ ├── manage.go │ ├── output.go │ └── validate.go ├── lefthook.yml ├── release-please-config.json ├── sample └── sample_manifest.json ├── schema ├── generate-schema.go └── v0 │ ├── flag-manifest.json │ ├── schema.go │ ├── schema_test.go │ └── testdata │ ├── negative │ ├── empty-flag-key.json │ └── missing-flag-type.json │ └── positive │ └── min-flag-manifest.json └── test ├── README.md ├── csharp-integration ├── CompileTest.csproj ├── Dockerfile ├── OpenFeature.cs ├── Program.cs ├── README.md └── expected │ ├── OpenFeature.cs │ └── OpenFeature.g.cs ├── integration ├── cmd │ ├── csharp │ │ └── run.go │ └── run.go └── integration.go └── new-generator.md /.github/workflows/dco-exceptions.yaml: -------------------------------------------------------------------------------- 1 | name: DCO 2 | on: 3 | merge_group: 4 | 5 | permissions: # set top-level default permissions as security best practice 6 | contents: read # Check https://github.com/ossf/scorecard/blob/7ce8609469289d5f3b1bf5ee3122f42b4e3054fb/docs/checks.md#token-permissions 7 | 8 | jobs: 9 | DCO: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - run: echo "skipping DCO check for the merge group" 13 | -------------------------------------------------------------------------------- /.github/workflows/lint-pr.yaml: -------------------------------------------------------------------------------- 1 | name: "Lint PR" 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | main: 15 | permissions: 16 | pull-requests: read # for amannn/action-semantic-pull-request to analyze PRs 17 | statuses: write # for amannn/action-semantic-pull-request to mark status of analyzed PR 18 | name: Validate PR title 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: amannn/action-semantic-pull-request@v5 22 | id: lint_pr_title 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | - uses: marocchino/sticky-pull-request-comment@v2 26 | # When the previous steps fails, the workflow would stop. By adding this 27 | # condition you can continue the execution with the populated error message. 28 | if: always() && (steps.lint_pr_title.outputs.error_message != null) 29 | with: 30 | header: pr-title-lint-error 31 | message: | 32 | Hey there and thank you for opening this pull request! 👋🏼 33 | 34 | We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted. 35 | 36 | Details: 37 | 38 | ``` 39 | ${{ steps.lint_pr_title.outputs.error_message }} 40 | ``` 41 | 42 | # Delete a previous comment when the issue has been resolved 43 | - if: ${{ steps.lint_pr_title.outputs.error_message == null }} 44 | uses: marocchino/sticky-pull-request-comment@v2 45 | with: 46 | header: pr-title-lint-error 47 | delete: true -------------------------------------------------------------------------------- /.github/workflows/pr-lint.yml: -------------------------------------------------------------------------------- 1 | name: PR Lint 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | permissions: 9 | # Required: allow read access to the content for analysis. 10 | contents: read 11 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 12 | pull-requests: read 13 | # Optional: allow write access to checks to allow the action to annotate code in the PR. 14 | checks: write 15 | 16 | jobs: 17 | golangci: 18 | name: lint 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | - uses: actions/setup-go@v5 25 | with: 26 | go-version-file: 'go.mod' 27 | - name: golangci-lint 28 | uses: golangci/golangci-lint-action@v6 29 | with: 30 | version: v1.64 31 | only-new-issues: true -------------------------------------------------------------------------------- /.github/workflows/pr-test.yml: -------------------------------------------------------------------------------- 1 | name: PR Test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | merge_group: 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | test: 14 | name: test 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - uses: actions/setup-go@v5 21 | with: 22 | go-version-file: 'go.mod' 23 | 24 | - name: Run tests 25 | run: go test ./... 26 | 27 | docs-check: 28 | name: Validate docs 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v4 32 | with: 33 | fetch-depth: 0 34 | ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }} 35 | - uses: actions/setup-go@v5 36 | with: 37 | go-version-file: 'go.mod' 38 | 39 | - run: make generate-docs 40 | - name: Check no diff 41 | run: | 42 | if [ ! -z "$(git status --porcelain)" ]; then 43 | echo "::error file=Makefile::Doc generation produced diff. Run 'make generate-docs' and commit results." 44 | git diff 45 | exit 1 46 | fi 47 | 48 | integration-tests: 49 | name: 'Generator Integration Tests' 50 | runs-on: ubuntu-latest 51 | steps: 52 | - uses: actions/checkout@v4 53 | 54 | - name: Set up Go 55 | uses: actions/setup-go@v5 56 | with: 57 | go-version-file: 'go.mod' 58 | 59 | - name: Run all integration tests with Dagger 60 | uses: dagger/dagger-for-github@v5 61 | with: 62 | workdir: . 63 | verb: run 64 | args: go run ./test/integration/cmd/run.go 65 | version: 'latest' 66 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Run Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | release-please: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write # for googleapis/release-please-action to create release commit 16 | pull-requests: write # for googleapis/release-please-action to create release PR 17 | # Release-please creates a PR that tracks all changes 18 | steps: 19 | - uses: google-github-actions/release-please-action@v3 20 | id: release 21 | with: 22 | command: manifest 23 | token: ${{secrets.RELEASE_PLEASE_ACTION_TOKEN}} 24 | default-branch: main 25 | signoff: "OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com>" 26 | - name: Dump Release Please Output 27 | env: 28 | RELEASE_PLEASE_OUTPUT: ${{ toJSON(steps.release.outputs) }} 29 | run: | 30 | echo "$RELEASE_PLEASE_OUTPUT" 31 | outputs: 32 | release_created: ${{ steps.release.outputs.release_created }} 33 | release_tag_name: ${{ steps.release.outputs.tag_name }} 34 | upload_url: ${{ steps.release.outputs.upload_url }} 35 | 36 | go-release: 37 | needs: release-please 38 | if: ${{ fromJSON(needs.release-please.outputs.release_created || false) }} 39 | runs-on: ubuntu-latest 40 | permissions: 41 | contents: write 42 | packages: write 43 | env: 44 | DOCKER_CLI_EXPERIMENTAL: "enabled" 45 | steps: 46 | - name: Checkout 47 | uses: actions/checkout@v4 48 | with: 49 | fetch-depth: 0 50 | ref: ${{ needs.release-please.outputs.release_tag_name }} 51 | 52 | - name: Set up QEMU 53 | uses: docker/setup-qemu-action@v3 54 | 55 | - name: Login to GitHub Container Registry 56 | uses: docker/login-action@v3 57 | with: 58 | registry: ghcr.io 59 | username: ${{ github.repository_owner }} 60 | password: ${{ secrets.GITHUB_TOKEN }} 61 | 62 | - name: Set up Go 63 | uses: actions/setup-go@v5 64 | with: 65 | go-version-file: 'go.mod' 66 | 67 | - name: Run GoReleaser 68 | uses: goreleaser/goreleaser-action@v6 69 | with: 70 | distribution: goreleaser 71 | version: "~> v2" 72 | args: release --clean 73 | env: 74 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | 24 | # env file 25 | .env 26 | dist 27 | 28 | # openfeature cli config 29 | .openfeature.yaml 30 | 31 | .idea/ -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines below are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 8 | 9 | version: 2 10 | project_name: openfeature 11 | 12 | before: 13 | hooks: 14 | # You may remove this if you don't use go modules. 15 | - go mod tidy 16 | 17 | builds: 18 | - env: 19 | - CGO_ENABLED=0 20 | goos: 21 | - linux 22 | - windows 23 | - darwin 24 | binary: ./cmd/openfeature 25 | 26 | archives: 27 | - formats: tar.gz 28 | # this name template makes the OS and Arch compatible with the results of `uname`. 29 | name_template: >- 30 | {{ .ProjectName }}_ 31 | {{- title .Os }}_ 32 | {{- if eq .Arch "amd64" }}x86_64 33 | {{- else if eq .Arch "386" }}i386 34 | {{- else }}{{ .Arch }}{{ end }} 35 | {{- if .Arm }}v{{ .Arm }}{{ end }} 36 | # use zip for windows archives 37 | format_overrides: 38 | - goos: windows 39 | formats: ["zip"] 40 | 41 | checksum: 42 | name_template: "checksums.txt" 43 | 44 | report_sizes: true 45 | 46 | dockers: 47 | - image_templates: ["ghcr.io/open-feature/cli:{{ .Version }}-amd64"] 48 | dockerfile: Dockerfile 49 | use: buildx 50 | build_flag_templates: 51 | - --platform=linux/amd64 52 | - --label=org.opencontainers.image.title={{ .ProjectName }} cli 53 | - --label=org.opencontainers.image.url=https://github.com/open-feature/cli 54 | - --label=org.opencontainers.image.source=https://github.com/open-feature/cli 55 | - --label=org.opencontainers.image.version={{ .Version }} 56 | - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} 57 | - --label=org.opencontainers.image.description="OpenFeature’s official command-line tool" 58 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 59 | - --label=org.opencontainers.image.licenses=Apache-2.0 60 | 61 | - image_templates: ["ghcr.io/open-feature/cli:{{ .Version }}-arm64"] 62 | goarch: arm64 63 | dockerfile: Dockerfile 64 | use: buildx 65 | build_flag_templates: 66 | - --platform=linux/arm64 67 | - --label=org.opencontainers.image.title={{ .ProjectName }} cli 68 | - --label=org.opencontainers.image.url=https://github.com/open-feature/cli 69 | - --label=org.opencontainers.image.source=https://github.com/open-feature/cli 70 | - --label=org.opencontainers.image.version={{ .Version }} 71 | - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} 72 | - --label=org.opencontainers.image.description="OpenFeature’s official command-line tool" 73 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 74 | - --label=org.opencontainers.image.licenses=Apache-2.0 75 | 76 | docker_manifests: 77 | - name_template: ghcr.io/open-feature/cli:{{ .Version }} 78 | image_templates: 79 | - ghcr.io/open-feature/cli:{{ .Version }}-amd64 80 | - ghcr.io/open-feature/cli:{{ .Version }}-arm64 81 | - name_template: ghcr.io/open-feature/cli:latest 82 | image_templates: 83 | - ghcr.io/open-feature/cli:{{ .Version }}-amd64 84 | - ghcr.io/open-feature/cli:{{ .Version }}-arm64 85 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "0.3.5" 3 | } 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to OpenFeature CLI 2 | 3 | Thank you for your interest in contributing to the OpenFeature CLI! This document provides guidelines and instructions to help you get started with contributing to the project. Whether you're fixing a bug, adding a new feature, or improving documentation, your contributions are greatly appreciated. 4 | 5 | ## Contributing New Generators 6 | 7 | We welcome contributions for new generators to extend the functionality of the OpenFeature CLI. Below are the steps to contribute a new generator: 8 | 9 | 1. **Fork the Repository**: Start by forking the repository to your GitHub account. 10 | 11 | 2. **Clone the Repository**: Clone the forked repository to your local machine. 12 | 13 | 3. **Create a New Branch**: Create a new branch for your generator. Use a descriptive name for the branch, such as `feature/add-new-generator`. 14 | 15 | 4. **Add Your Generator**: Add your generator in the appropriate directory under `/internal/generate/generators/`. For example, if you are adding a generator for Python, you might create a new directory `/internal/generate/generators/python/` and add your files there. 16 | 17 | 5. **Implement the Generator**: Implement the generator logic. Ensure that your generator follows the existing patterns and conventions used in the project. Refer to the existing generators like `/internal/generate/generators/golang` or `/internal/generate/generators/react` for examples. 18 | 19 | 6. **Write Tests**: Write tests for your generator to ensure it works as expected. Add your tests in the appropriate test directory, such as `/internal/generate/generators/python/`. Write tests for any commands you may add, too. Add your command tests in the appropriate test directory, such as `cmd/generate_test.go`. 20 | 21 | 7. **Register the Generator**: After implementing your generator, you need to register it in the CLI under the `generate` command. Follow these steps to register your generator: 22 | 23 | - **Create a New Command Directory**: Create a new directory under `cmd/generate` with the name of your target language. For example, if you are adding a generator for Python, create a new directory `cmd/generate/python/`. 24 | 25 | - **Add Command File**: In the new directory, create a file named `python.go` (replace `python` with the name of your target language). This file will define the CLI command for your generator. 26 | 27 | - **Implement Command**: Implement the command logic in the `python.go` file. Refer to the existing commands like `cmd/generate/golang/golang.go` or `cmd/generate/react/react.go` for examples. 28 | 29 | - **Register Command**: Open the `cmd/generate/generate.go` file and register your new command as a subcommand. Add an import statement for your new command package and call `Root.AddCommand(python.Cmd)` (replace `python` with the name of your target language). 30 | 31 | 8. **Update Documentation**: Update the documentation to include information about your new generator. This may include updating the README.md and any other relevant documentation files. You can run `make generate-docs` to assist with documentation updates. 32 | 33 | 9. **Commit and Push**: Commit your changes and push the new branch to your forked repository. 34 | 35 | 10. **Create a Pull Request**: Create a pull request from your new branch to the main repository. Provide a clear and detailed description of your changes, including the purpose of the new generator and any relevant information. 36 | 37 | 11. **Address Feedback**: Be responsive to feedback from the maintainers. Make any necessary changes and update your pull request as needed. 38 | 39 | ### Testing 40 | 41 | The OpenFeature CLI includes both unit and integration tests to ensure quality and correctness. 42 | 43 | #### Unit Tests 44 | 45 | Run the unit tests with: 46 | 47 | ```bash 48 | go test ./... 49 | ``` 50 | 51 | #### Integration Tests 52 | 53 | To verify that generated code compiles correctly, the project includes integration tests. The CLI uses a Dagger-based integration testing framework to test code generation for each supported language: 54 | 55 | ```bash 56 | # Run all integration tests 57 | make test-integration 58 | 59 | # Run tests for a specific language 60 | make test-csharp-dagger 61 | ``` 62 | 63 | For more information on the integration testing framework, see [Integration Testing](./docs/integration-testing.md). 64 | 65 | ## Setting Up Lefthook 66 | 67 | To streamline the setup of Git hooks for this project, we utilize [Lefthook](https://github.com/evilmartians/lefthook). Lefthook automates pre-commit and pre-push checks, ensuring consistent enforcement of best practices across the team. These checks include code formatting, documentation generation, and running tests. 68 | 69 | This tool is particularly helpful for new contributors or those returning to the project after some time, as it provides a seamless way to align with the project's standards. By catching issues early in your local development environment, Lefthook helps you address potential problems before opening a Pull Request, saving time and effort for both contributors and maintainers. 70 | 71 | ### Installation 72 | 73 | 1. Install Lefthook using Homebrew: 74 | 75 | ```bash 76 | brew install lefthook 77 | ``` 78 | 79 | 2. Install the Lefthook configuration into your Git repository: 80 | 81 | ```bash 82 | lefthook install 83 | ``` 84 | 85 | ### Pre-Commit Hook 86 | 87 | The pre-commit hook is configured to run the following check: 88 | 89 | 1. **Code Formatting**: Ensures all files are properly formatted using `go fmt`. Any changes made by `go fmt` will be automatically staged. 90 | 91 | ### Pre-Push Hook 92 | 93 | The pre-push hook is configured to run the following checks: 94 | 95 | 1. **Documentation Generation**: Runs `make generate-docs` to ensure documentation is up-to-date. If any changes are detected, the push will be blocked until the changes are committed. 96 | 2. **Tests**: Executes `make test` to verify that all tests pass. If any tests fail, the push will be blocked. 97 | 98 | ### Running Hooks Manually 99 | 100 | You can manually run the hooks using the following commands: 101 | 102 | - Pre-commit hook: 103 | 104 | ```bash 105 | lefthook run pre-commit 106 | ``` 107 | 108 | - Pre-push hook: 109 | 110 | ```bash 111 | lefthook run pre-push 112 | ``` 113 | 114 | ## Templates 115 | 116 | ### Data 117 | 118 | The `TemplateData` struct is used to pass data to the templates. 119 | 120 | ### Built-in template functions 121 | 122 | The following functions are automatically included in the templates: 123 | 124 | #### ToPascal 125 | 126 | Converts a string to `PascalCase` 127 | 128 | ```go 129 | {{ "hello world" | ToPascal }} // HelloWorld 130 | ``` 131 | 132 | #### ToCamel 133 | 134 | Converts a string to `camelCase` 135 | 136 | ```go 137 | {{ "hello world" | ToCamel }} // helloWorld 138 | ``` 139 | 140 | #### ToKebab 141 | 142 | Converts a string to `kebab-case` 143 | 144 | ```go 145 | {{ "hello world" | ToKebab }} // hello-world 146 | ``` 147 | 148 | #### ToSnake 149 | 150 | Converts a string to `snake_case` 151 | 152 | ```go 153 | {{ "hello world" | ToSnake }} // hello_world 154 | ``` 155 | 156 | #### ToScreamingSnake 157 | 158 | Converts a string to `SCREAMING_SNAKE_CASE` 159 | 160 | ```go 161 | {{ "hello world" | ToScreamingSnake }} // HELLO_WORLD 162 | ``` 163 | 164 | #### ToUpper 165 | 166 | Converts a string to `UPPER CASE` 167 | 168 | ```go 169 | {{ "hello world" | ToUpper }} // HELLO WORLD 170 | ``` 171 | 172 | #### ToLower 173 | 174 | Converts a string to `lower case` 175 | 176 | ```go 177 | {{ "HELLO WORLD" | ToLower }} // hello world 178 | ``` 179 | 180 | #### ToTitle 181 | 182 | Converts a string to `Title Case` 183 | 184 | ```go 185 | {{ "hello world" | ToTitle }} // Hello World 186 | ``` 187 | 188 | #### Quote 189 | 190 | Wraps a string in double quotes 191 | 192 | ```go 193 | {{ "hello world" | Quote }} // "hello world" 194 | ``` 195 | 196 | #### QuoteString 197 | 198 | Wraps only strings in double quotes 199 | 200 | ```go 201 | {{ "hello world" | QuoteString }} // "hello world" 202 | {{ 123 | QuoteString }} // 123 203 | ``` 204 | 205 | ### Custom template functions 206 | 207 | You can add custom template functions by passing a `FuncMap` to the `GenerateFile` function. 208 | -------------------------------------------------------------------------------- /DESIGN.md: -------------------------------------------------------------------------------- 1 | # OpenFeature CLI Design 2 | 3 | This document describes the design considerations and goals for the OpenFeature CLI tool. 4 | 5 | ## Why Code Generation? 6 | 7 | Code generation automates the creation of strongly typed flag accessors, minimizing configuration errors and providing a better developer experience. 8 | By generating these accessors, developers can avoid issues related to incorrect flag names or types, resulting in more reliable and maintainable code. 9 | 10 | Benefits of the code generation approach: 11 | 12 | - **Type Safety**: Catch flag-related errors at compile time instead of runtime 13 | - **IDE Support**: Get autocomplete and documentation for your flags 14 | - **Refactoring Support**: Rename flags and the changes propagate throughout your codebase 15 | - **Discoverability**: Make it easier for developers to find and use available flags 16 | 17 | ## Goals 18 | 19 | - **Unified Flag Manifest Format**: Establish a standardized flag manifest format that can be easily converted from existing configurations. 20 | - **Strongly Typed Flag Accessors**: Develop a CLI tool to generate strongly typed flag accessors for multiple programming languages. 21 | - **Modular and Extensible Design**: Create a format that allows for future extensions and modularization of flags. 22 | - **Language Agnostic**: Support multiple programming languages through a common flag manifest format. 23 | - **Provider Independence**: Work with any feature flag provider that can be adapted to the OpenFeature API. 24 | 25 | ## Non-Goals 26 | 27 | - **Full Provider Integration**: The initial scope does not include creating tools to convert provider-specific configurations to the new flag manifest format. 28 | - **Validation of Flag Configs**: The project will not initially focus on validating flag configurations for consistency with the flag manifest. 29 | - **General-Purpose Configuration**: The project will not aim to create a general-purpose configuration tool for feature flags beyond the scope of the code generation tool. 30 | - **Runtime Flag Management**: The CLI is not intended to replace provider SDKs for runtime flag evaluation. 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.21 2 | 3 | COPY ./openfeature usr/local/bin/openfeature 4 | 5 | RUN chmod +x /usr/local/bin/openfeature 6 | 7 | ENTRYPOINT ["/usr/local/bin/openfeature"] 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | test: 3 | @echo "Running tests..." 4 | @go test -v ./... 5 | @echo "Tests passed successfully!" 6 | 7 | # Dagger-based integration tests 8 | .PHONY: test-integration-csharp 9 | test-integration-csharp: 10 | @echo "Running C# integration test with Dagger..." 11 | @go run ./test/integration/cmd/csharp/run.go 12 | 13 | .PHONY: test-integration 14 | test-integration: 15 | @echo "Running all integration tests with Dagger..." 16 | @go run ./test/integration/cmd/run.go 17 | 18 | generate-docs: 19 | @echo "Generating documentation..." 20 | @go run ./docs/generate-commands.go 21 | @echo "Documentation generated successfully!" 22 | 23 | generate-schema: 24 | @echo "Generating schema..." 25 | @go run ./schema/generate-schema.go 26 | @echo "Schema generated successfully!" 27 | 28 | .PHONY: fmt 29 | fmt: 30 | @echo "Running go fmt..." 31 | @go fmt ./... 32 | @echo "Code formatted successfully!" -------------------------------------------------------------------------------- /bin/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Adapted/Copied from https://github.com/daveshanley/vacuum/blob/main/bin/install.sh 5 | 6 | if [ -d "$HOME/.local/bin" ] || mkdir -p "$HOME/.local/bin" 2>/dev/null; then 7 | DEFAULT_INSTALL_DIR="$HOME/.local/bin" 8 | elif [ -w "/usr/local/bin" ]; then 9 | DEFAULT_INSTALL_DIR="/usr/local/bin" 10 | else 11 | fmt_error "unable to write to $HOME/.local/bin or /usr/local/bin" 12 | fmt_error "Please run this script with sudo or set INSTALL_DIR to a directory you can write to." 13 | exit 1 14 | fi 15 | 16 | INSTALL_DIR=${INSTALL_DIR:-$DEFAULT_INSTALL_DIR} 17 | BINARY_NAME=${BINARY_NAME:-"openfeature"} 18 | 19 | REPO_NAME="open-feature/cli" 20 | ISSUE_URL="https://github.com/open-feature/cli/issues/new" 21 | 22 | # get_latest_release "open-feature/cli" 23 | get_latest_release() { 24 | curl --retry 5 --silent "https://api.github.com/repos/$1/releases/latest" | # Get latest release from GitHub api 25 | grep '"tag_name":' | # Get tag line 26 | sed -E 's/.*"([^"]+)".*/\1/' # Pluck JSON value 27 | } 28 | 29 | get_asset_name() { 30 | echo "openfeature_$1_$2.tar.gz" 31 | } 32 | 33 | get_download_url() { 34 | local asset_name=$(get_asset_name $2 $3) 35 | echo "https://github.com/open-feature/cli/releases/download/v$1/${asset_name}" 36 | } 37 | 38 | get_checksum_url() { 39 | echo "https://github.com/open-feature/cli/releases/download/v$1/checksums.txt" 40 | } 41 | 42 | command_exists() { 43 | command -v "$@" >/dev/null 2>&1 44 | } 45 | 46 | fmt_error() { 47 | echo ${RED}"Error: $@"${RESET} >&2 48 | } 49 | 50 | fmt_warning() { 51 | echo ${YELLOW}"Warning: $@"${RESET} >&2 52 | } 53 | 54 | fmt_underline() { 55 | echo "$(printf '\033[4m')$@$(printf '\033[24m')" 56 | } 57 | 58 | fmt_code() { 59 | echo "\`$(printf '\033[38;5;247m')$@${RESET}\`" 60 | } 61 | 62 | setup_color() { 63 | # Only use colors if connected to a terminal 64 | if [ -t 1 ]; then 65 | RED=$(printf '\033[31m') 66 | GREEN=$(printf '\033[32m') 67 | YELLOW=$(printf '\033[33m') 68 | BLUE=$(printf '\033[34m') 69 | MAGENTA=$(printf '\033[35m') 70 | BOLD=$(printf '\033[1m') 71 | RESET=$(printf '\033[m') 72 | else 73 | RED="" 74 | GREEN="" 75 | YELLOW="" 76 | BLUE="" 77 | MAGENTA="" 78 | BOLD="" 79 | RESET="" 80 | fi 81 | } 82 | 83 | get_os() { 84 | case "$(uname -s)" in 85 | *linux* ) echo "Linux" ;; 86 | *Linux* ) echo "Linux" ;; 87 | *darwin* ) echo "Darwin" ;; 88 | *Darwin* ) echo "Darwin" ;; 89 | esac 90 | } 91 | 92 | get_machine() { 93 | case "$(uname -m)" in 94 | "x86_64"|"amd64"|"x64") 95 | echo "x86_64" ;; 96 | "i386"|"i86pc"|"x86"|"i686") 97 | echo "i386" ;; 98 | "arm64"|"armv6l"|"aarch64") 99 | echo "arm64" 100 | esac 101 | } 102 | 103 | get_tmp_dir() { 104 | echo $(mktemp -d) 105 | } 106 | 107 | do_checksum() { 108 | checksum_url=$(get_checksum_url $version) 109 | get_checksum_url $version 110 | expected_checksum=$(curl -sL $checksum_url | grep $asset_name | awk '{print $1}') 111 | 112 | if command_exists sha256sum; then 113 | checksum=$(sha256sum $asset_name | awk '{print $1}') 114 | elif command_exists shasum; then 115 | checksum=$(shasum -a 256 $asset_name | awk '{print $1}') 116 | else 117 | fmt_warning "Could not find a checksum program. Install shasum or sha256sum to validate checksum." 118 | return 0 119 | fi 120 | 121 | if [ "$checksum" != "$expected_checksum" ]; then 122 | fmt_error "Checksums do not match" 123 | exit 1 124 | fi 125 | } 126 | 127 | do_install_binary() { 128 | asset_name=$(get_asset_name $os $machine) 129 | download_url=$(get_download_url $version $os $machine) 130 | 131 | command_exists curl || { 132 | fmt_error "curl is not installed" 133 | exit 1 134 | } 135 | 136 | command_exists tar || { 137 | fmt_error "tar is not installed" 138 | exit 1 139 | } 140 | 141 | local tmp_dir=$(get_tmp_dir) 142 | 143 | # Download tar.gz to tmp directory 144 | echo "Downloading $download_url" 145 | (cd $tmp_dir && curl -sL -O "$download_url") 146 | 147 | (cd $tmp_dir && do_checksum) 148 | 149 | # Extract download 150 | (cd $tmp_dir && tar -xzf "$asset_name") 151 | 152 | # Install binary 153 | if [ -w "$INSTALL_DIR" ]; then 154 | mv "$tmp_dir/$BINARY_NAME" "$INSTALL_DIR" 155 | else 156 | fmt_error "Unable to write to $INSTALL_DIR. Please run this script with sudo or set INSTALL_DIR to a directory you can write to." 157 | exit 1 158 | fi 159 | 160 | # Make the binary executable 161 | if [ -w "$INSTALL_DIR/$BINARY_NAME" ]; then 162 | chmod +x "$INSTALL_DIR/$BINARY_NAME" 163 | else 164 | sudo chmod +x "$INSTALL_DIR/$BINARY_NAME" 2>/dev/null || { 165 | fmt_error "Could not make $INSTALL_DIR/$BINARY_NAME executable" 166 | exit 1 167 | } 168 | fi 169 | 170 | # Check if the binary is executable 171 | if [ ! -x "$INSTALL_DIR/$BINARY_NAME" ]; then 172 | fmt_error "The binary is not executable. Please check your permissions." 173 | exit 1 174 | fi 175 | 176 | echo "Installed the OpenFeature cli to $INSTALL_DIR" 177 | 178 | # Add to PATH information if not already in PATH 179 | if ! echo "$PATH" | tr ":" "\n" | grep -q "^$INSTALL_DIR$"; then 180 | shell_profile="" 181 | case $SHELL in 182 | */bash*) 183 | if [ -f "$HOME/.bashrc" ]; then 184 | shell_profile="$HOME/.bashrc" 185 | elif [ -f "$HOME/.bash_profile" ]; then 186 | shell_profile="$HOME/.bash_profile" 187 | fi 188 | ;; 189 | */zsh*) 190 | shell_profile="$HOME/.zshrc" 191 | ;; 192 | esac 193 | 194 | if [ -n "$shell_profile" ]; then 195 | echo "" 196 | echo "${YELLOW}$INSTALL_DIR is not in your PATH.${RESET}" 197 | echo "To add it to your PATH, run:" 198 | echo " echo 'export PATH=\"\$PATH:$INSTALL_DIR\"' >> $shell_profile" 199 | echo "Then, restart your terminal or run:" 200 | echo " source $shell_profile" 201 | fi 202 | fi 203 | 204 | # Cleanup 205 | rm -rf "$tmp_dir" 206 | } 207 | 208 | install_termux() { 209 | echo "Installing the OpenFeature cli, this may take a few minutes..." 210 | pkg upgrade && pkg install golang git -y && git clone https://github.com/open-feature/cli.git && cd cli/ && go build -o $PREFIX/bin/openfeature 211 | } 212 | 213 | main() { 214 | setup_color 215 | 216 | latest_tag=$(get_latest_release $REPO_NAME) 217 | latest_version=$(echo $latest_tag | sed 's/v//') 218 | version=${VERSION:-$latest_version} 219 | 220 | os=$(get_os) 221 | if test -z "$os"; then 222 | fmt_error "$(uname -s) os type is not supported" 223 | echo "Please create an issue so we can add support. $ISSUE_URL" 224 | exit 1 225 | fi 226 | 227 | machine=$(get_machine) 228 | if test -z "$machine"; then 229 | fmt_error "$(uname -m) machine type is not supported" 230 | echo "Please create an issue so we can add support. $ISSUE_URL" 231 | exit 1 232 | fi 233 | if [ ${TERMUX_VERSION} ] ; then 234 | install_termux 235 | else 236 | echo "Installing OpenFeature CLI to $INSTALL_DIR..." 237 | echo "To use a different install location, press Ctrl+C and run again with:" 238 | echo " INSTALL_DIR=/your/custom/path ./bin/install.sh" 239 | echo "" 240 | sleep 2 # Give user a chance to cancel if needed 241 | 242 | do_install_binary 243 | fi 244 | 245 | printf "$MAGENTA" 246 | cat <<'EOF' 247 | ___ _____ _ 248 | / _ \ _ __ ___ _ __ | ___|__ __ _| |_ _ _ _ __ ___ 249 | | | | | '_ \ / _ \ '_ \| |_ / _ \/ _` | __| | | | '__/ _ \ 250 | | |_| | |_) | __/ | | | _| __/ (_| | |_| |_| | | | __/ 251 | \___/| .__/ \___|_| |_|_| \___|\__,_|\__|\__,_|_| \___| 252 | |_| 253 | CLI 254 | 255 | Run `openfeature help` for commands 256 | 257 | EOF 258 | printf "$RESET" 259 | 260 | } 261 | 262 | main -------------------------------------------------------------------------------- /cmd/openfeature/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/open-feature/cli/internal/cmd" 4 | 5 | var ( 6 | // Overridden by Go Releaser at build time 7 | version = "dev" 8 | commit = "HEAD" 9 | date = "unknown" 10 | ) 11 | 12 | func main() { 13 | cmd.Execute(version, commit, date) 14 | } 15 | -------------------------------------------------------------------------------- /docs/commands/openfeature.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## openfeature 4 | 5 | CLI for OpenFeature. 6 | 7 | ### Synopsis 8 | 9 | CLI for OpenFeature related functionalities. 10 | 11 | ``` 12 | openfeature [flags] 13 | ``` 14 | 15 | ### Options 16 | 17 | ``` 18 | --debug Enable debug logging 19 | -h, --help help for openfeature 20 | -m, --manifest string Path to the flag manifest (default "flags.json") 21 | --no-input Disable interactive prompts 22 | ``` 23 | 24 | ### SEE ALSO 25 | 26 | * [openfeature compare](openfeature_compare.md) - Compare two feature flag manifests 27 | * [openfeature generate](openfeature_generate.md) - Generate typesafe OpenFeature accessors. 28 | * [openfeature init](openfeature_init.md) - Initialize a new project 29 | * [openfeature version](openfeature_version.md) - Print the version number of the OpenFeature CLI 30 | 31 | -------------------------------------------------------------------------------- /docs/commands/openfeature_compare.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## openfeature compare 4 | 5 | Compare two feature flag manifests 6 | 7 | ### Synopsis 8 | 9 | Compare two OpenFeature flag manifests and display the differences in a structured format. 10 | 11 | ``` 12 | openfeature compare [flags] 13 | ``` 14 | 15 | ### Options 16 | 17 | ``` 18 | -a, --against string Path to the target manifest file to compare against 19 | -h, --help help for compare 20 | -o, --output string Output format. Valid formats: tree, flat, json, yaml (default "tree") 21 | ``` 22 | 23 | ### Options inherited from parent commands 24 | 25 | ``` 26 | --debug Enable debug logging 27 | -m, --manifest string Path to the flag manifest (default "flags.json") 28 | --no-input Disable interactive prompts 29 | ``` 30 | 31 | ### SEE ALSO 32 | 33 | * [openfeature](openfeature.md) - CLI for OpenFeature. 34 | 35 | -------------------------------------------------------------------------------- /docs/commands/openfeature_generate.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## openfeature generate 4 | 5 | Generate typesafe OpenFeature accessors. 6 | 7 | ``` 8 | openfeature generate [flags] 9 | ``` 10 | 11 | ### Options 12 | 13 | ``` 14 | -h, --help help for generate 15 | -o, --output string Path to where the generated files should be saved 16 | ``` 17 | 18 | ### Options inherited from parent commands 19 | 20 | ``` 21 | --debug Enable debug logging 22 | -m, --manifest string Path to the flag manifest (default "flags.json") 23 | --no-input Disable interactive prompts 24 | ``` 25 | 26 | ### SEE ALSO 27 | 28 | * [openfeature](openfeature.md) - CLI for OpenFeature. 29 | * [openfeature generate csharp](openfeature_generate_csharp.md) - Generate typesafe C# client. 30 | * [openfeature generate go](openfeature_generate_go.md) - Generate typesafe accessors for OpenFeature. 31 | * [openfeature generate java](openfeature_generate_java.md) - Generate typesafe Java client. 32 | * [openfeature generate nestjs](openfeature_generate_nestjs.md) - Generate typesafe NestJS decorators. 33 | * [openfeature generate nodejs](openfeature_generate_nodejs.md) - Generate typesafe Node.js client. 34 | * [openfeature generate python](openfeature_generate_python.md) - Generate typesafe Python client. 35 | * [openfeature generate react](openfeature_generate_react.md) - Generate typesafe React Hooks. 36 | 37 | -------------------------------------------------------------------------------- /docs/commands/openfeature_generate_csharp.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## openfeature generate csharp 4 | 5 | Generate typesafe C# client. 6 | 7 | 8 | > **Stability**: alpha 9 | 10 | ### Synopsis 11 | 12 | Generate typesafe C# client compatible with the OpenFeature .NET SDK. 13 | 14 | ``` 15 | openfeature generate csharp [flags] 16 | ``` 17 | 18 | ### Options 19 | 20 | ``` 21 | -h, --help help for csharp 22 | --namespace string Namespace for the generated C# code (default "OpenFeature") 23 | ``` 24 | 25 | ### Options inherited from parent commands 26 | 27 | ``` 28 | --debug Enable debug logging 29 | -m, --manifest string Path to the flag manifest (default "flags.json") 30 | --no-input Disable interactive prompts 31 | -o, --output string Path to where the generated files should be saved 32 | ``` 33 | 34 | ### SEE ALSO 35 | 36 | * [openfeature generate](openfeature_generate.md) - Generate typesafe OpenFeature accessors. 37 | 38 | -------------------------------------------------------------------------------- /docs/commands/openfeature_generate_go.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## openfeature generate go 4 | 5 | Generate typesafe accessors for OpenFeature. 6 | 7 | 8 | > **Stability**: alpha 9 | 10 | ### Synopsis 11 | 12 | Generate typesafe accessors compatible with the OpenFeature Go SDK. 13 | 14 | ``` 15 | openfeature generate go [flags] 16 | ``` 17 | 18 | ### Options 19 | 20 | ``` 21 | -h, --help help for go 22 | --package-name string Name of the generated Go package (default "openfeature") 23 | ``` 24 | 25 | ### Options inherited from parent commands 26 | 27 | ``` 28 | --debug Enable debug logging 29 | -m, --manifest string Path to the flag manifest (default "flags.json") 30 | --no-input Disable interactive prompts 31 | -o, --output string Path to where the generated files should be saved 32 | ``` 33 | 34 | ### SEE ALSO 35 | 36 | * [openfeature generate](openfeature_generate.md) - Generate typesafe OpenFeature accessors. 37 | 38 | -------------------------------------------------------------------------------- /docs/commands/openfeature_generate_java.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## openfeature generate java 4 | 5 | Generate typesafe Java client. 6 | 7 | 8 | > **Stability**: alpha 9 | 10 | ### Synopsis 11 | 12 | Generate typesafe Java client compatible with the OpenFeature Java SDK. 13 | 14 | ``` 15 | openfeature generate java [flags] 16 | ``` 17 | 18 | ### Options 19 | 20 | ``` 21 | -h, --help help for java 22 | --package-name string Name of the generated Java package (default "com.example.openfeature") 23 | ``` 24 | 25 | ### Options inherited from parent commands 26 | 27 | ``` 28 | --debug Enable debug logging 29 | -m, --manifest string Path to the flag manifest (default "flags.json") 30 | --no-input Disable interactive prompts 31 | -o, --output string Path to where the generated files should be saved 32 | ``` 33 | 34 | ### SEE ALSO 35 | 36 | * [openfeature generate](openfeature_generate.md) - Generate typesafe OpenFeature accessors. 37 | 38 | -------------------------------------------------------------------------------- /docs/commands/openfeature_generate_nestjs.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## openfeature generate nestjs 4 | 5 | Generate typesafe NestJS decorators. 6 | 7 | 8 | > **Stability**: alpha 9 | 10 | ### Synopsis 11 | 12 | Generate typesafe NestJS decorators compatible with the OpenFeature NestJS SDK. 13 | 14 | ``` 15 | openfeature generate nestjs [flags] 16 | ``` 17 | 18 | ### Options 19 | 20 | ``` 21 | -h, --help help for nestjs 22 | ``` 23 | 24 | ### Options inherited from parent commands 25 | 26 | ``` 27 | --debug Enable debug logging 28 | -m, --manifest string Path to the flag manifest (default "flags.json") 29 | --no-input Disable interactive prompts 30 | -o, --output string Path to where the generated files should be saved 31 | ``` 32 | 33 | ### SEE ALSO 34 | 35 | * [openfeature generate](openfeature_generate.md) - Generate typesafe OpenFeature accessors. 36 | 37 | -------------------------------------------------------------------------------- /docs/commands/openfeature_generate_nodejs.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## openfeature generate nodejs 4 | 5 | Generate typesafe Node.js client. 6 | 7 | 8 | > **Stability**: alpha 9 | 10 | ### Synopsis 11 | 12 | Generate typesafe Node.js client compatible with the OpenFeature JavaScript Server SDK. 13 | 14 | ``` 15 | openfeature generate nodejs [flags] 16 | ``` 17 | 18 | ### Options 19 | 20 | ``` 21 | -h, --help help for nodejs 22 | ``` 23 | 24 | ### Options inherited from parent commands 25 | 26 | ``` 27 | --debug Enable debug logging 28 | -m, --manifest string Path to the flag manifest (default "flags.json") 29 | --no-input Disable interactive prompts 30 | -o, --output string Path to where the generated files should be saved 31 | ``` 32 | 33 | ### SEE ALSO 34 | 35 | * [openfeature generate](openfeature_generate.md) - Generate typesafe OpenFeature accessors. 36 | 37 | -------------------------------------------------------------------------------- /docs/commands/openfeature_generate_python.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## openfeature generate python 4 | 5 | Generate typesafe Python client. 6 | 7 | 8 | > **Stability**: alpha 9 | 10 | ### Synopsis 11 | 12 | Generate typesafe Python client compatible with the OpenFeature Python SDK. 13 | 14 | ``` 15 | openfeature generate python [flags] 16 | ``` 17 | 18 | ### Options 19 | 20 | ``` 21 | -h, --help help for python 22 | ``` 23 | 24 | ### Options inherited from parent commands 25 | 26 | ``` 27 | --debug Enable debug logging 28 | -m, --manifest string Path to the flag manifest (default "flags.json") 29 | --no-input Disable interactive prompts 30 | -o, --output string Path to where the generated files should be saved 31 | ``` 32 | 33 | ### SEE ALSO 34 | 35 | * [openfeature generate](openfeature_generate.md) - Generate typesafe OpenFeature accessors. 36 | 37 | -------------------------------------------------------------------------------- /docs/commands/openfeature_generate_react.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## openfeature generate react 4 | 5 | Generate typesafe React Hooks. 6 | 7 | 8 | > **Stability**: alpha 9 | 10 | ### Synopsis 11 | 12 | Generate typesafe React Hooks compatible with the OpenFeature React SDK. 13 | 14 | ``` 15 | openfeature generate react [flags] 16 | ``` 17 | 18 | ### Options 19 | 20 | ``` 21 | -h, --help help for react 22 | ``` 23 | 24 | ### Options inherited from parent commands 25 | 26 | ``` 27 | --debug Enable debug logging 28 | -m, --manifest string Path to the flag manifest (default "flags.json") 29 | --no-input Disable interactive prompts 30 | -o, --output string Path to where the generated files should be saved 31 | ``` 32 | 33 | ### SEE ALSO 34 | 35 | * [openfeature generate](openfeature_generate.md) - Generate typesafe OpenFeature accessors. 36 | 37 | -------------------------------------------------------------------------------- /docs/commands/openfeature_init.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## openfeature init 4 | 5 | Initialize a new project 6 | 7 | ### Synopsis 8 | 9 | Initialize a new project for OpenFeature CLI. 10 | 11 | ``` 12 | openfeature init [flags] 13 | ``` 14 | 15 | ### Options 16 | 17 | ``` 18 | -h, --help help for init 19 | --override Override an existing configuration 20 | ``` 21 | 22 | ### Options inherited from parent commands 23 | 24 | ``` 25 | --debug Enable debug logging 26 | -m, --manifest string Path to the flag manifest (default "flags.json") 27 | --no-input Disable interactive prompts 28 | ``` 29 | 30 | ### SEE ALSO 31 | 32 | * [openfeature](openfeature.md) - CLI for OpenFeature. 33 | 34 | -------------------------------------------------------------------------------- /docs/commands/openfeature_version.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## openfeature version 4 | 5 | Print the version number of the OpenFeature CLI 6 | 7 | ``` 8 | openfeature version [flags] 9 | ``` 10 | 11 | ### Options 12 | 13 | ``` 14 | -h, --help help for version 15 | ``` 16 | 17 | ### Options inherited from parent commands 18 | 19 | ``` 20 | --debug Enable debug logging 21 | -m, --manifest string Path to the flag manifest (default "flags.json") 22 | --no-input Disable interactive prompts 23 | ``` 24 | 25 | ### SEE ALSO 26 | 27 | * [openfeature](openfeature.md) - CLI for OpenFeature. 28 | 29 | -------------------------------------------------------------------------------- /docs/generate-commands.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/open-feature/cli/internal/cmd" 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/cobra/doc" 12 | ) 13 | 14 | const docPath = "./docs/commands" 15 | 16 | // addStabilityToMarkdown adds stability information to the generated markdown content 17 | func addStabilityToMarkdown(cmd *cobra.Command, content string) string { 18 | if stability, ok := cmd.Annotations["stability"]; ok { 19 | // Look for existing stability info to replace 20 | oldStabilityPattern := "\n> \\*\\*Stability\\*\\*: [a-z]+\n\n" 21 | hasExistingStability := regexp.MustCompile(oldStabilityPattern).MatchString(content) 22 | 23 | if hasExistingStability { 24 | // Replace existing stability info with the current one 25 | return regexp.MustCompile(oldStabilityPattern).ReplaceAllString( 26 | content, 27 | fmt.Sprintf("\n> **Stability**: %s\n\n", stability), 28 | ) 29 | } 30 | 31 | // If no existing stability info, insert it 32 | // Look for the pattern of command title, description, and then either ### Synopsis or ``` 33 | cmdNameLine := fmt.Sprintf("## openfeature%s", cmd.CommandPath()[11:]) 34 | cmdNameIndex := strings.Index(content, cmdNameLine) 35 | 36 | if cmdNameIndex != -1 { 37 | // Find the end of the description section 38 | var insertPoint int 39 | synopsisIndex := strings.Index(content, "### Synopsis") 40 | codeBlockIndex := strings.Index(content, "```") 41 | 42 | if synopsisIndex != -1 { 43 | // If there's a Synopsis section, insert before it 44 | insertPoint = synopsisIndex 45 | } else if codeBlockIndex != -1 { 46 | // If there's a code block, insert before it 47 | insertPoint = codeBlockIndex 48 | } else { 49 | // Default to inserting after the description 50 | descStart := cmdNameIndex + len(cmdNameLine) 51 | nextNewline := strings.Index(content[descStart:], "\n\n") 52 | if nextNewline != -1 { 53 | insertPoint = descStart + nextNewline + 1 54 | } else { 55 | // Fallback to end of file 56 | insertPoint = len(content) 57 | } 58 | } 59 | 60 | stabilityInfo := fmt.Sprintf("\n> **Stability**: %s\n\n", stability) 61 | return content[:insertPoint] + stabilityInfo + content[insertPoint:] 62 | } 63 | } 64 | 65 | // If no stability annotation or couldn't find insertion point, return content unchanged 66 | return content 67 | } 68 | 69 | // Generates cobra docs of the cmd 70 | func main() { 71 | linkHandler := func(name string) string { 72 | return name 73 | } 74 | 75 | filePrepender := func(filename string) string { 76 | return "\n\n" 77 | } 78 | 79 | // Generate the markdown documentation 80 | if err := doc.GenMarkdownTreeCustom(cmd.GetRootCmd(), docPath, filePrepender, linkHandler); err != nil { 81 | fmt.Fprintf(os.Stderr, "error generating docs: %v\n", err) 82 | os.Exit(1) 83 | } 84 | 85 | // Apply the content modifier to all generated files 86 | // This is needed because Cobra doesn't expose a way to modify content during generation 87 | applyContentModifierToFiles(cmd.GetRootCmd(), docPath) 88 | } 89 | 90 | // applyContentModifierToFiles applies our content modifier to all generated markdown files 91 | func applyContentModifierToFiles(root *cobra.Command, docPath string) { 92 | // Process the root command 93 | processCommandFile(root, fmt.Sprintf("%s/%s.md", docPath, root.Name())) 94 | 95 | // Process all descendant commands recursively 96 | processCommandTree(root, docPath) 97 | } 98 | 99 | // processCommandFile applies the content modifier to a single command's markdown file 100 | func processCommandFile(cmd *cobra.Command, filePath string) { 101 | content, err := os.ReadFile(filePath) 102 | if err != nil { 103 | fmt.Fprintf(os.Stderr, "error reading file %s: %v\n", filePath, err) 104 | return 105 | } 106 | 107 | // Apply our content modifier 108 | modifiedContent := addStabilityToMarkdown(cmd, string(content)) 109 | 110 | // Only write the file if content was modified 111 | if modifiedContent != string(content) { 112 | err = os.WriteFile(filePath, []byte(modifiedContent), 0644) 113 | if err != nil { 114 | fmt.Fprintf(os.Stderr, "error writing file %s: %v\n", filePath, err) 115 | } 116 | } 117 | } 118 | 119 | // processCommandTree recursively processes all commands in the command tree 120 | func processCommandTree(cmd *cobra.Command, docPath string) { 121 | for _, subCmd := range cmd.Commands() { 122 | if !subCmd.IsAvailableCommand() || subCmd.IsAdditionalHelpTopicCommand() { 123 | continue 124 | } 125 | 126 | // Calculate the filename for this command 127 | fileName := getMarkdownFilename(cmd, subCmd) 128 | filePath := fmt.Sprintf("%s/%s", docPath, fileName) 129 | 130 | // Process this command's file 131 | processCommandFile(subCmd, filePath) 132 | 133 | // Process its children 134 | processCommandTree(subCmd, docPath) 135 | } 136 | } 137 | 138 | // getMarkdownFilename determines the markdown filename for a command based on its path 139 | func getMarkdownFilename(parent *cobra.Command, cmd *cobra.Command) string { 140 | if parent.Name() == "openfeature" { 141 | return fmt.Sprintf("openfeature_%s.md", cmd.Name()) 142 | } 143 | return fmt.Sprintf("openfeature_%s_%s.md", parent.Name(), cmd.Name()) 144 | } 145 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/open-feature/cli 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | dagger.io/dagger v0.18.9 7 | github.com/google/go-cmp v0.7.0 8 | github.com/iancoleman/strcase v0.3.0 9 | github.com/invopop/jsonschema v0.13.0 10 | github.com/pterm/pterm v0.12.80 11 | github.com/spf13/afero v1.14.0 12 | github.com/spf13/cobra v1.9.1 13 | github.com/spf13/pflag v1.0.6 14 | github.com/spf13/viper v1.20.1 15 | github.com/stretchr/testify v1.10.0 16 | github.com/xeipuuv/gojsonschema v1.2.0 17 | golang.org/x/text v0.25.0 18 | gopkg.in/yaml.v3 v3.0.1 19 | ) 20 | 21 | require ( 22 | atomicgo.dev/cursor v0.2.0 // indirect 23 | atomicgo.dev/keyboard v0.2.9 // indirect 24 | atomicgo.dev/schedule v0.1.0 // indirect 25 | github.com/99designs/gqlgen v0.17.73 // indirect 26 | github.com/Khan/genqlient v0.8.1 // indirect 27 | github.com/adrg/xdg v0.5.3 // indirect 28 | github.com/bahlo/generic-list-go v0.2.0 // indirect 29 | github.com/buger/jsonparser v1.1.1 // indirect 30 | github.com/cenkalti/backoff/v5 v5.0.2 // indirect 31 | github.com/containerd/console v1.0.5 // indirect 32 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 33 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 34 | github.com/fsnotify/fsnotify v1.9.0 // indirect 35 | github.com/go-logr/logr v1.4.3 // indirect 36 | github.com/go-logr/stdr v1.2.2 // indirect 37 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 38 | github.com/google/uuid v1.6.0 // indirect 39 | github.com/gookit/color v1.5.4 // indirect 40 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect 41 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 42 | github.com/lithammer/fuzzysearch v1.1.8 // indirect 43 | github.com/mailru/easyjson v0.9.0 // indirect 44 | github.com/mattn/go-runewidth v0.0.16 // indirect 45 | github.com/mitchellh/go-homedir v1.1.0 // indirect 46 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 47 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 48 | github.com/rivo/uniseg v0.4.7 // indirect 49 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 50 | github.com/sagikazarmark/locafero v0.9.0 // indirect 51 | github.com/sosodev/duration v1.3.1 // indirect 52 | github.com/sourcegraph/conc v0.3.0 // indirect 53 | github.com/spf13/cast v1.8.0 // indirect 54 | github.com/subosito/gotenv v1.6.0 // indirect 55 | github.com/vektah/gqlparser/v2 v2.5.27 // indirect 56 | github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect 57 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect 58 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 59 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 60 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 61 | go.opentelemetry.io/otel v1.36.0 // indirect 62 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2 // indirect 63 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2 // indirect 64 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.36.0 // indirect 65 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.36.0 // indirect 66 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 // indirect 67 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0 // indirect 68 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect 69 | go.opentelemetry.io/otel/log v0.12.2 // indirect 70 | go.opentelemetry.io/otel/metric v1.36.0 // indirect 71 | go.opentelemetry.io/otel/sdk v1.36.0 // indirect 72 | go.opentelemetry.io/otel/sdk/log v0.12.2 // indirect 73 | go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect 74 | go.opentelemetry.io/otel/trace v1.36.0 // indirect 75 | go.opentelemetry.io/proto/otlp v1.7.0 // indirect 76 | go.uber.org/multierr v1.11.0 // indirect 77 | golang.org/x/exp v0.0.0-20250530174510-65e920069ea6 // indirect 78 | golang.org/x/net v0.40.0 // indirect 79 | golang.org/x/sync v0.14.0 // indirect 80 | golang.org/x/sys v0.33.0 // indirect 81 | golang.org/x/term v0.32.0 // indirect 82 | google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect 83 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect 84 | google.golang.org/grpc v1.72.2 // indirect 85 | google.golang.org/protobuf v1.36.6 // indirect 86 | ) 87 | -------------------------------------------------------------------------------- /internal/cmd/compare_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestGetCompareCmd(t *testing.T) { 11 | cmd := GetCompareCmd() 12 | 13 | assert.Equal(t, "compare", cmd.Use) 14 | assert.Equal(t, "Compare two feature flag manifests", cmd.Short) 15 | 16 | // Verify flags exist 17 | againstFlag := cmd.Flag("against") 18 | assert.NotNil(t, againstFlag) 19 | 20 | // Verify output flag 21 | outputFlag := cmd.Flag("output") 22 | assert.NotNil(t, outputFlag) 23 | assert.Equal(t, "tree", outputFlag.DefValue) 24 | } 25 | 26 | func TestCompareManifests(t *testing.T) { 27 | // This test mainly verifies the command executes without errors 28 | // with each of the supported output formats 29 | 30 | formats := []string{"tree", "flat", "json", "yaml"} 31 | 32 | for _, format := range formats { 33 | t.Run(fmt.Sprintf("output_format_%s", format), func(t *testing.T) { 34 | // Need to use the root command to properly inherit the manifest flag 35 | rootCmd := GetRootCmd() 36 | 37 | // Setup command line arguments 38 | rootCmd.SetArgs([]string{ 39 | "compare", 40 | "--manifest", "testdata/source_manifest.json", 41 | "--against", "testdata/target_manifest.json", 42 | "--output", format, 43 | }) 44 | 45 | // Execute command 46 | err := rootCmd.Execute() 47 | assert.NoError(t, err, "Command should execute without errors with output format: "+format) 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /internal/cmd/config.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/open-feature/cli/internal/logger" 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/pflag" 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | // initializeConfig reads in config file and ENV variables if set. 14 | // It applies configuration values to command flags based on hierarchical priority. 15 | func initializeConfig(cmd *cobra.Command, bindPrefix string) error { 16 | v := viper.New() 17 | 18 | // Set the config file name and path 19 | v.SetConfigName(".openfeature") 20 | v.AddConfigPath(".") 21 | 22 | logger.Default.Debug("Looking for .openfeature config file in current directory") 23 | 24 | // Read the config file 25 | if err := v.ReadInConfig(); err != nil { 26 | // It's okay if there isn't a config file 27 | if _, ok := err.(viper.ConfigFileNotFoundError); !ok { 28 | return err 29 | } 30 | logger.Default.Debug("No config file found, using defaults and environment variables") 31 | } else { 32 | logger.Default.Debug(fmt.Sprintf("Using config file: %s", v.ConfigFileUsed())) 33 | } 34 | 35 | // Track which flags were set directly via command line 36 | cmdLineFlags := make(map[string]bool) 37 | cmd.Flags().Visit(func(f *pflag.Flag) { 38 | cmdLineFlags[f.Name] = true 39 | logger.Default.Debug(fmt.Sprintf("Flag set via command line: %s=%s", f.Name, f.Value.String())) 40 | }) 41 | 42 | // Apply the configuration values 43 | cmd.Flags().VisitAll(func(f *pflag.Flag) { 44 | // Skip if flag was set on command line 45 | if cmdLineFlags[f.Name] { 46 | logger.Default.Debug(fmt.Sprintf("Skipping config for %s: already set via command line", f.Name)) 47 | return 48 | } 49 | 50 | // Build configuration paths from most specific to least specific 51 | configPaths := []string{} 52 | 53 | // Check the most specific path (e.g., generate.go.package-name) 54 | if bindPrefix != "" { 55 | configPaths = append(configPaths, bindPrefix+"."+f.Name) 56 | 57 | // Check parent paths (e.g., generate.package-name) 58 | parts := strings.Split(bindPrefix, ".") 59 | for i := len(parts) - 1; i > 0; i-- { 60 | parentPath := strings.Join(parts[:i], ".") + "." + f.Name 61 | configPaths = append(configPaths, parentPath) 62 | } 63 | } 64 | 65 | // Check the base path (e.g., package-name) 66 | configPaths = append(configPaths, f.Name) 67 | 68 | logger.Default.Debug(fmt.Sprintf("Looking for config value for flag %s in paths: %s", f.Name, strings.Join(configPaths, ", "))) 69 | 70 | // Try each path in order until we find a match 71 | for _, path := range configPaths { 72 | if v.IsSet(path) { 73 | val := v.Get(path) 74 | err := f.Value.Set(fmt.Sprintf("%v", val)) 75 | if err != nil { 76 | logger.Default.Debug(fmt.Sprintf("Error setting flag %s from config: %v", f.Name, err)) 77 | } else { 78 | logger.Default.Debug(fmt.Sprintf("Set flag %s=%s from config path %s", f.Name, val, path)) 79 | break 80 | } 81 | } 82 | } 83 | 84 | // Log the final value for the flag 85 | logger.Default.Debug(fmt.Sprintf("Final flag value: %s=%s", f.Name, f.Value.String())) 86 | }) 87 | 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /internal/cmd/config_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func setupTestCommand() *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "test", 15 | } 16 | 17 | // Add some test flags 18 | cmd.Flags().String("output", "", "output path") 19 | cmd.Flags().String("package-name", "default", "package name") 20 | 21 | return cmd 22 | } 23 | 24 | // setupConfigFileForTest creates a temporary directory with a config file 25 | // and changes the working directory to it. 26 | // Returns the original working directory and temp directory path for cleanup. 27 | func setupConfigFileForTest(t *testing.T, configContent string) (string, string) { 28 | // Create a temporary config file 29 | tmpDir, err := os.MkdirTemp("", "config-test") 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | 34 | configPath := filepath.Join(tmpDir, ".openfeature.yaml") 35 | err = os.WriteFile(configPath, []byte(configContent), 0644) 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | 40 | // Change to the temporary directory so the config file can be found 41 | originalDir, _ := os.Getwd() 42 | err = os.Chdir(tmpDir) 43 | if err != nil { 44 | t.Fatal(err) 45 | } 46 | 47 | return originalDir, tmpDir 48 | } 49 | 50 | func TestRootCommandIgnoresUnrelatedConfig(t *testing.T) { 51 | configContent := ` 52 | generate: 53 | output: output-from-generate 54 | ` 55 | originalDir, tmpDir := setupConfigFileForTest(t, configContent) 56 | defer func() { 57 | _ = os.Chdir(originalDir) 58 | _ = os.RemoveAll(tmpDir) 59 | }() 60 | 61 | rootCmd := setupTestCommand() 62 | err := initializeConfig(rootCmd, "") 63 | 64 | assert.NoError(t, err) 65 | assert.Equal(t, "", rootCmd.Flag("output").Value.String(), 66 | "Root command should not get output config from unrelated sections") 67 | } 68 | 69 | func TestGenerateCommandGetsGenerateConfig(t *testing.T) { 70 | configContent := ` 71 | generate: 72 | output: output-from-generate 73 | ` 74 | originalDir, tmpDir := setupConfigFileForTest(t, configContent) 75 | defer func() { 76 | _ = os.Chdir(originalDir) 77 | _ = os.RemoveAll(tmpDir) 78 | }() 79 | 80 | generateCmd := setupTestCommand() 81 | err := initializeConfig(generateCmd, "generate") 82 | 83 | assert.NoError(t, err) 84 | assert.Equal(t, "output-from-generate", generateCmd.Flag("output").Value.String(), 85 | "Generate command should get generate.output value") 86 | } 87 | 88 | func TestSubcommandGetsSpecificConfig(t *testing.T) { 89 | configContent := ` 90 | generate: 91 | output: output-from-generate 92 | go: 93 | output: output-from-go 94 | package-name: fromconfig 95 | ` 96 | originalDir, tmpDir := setupConfigFileForTest(t, configContent) 97 | defer func() { 98 | _ = os.Chdir(originalDir) 99 | _ = os.RemoveAll(tmpDir) 100 | }() 101 | 102 | goCmd := setupTestCommand() 103 | err := initializeConfig(goCmd, "generate.go") 104 | 105 | assert.NoError(t, err) 106 | assert.Equal(t, "output-from-go", goCmd.Flag("output").Value.String(), 107 | "Go command should get generate.go.output, not generate.output") 108 | assert.Equal(t, "fromconfig", goCmd.Flag("package-name").Value.String(), 109 | "Go command should get generate.go.package-name") 110 | } 111 | 112 | func TestSubcommandInheritsFromParent(t *testing.T) { 113 | configContent := ` 114 | generate: 115 | output: output-from-generate 116 | ` 117 | originalDir, tmpDir := setupConfigFileForTest(t, configContent) 118 | defer func() { 119 | _ = os.Chdir(originalDir) 120 | _ = os.RemoveAll(tmpDir) 121 | }() 122 | 123 | otherCmd := setupTestCommand() 124 | err := initializeConfig(otherCmd, "generate.other") 125 | 126 | assert.NoError(t, err) 127 | assert.Equal(t, "output-from-generate", otherCmd.Flag("output").Value.String(), 128 | "Other command should inherit generate.output when no specific config exists") 129 | } 130 | 131 | func TestCommandLineOverridesConfig(t *testing.T) { 132 | // Create a temporary config file 133 | tmpDir, err := os.MkdirTemp("", "config-test") 134 | if err != nil { 135 | t.Fatal(err) 136 | } 137 | defer func() { 138 | _ = os.RemoveAll(tmpDir) 139 | }() 140 | 141 | configPath := filepath.Join(tmpDir, ".openfeature.yaml") 142 | configContent := ` 143 | generate: 144 | output: output-from-config 145 | ` 146 | err = os.WriteFile(configPath, []byte(configContent), 0644) 147 | if err != nil { 148 | t.Fatal(err) 149 | } 150 | 151 | // Change to the temporary directory so the config file can be found 152 | originalDir, _ := os.Getwd() 153 | err = os.Chdir(tmpDir) 154 | if err != nil { 155 | t.Fatal(err) 156 | } 157 | defer func() { 158 | _ = os.Chdir(originalDir) 159 | }() 160 | 161 | // Set up a command with a flag value already set via command line 162 | cmd := setupTestCommand() 163 | _ = cmd.Flags().Set("output", "output-from-cmdline") 164 | 165 | // Initialize config 166 | err = initializeConfig(cmd, "generate") 167 | assert.NoError(t, err) 168 | 169 | // Command line value should take precedence 170 | assert.Equal(t, "output-from-cmdline", cmd.Flag("output").Value.String(), 171 | "Command line value should override config file") 172 | } 173 | -------------------------------------------------------------------------------- /internal/cmd/generate_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | "github.com/open-feature/cli/internal/config" 11 | "github.com/open-feature/cli/internal/filesystem" 12 | 13 | "github.com/spf13/afero" 14 | ) 15 | 16 | // generateTestCase holds the configuration for each generate test 17 | type generateTestCase struct { 18 | name string // test case name 19 | command string // generator to run 20 | manifestGolden string // path to the golden manifest file 21 | outputGolden string // path to the golden output file 22 | outputPath string // output directory (optional, defaults to "output") 23 | outputFile string // output file name 24 | packageName string // optional, used for Go (package-name), Java (package-name) and C# (namespace) 25 | } 26 | 27 | func TestGenerate(t *testing.T) { 28 | testCases := []generateTestCase{ 29 | { 30 | name: "Go generation success", 31 | command: "go", 32 | manifestGolden: "testdata/success_manifest.golden", 33 | outputGolden: "testdata/success_go.golden", 34 | outputFile: "testpackage.go", 35 | packageName: "testpackage", 36 | }, 37 | { 38 | name: "React generation success", 39 | command: "react", 40 | manifestGolden: "testdata/success_manifest.golden", 41 | outputGolden: "testdata/success_react.golden", 42 | outputFile: "openfeature.ts", 43 | }, 44 | { 45 | name: "NodeJS generation success", 46 | command: "nodejs", 47 | manifestGolden: "testdata/success_manifest.golden", 48 | outputGolden: "testdata/success_nodejs.golden", 49 | outputFile: "openfeature.ts", 50 | }, 51 | { 52 | name: "NestJS generation success", 53 | command: "nestjs", 54 | manifestGolden: "testdata/success_manifest.golden", 55 | outputGolden: "testdata/success_nestjs.golden", 56 | outputFile: "openfeature-decorators.ts", 57 | }, 58 | { 59 | name: "Python generation success", 60 | command: "python", 61 | manifestGolden: "testdata/success_manifest.golden", 62 | outputGolden: "testdata/success_python.golden", 63 | outputFile: "openfeature.py", 64 | }, 65 | { 66 | name: "CSharp generation success", 67 | command: "csharp", 68 | manifestGolden: "testdata/success_manifest.golden", 69 | outputGolden: "testdata/success_csharp.golden", 70 | outputFile: "OpenFeature.g.cs", 71 | packageName: "TestNamespace", // Using packageName field for namespace 72 | }, 73 | { 74 | name: "Java generation success", 75 | command: "java", 76 | manifestGolden: "testdata/success_manifest.golden", 77 | outputGolden: "testdata/success_java.golden", 78 | outputFile: "OpenFeature.java", 79 | packageName: "com.example.openfeature", 80 | }, 81 | // Add more test cases here as needed 82 | } 83 | 84 | for _, tc := range testCases { 85 | t.Run(tc.name, func(t *testing.T) { 86 | cmd := GetGenerateCmd() 87 | 88 | // global flag exists on root only. 89 | config.AddRootFlags(cmd) 90 | 91 | // Constant paths 92 | const memoryManifestPath = "manifest/path.json" 93 | 94 | // Use default output path if not specified 95 | outputPath := tc.outputPath 96 | if outputPath == "" { 97 | outputPath = "output" 98 | } 99 | 100 | // Prepare in-memory files 101 | fs := afero.NewMemMapFs() 102 | filesystem.SetFileSystem(fs) 103 | readOsFileAndWriteToMemMap(t, tc.manifestGolden, memoryManifestPath, fs) 104 | 105 | // Prepare command arguments 106 | args := []string{ 107 | tc.command, 108 | "--manifest", memoryManifestPath, 109 | "--output", outputPath, 110 | } 111 | 112 | // Add parameters specific to each generator 113 | if tc.packageName != "" { 114 | if tc.command == "csharp" { 115 | args = append(args, "--namespace", tc.packageName) 116 | } else if tc.command == "go" { 117 | args = append(args, "--package-name", tc.packageName) 118 | } else if tc.command == "java" { 119 | args = append(args, "--package-name", tc.packageName) 120 | } 121 | } 122 | 123 | cmd.SetArgs(args) 124 | 125 | // Run command 126 | err := cmd.Execute() 127 | if err != nil { 128 | t.Error(err) 129 | } 130 | 131 | // Compare result 132 | compareOutput(t, tc.outputGolden, filepath.Join(outputPath, tc.outputFile), fs) 133 | }) 134 | } 135 | } 136 | 137 | func readOsFileAndWriteToMemMap(t *testing.T, inputPath string, memPath string, memFs afero.Fs) { 138 | data, err := os.ReadFile(inputPath) 139 | if err != nil { 140 | t.Fatalf("error reading file %q: %v", inputPath, err) 141 | } 142 | if err := memFs.MkdirAll(filepath.Dir(memPath), os.ModePerm); err != nil { 143 | t.Fatalf("error creating directory %q: %v", filepath.Dir(memPath), err) 144 | } 145 | f, err := memFs.Create(memPath) 146 | if err != nil { 147 | t.Fatalf("error creating file %q: %v", memPath, err) 148 | } 149 | defer f.Close() 150 | writtenBytes, err := f.Write(data) 151 | if err != nil { 152 | t.Fatalf("error writing contents to file %q: %v", memPath, err) 153 | } 154 | if writtenBytes != len(data) { 155 | t.Fatalf("error writing entire file %v: writtenBytes != expectedWrittenBytes", memPath) 156 | } 157 | } 158 | 159 | // normalizeLines trims trailing whitespace and carriage returns from each line. 160 | // This helps ensure consistent comparison by ignoring formatting differences like indentation or line endings. 161 | func normalizeLines(input []string) []string { 162 | normalized := make([]string, len(input)) 163 | for i, line := range input { 164 | // Trim right whitespace and convert \r\n or \r to \n 165 | normalized[i] = strings.TrimRight(line, " \t\r") 166 | } 167 | return normalized 168 | } 169 | 170 | func compareOutput(t *testing.T, testFile, memoryOutputPath string, fs afero.Fs) { 171 | want, err := os.ReadFile(testFile) 172 | if err != nil { 173 | t.Fatalf("error reading file %q: %v", testFile, err) 174 | } 175 | 176 | got, err := afero.ReadFile(fs, memoryOutputPath) 177 | if err != nil { 178 | t.Fatalf("error reading file %q: %v", memoryOutputPath, err) 179 | } 180 | 181 | // Convert to string arrays by splitting on newlines 182 | wantLines := normalizeLines(strings.Split(string(want), "\n")) 183 | gotLines := normalizeLines(strings.Split(string(got), "\n")) 184 | 185 | if diff := cmp.Diff(wantLines, gotLines); diff != "" { 186 | t.Errorf("output mismatch (-want +got):\n%s", diff) 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /internal/cmd/init.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/open-feature/cli/internal/config" 7 | "github.com/open-feature/cli/internal/filesystem" 8 | "github.com/open-feature/cli/internal/logger" 9 | "github.com/open-feature/cli/internal/manifest" 10 | "github.com/pterm/pterm" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func GetInitCmd() *cobra.Command { 15 | initCmd := &cobra.Command{ 16 | Use: "init", 17 | Short: "Initialize a new project", 18 | Long: "Initialize a new project for OpenFeature CLI.", 19 | PreRunE: func(cmd *cobra.Command, args []string) error { 20 | return initializeConfig(cmd, "init") 21 | }, 22 | RunE: func(cmd *cobra.Command, args []string) error { 23 | manifestPath := config.GetManifestPath(cmd) 24 | override := config.GetOverride(cmd) 25 | 26 | manifestExists, _ := filesystem.Exists(manifestPath) 27 | if manifestExists && !override { 28 | logger.Default.Debug(fmt.Sprintf("Manifest file already exists at %s", manifestPath)) 29 | confirmMessage := fmt.Sprintf("An existing manifest was found at %s. Would you like to override it?", manifestPath) 30 | shouldOverride, _ := pterm.DefaultInteractiveConfirm.Show(confirmMessage) 31 | // Print a blank line for better readability. 32 | pterm.Println() 33 | if !shouldOverride { 34 | logger.Default.Info("No changes were made.") 35 | return nil 36 | } 37 | 38 | logger.Default.Debug("User confirmed override of existing manifest") 39 | } 40 | 41 | logger.Default.Info("Initializing project...") 42 | err := manifest.Create(manifestPath) 43 | if err != nil { 44 | logger.Default.Error(fmt.Sprintf("Failed to create manifest: %v", err)) 45 | return err 46 | } 47 | 48 | logger.Default.FileCreated(manifestPath) 49 | logger.Default.Success("Project initialized.") 50 | return nil 51 | }, 52 | } 53 | 54 | config.AddInitFlags(initCmd) 55 | 56 | addStabilityInfo(initCmd) 57 | 58 | return initCmd 59 | } 60 | -------------------------------------------------------------------------------- /internal/cmd/init_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/open-feature/cli/internal/config" 7 | "github.com/open-feature/cli/internal/filesystem" 8 | "github.com/spf13/afero" 9 | ) 10 | 11 | func TestInitCmd(t *testing.T) { 12 | fs := afero.NewMemMapFs() 13 | filesystem.SetFileSystem(fs) 14 | outputFile := "flags-test.json" 15 | cmd := GetInitCmd() 16 | // global flag exists on root only. 17 | config.AddRootFlags(cmd) 18 | 19 | cmd.SetArgs([]string{ 20 | "-m", 21 | outputFile, 22 | }) 23 | err := cmd.Execute() 24 | if err != nil { 25 | t.Error(err) 26 | } 27 | compareOutput(t, "testdata/success_init.golden", outputFile, fs) 28 | } 29 | -------------------------------------------------------------------------------- /internal/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/open-feature/cli/internal/config" 8 | "github.com/open-feature/cli/internal/logger" 9 | 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var ( 14 | Version = "dev" 15 | Commit string 16 | Date string 17 | ) 18 | 19 | // Execute adds all child commands to the root command and sets flags appropriately. 20 | // This is called by main.main(). It only needs to happen once to the rootCmd. 21 | func Execute(version string, commit string, date string) { 22 | Version = version 23 | Commit = commit 24 | Date = date 25 | if err := GetRootCmd().Execute(); err != nil { 26 | logger.Default.Error(err.Error()) 27 | os.Exit(1) 28 | } 29 | } 30 | 31 | func GetRootCmd() *cobra.Command { 32 | // Execute all parent's persistent hooks 33 | cobra.EnableTraverseRunHooks = true 34 | 35 | rootCmd := &cobra.Command{ 36 | Use: "openfeature", 37 | Short: "CLI for OpenFeature.", 38 | Long: `CLI for OpenFeature related functionalities.`, 39 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 40 | debug, _ := cmd.Flags().GetBool("debug") 41 | logger.Default.SetDebug(debug) 42 | logger.Default.Debug("Debug logging enabled") 43 | return initializeConfig(cmd, "") 44 | }, 45 | RunE: func(cmd *cobra.Command, args []string) error { 46 | printBanner() 47 | logger.Default.Println("") 48 | logger.Default.Println("To see all the options, try 'openfeature --help'") 49 | return nil 50 | }, 51 | SilenceErrors: true, 52 | SilenceUsage: true, 53 | DisableSuggestions: false, 54 | SuggestionsMinimumDistance: 2, 55 | DisableAutoGenTag: true, 56 | } 57 | 58 | // Add global flags using the config package 59 | config.AddRootFlags(rootCmd) 60 | 61 | // Add subcommands 62 | rootCmd.AddCommand(GetVersionCmd()) 63 | rootCmd.AddCommand(GetInitCmd()) 64 | rootCmd.AddCommand(GetGenerateCmd()) 65 | rootCmd.AddCommand(GetCompareCmd()) 66 | 67 | // Add a custom error handler after the command is created 68 | rootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { 69 | logger.Default.Error(fmt.Sprintf("Invalid flag: %s", err)) 70 | logger.Default.Info("Run 'openfeature --help' for usage information") 71 | return err 72 | }) 73 | 74 | return rootCmd 75 | } 76 | -------------------------------------------------------------------------------- /internal/cmd/testdata/source_manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag_manifest.json", 3 | "flags": { 4 | "darkMode": { 5 | "flagType": "boolean", 6 | "description": "Enable dark mode", 7 | "defaultValue": false 8 | }, 9 | "backgroundColor": { 10 | "flagType": "string", 11 | "description": "Background color for the application", 12 | "defaultValue": "white" 13 | }, 14 | "maxItems": { 15 | "flagType": "integer", 16 | "description": "Maximum number of items to display", 17 | "defaultValue": 10 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /internal/cmd/testdata/success_go.golden: -------------------------------------------------------------------------------- 1 | // AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. 2 | package testpackage 3 | 4 | import ( 5 | "context" 6 | "github.com/open-feature/go-sdk/openfeature" 7 | ) 8 | 9 | type BooleanProvider func(ctx context.Context, evalCtx openfeature.EvaluationContext) (bool, error) 10 | type BooleanProviderDetails func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.BooleanEvaluationDetails, error) 11 | type FloatProvider func(ctx context.Context, evalCtx openfeature.EvaluationContext) (float64, error) 12 | type FloatProviderDetails func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.FloatEvaluationDetails, error) 13 | type IntProvider func(ctx context.Context, evalCtx openfeature.EvaluationContext) (int64, error) 14 | type IntProviderDetails func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.IntEvaluationDetails, error) 15 | type StringProvider func(ctx context.Context, evalCtx openfeature.EvaluationContext) (string, error) 16 | type StringProviderDetails func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.StringEvaluationDetails, error) 17 | 18 | var client openfeature.IClient = nil 19 | // Discount percentage applied to purchases. 20 | var DiscountPercentage = struct { 21 | // Value returns the value of the flag DiscountPercentage, 22 | // as well as the evaluation error, if present. 23 | Value FloatProvider 24 | 25 | // ValueWithDetails returns the value of the flag DiscountPercentage, 26 | // the evaluation error, if any, and the evaluation details. 27 | ValueWithDetails FloatProviderDetails 28 | }{ 29 | Value: func(ctx context.Context, evalCtx openfeature.EvaluationContext) (float64, error) { 30 | return client.FloatValue(ctx, "discountPercentage", 0.15, evalCtx) 31 | }, 32 | ValueWithDetails: func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.FloatEvaluationDetails, error){ 33 | return client.FloatValueDetails(ctx, "discountPercentage", 0.15, evalCtx) 34 | }, 35 | } 36 | // Controls whether Feature A is enabled. 37 | var EnableFeatureA = struct { 38 | // Value returns the value of the flag EnableFeatureA, 39 | // as well as the evaluation error, if present. 40 | Value BooleanProvider 41 | 42 | // ValueWithDetails returns the value of the flag EnableFeatureA, 43 | // the evaluation error, if any, and the evaluation details. 44 | ValueWithDetails BooleanProviderDetails 45 | }{ 46 | Value: func(ctx context.Context, evalCtx openfeature.EvaluationContext) (bool, error) { 47 | return client.BooleanValue(ctx, "enableFeatureA", false, evalCtx) 48 | }, 49 | ValueWithDetails: func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.BooleanEvaluationDetails, error){ 50 | return client.BooleanValueDetails(ctx, "enableFeatureA", false, evalCtx) 51 | }, 52 | } 53 | // The message to use for greeting users. 54 | var GreetingMessage = struct { 55 | // Value returns the value of the flag GreetingMessage, 56 | // as well as the evaluation error, if present. 57 | Value StringProvider 58 | 59 | // ValueWithDetails returns the value of the flag GreetingMessage, 60 | // the evaluation error, if any, and the evaluation details. 61 | ValueWithDetails StringProviderDetails 62 | }{ 63 | Value: func(ctx context.Context, evalCtx openfeature.EvaluationContext) (string, error) { 64 | return client.StringValue(ctx, "greetingMessage", "Hello there!", evalCtx) 65 | }, 66 | ValueWithDetails: func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.StringEvaluationDetails, error){ 67 | return client.StringValueDetails(ctx, "greetingMessage", "Hello there!", evalCtx) 68 | }, 69 | } 70 | // Maximum allowed length for usernames. 71 | var UsernameMaxLength = struct { 72 | // Value returns the value of the flag UsernameMaxLength, 73 | // as well as the evaluation error, if present. 74 | Value IntProvider 75 | 76 | // ValueWithDetails returns the value of the flag UsernameMaxLength, 77 | // the evaluation error, if any, and the evaluation details. 78 | ValueWithDetails IntProviderDetails 79 | }{ 80 | Value: func(ctx context.Context, evalCtx openfeature.EvaluationContext) (int64, error) { 81 | return client.IntValue(ctx, "usernameMaxLength", 50, evalCtx) 82 | }, 83 | ValueWithDetails: func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.IntEvaluationDetails, error){ 84 | return client.IntValueDetails(ctx, "usernameMaxLength", 50, evalCtx) 85 | }, 86 | } 87 | 88 | func init() { 89 | client = openfeature.GetApiInstance().GetClient() 90 | } 91 | -------------------------------------------------------------------------------- /internal/cmd/testdata/success_init.golden: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/open-feature/cli/main/schema/v0/flag-manifest.json", 3 | "flags": {} 4 | } -------------------------------------------------------------------------------- /internal/cmd/testdata/success_java.golden: -------------------------------------------------------------------------------- 1 | // AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. 2 | package com.example.openfeature; 3 | 4 | import dev.openfeature.sdk.Client; 5 | import dev.openfeature.sdk.EvaluationContext; 6 | import dev.openfeature.sdk.FlagEvaluationDetails; 7 | import dev.openfeature.sdk.OpenFeatureAPI; 8 | 9 | public final class OpenFeature { 10 | 11 | private OpenFeature() {} // prevent instantiation 12 | 13 | public interface GeneratedClient { 14 | 15 | /** 16 | * Discount percentage applied to purchases. 17 | * Details: 18 | * - Flag key: discountPercentage 19 | * - Type: Double 20 | * - Default value: 0.15 21 | * Returns the flag value 22 | */ 23 | Double discountPercentage(EvaluationContext ctx); 24 | 25 | /** 26 | * Discount percentage applied to purchases. 27 | * Details: 28 | * - Flag key: discountPercentage 29 | * - Type: Double 30 | * - Default value: 0.15 31 | * Returns the evaluation details containing the flag value and metadata 32 | */ 33 | FlagEvaluationDetails discountPercentageDetails(EvaluationContext ctx); 34 | 35 | /** 36 | * Controls whether Feature A is enabled. 37 | * Details: 38 | * - Flag key: enableFeatureA 39 | * - Type: Boolean 40 | * - Default value: false 41 | * Returns the flag value 42 | */ 43 | Boolean enableFeatureA(EvaluationContext ctx); 44 | 45 | /** 46 | * Controls whether Feature A is enabled. 47 | * Details: 48 | * - Flag key: enableFeatureA 49 | * - Type: Boolean 50 | * - Default value: false 51 | * Returns the evaluation details containing the flag value and metadata 52 | */ 53 | FlagEvaluationDetails enableFeatureADetails(EvaluationContext ctx); 54 | 55 | /** 56 | * The message to use for greeting users. 57 | * Details: 58 | * - Flag key: greetingMessage 59 | * - Type: String 60 | * - Default value: Hello there! 61 | * Returns the flag value 62 | */ 63 | String greetingMessage(EvaluationContext ctx); 64 | 65 | /** 66 | * The message to use for greeting users. 67 | * Details: 68 | * - Flag key: greetingMessage 69 | * - Type: String 70 | * - Default value: Hello there! 71 | * Returns the evaluation details containing the flag value and metadata 72 | */ 73 | FlagEvaluationDetails greetingMessageDetails(EvaluationContext ctx); 74 | 75 | /** 76 | * Maximum allowed length for usernames. 77 | * Details: 78 | * - Flag key: usernameMaxLength 79 | * - Type: Integer 80 | * - Default value: 50 81 | * Returns the flag value 82 | */ 83 | Integer usernameMaxLength(EvaluationContext ctx); 84 | 85 | /** 86 | * Maximum allowed length for usernames. 87 | * Details: 88 | * - Flag key: usernameMaxLength 89 | * - Type: Integer 90 | * - Default value: 50 91 | * Returns the evaluation details containing the flag value and metadata 92 | */ 93 | FlagEvaluationDetails usernameMaxLengthDetails(EvaluationContext ctx); 94 | 95 | } 96 | 97 | private static final class OpenFeatureGeneratedClient implements GeneratedClient { 98 | private final Client client; 99 | 100 | private OpenFeatureGeneratedClient(Client client) { 101 | this.client = client; 102 | } 103 | 104 | 105 | @Override 106 | public Double discountPercentage(EvaluationContext ctx) { 107 | return client.getDoubleValue("discountPercentage", 0.15, ctx); 108 | } 109 | 110 | @Override 111 | public FlagEvaluationDetails discountPercentageDetails(EvaluationContext ctx) { 112 | return client.getDoubleDetails("discountPercentage", 0.15, ctx); 113 | } 114 | 115 | @Override 116 | public Boolean enableFeatureA(EvaluationContext ctx) { 117 | return client.getBooleanValue("enableFeatureA", false, ctx); 118 | } 119 | 120 | @Override 121 | public FlagEvaluationDetails enableFeatureADetails(EvaluationContext ctx) { 122 | return client.getBooleanDetails("enableFeatureA", false, ctx); 123 | } 124 | 125 | @Override 126 | public String greetingMessage(EvaluationContext ctx) { 127 | return client.getStringValue("greetingMessage", "Hello there!", ctx); 128 | } 129 | 130 | @Override 131 | public FlagEvaluationDetails greetingMessageDetails(EvaluationContext ctx) { 132 | return client.getStringDetails("greetingMessage", "Hello there!", ctx); 133 | } 134 | 135 | @Override 136 | public Integer usernameMaxLength(EvaluationContext ctx) { 137 | return client.getIntegerValue("usernameMaxLength", 50, ctx); 138 | } 139 | 140 | @Override 141 | public FlagEvaluationDetails usernameMaxLengthDetails(EvaluationContext ctx) { 142 | return client.getIntegerDetails("usernameMaxLength", 50, ctx); 143 | } 144 | 145 | } 146 | 147 | public static GeneratedClient getClient() { 148 | return new OpenFeatureGeneratedClient(OpenFeatureAPI.getInstance().getClient()); 149 | } 150 | 151 | public static GeneratedClient getClient(String domain) { 152 | return new OpenFeatureGeneratedClient(OpenFeatureAPI.getInstance().getClient(domain)); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /internal/cmd/testdata/success_manifest.golden: -------------------------------------------------------------------------------- 1 | { 2 | "flags": { 3 | "enableFeatureA": { 4 | "flagType": "boolean", 5 | "defaultValue": false, 6 | "description": "Controls whether Feature A is enabled." 7 | }, 8 | "usernameMaxLength": { 9 | "flagType": "integer", 10 | "defaultValue": 50, 11 | "description": "Maximum allowed length for usernames." 12 | }, 13 | "greetingMessage": { 14 | "flagType": "string", 15 | "defaultValue": "Hello there!", 16 | "description": "The message to use for greeting users." 17 | }, 18 | "discountPercentage": { 19 | "flagType": "float", 20 | "defaultValue": 0.15, 21 | "description": "Discount percentage applied to purchases." 22 | }, 23 | "themeCustomization": { 24 | "flagType": "object", 25 | "defaultValue": { 26 | "primaryColor": "#007bff", 27 | "secondaryColor": "#6c757d" 28 | }, 29 | "description": "Allows customization of theme colors." 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /internal/cmd/testdata/success_nestjs.golden: -------------------------------------------------------------------------------- 1 | import type { DynamicModule, FactoryProvider as NestFactoryProvider } from "@nestjs/common"; 2 | import { Inject, Module } from "@nestjs/common"; 3 | import type { Observable } from "rxjs"; 4 | 5 | import type { 6 | OpenFeature, 7 | Client, 8 | EvaluationContext, 9 | EvaluationDetails, 10 | OpenFeatureModuleOptions, 11 | } from "@openfeature/nestjs-sdk"; 12 | import { OpenFeatureModule, BooleanFeatureFlag, StringFeatureFlag, NumberFeatureFlag } from "@openfeature/nestjs-sdk"; 13 | 14 | import type { GeneratedClient } from "./openfeature"; 15 | import { getGeneratedClient } from "./openfeature"; 16 | 17 | /** 18 | * Returns an injection token for a (domain scoped) generated OpenFeature client. 19 | * @param {string} domain The domain of the generated OpenFeature client. 20 | * @returns {string} The injection token. 21 | */ 22 | export function getOpenFeatureGeneratedClientToken(domain?: string): string { 23 | return domain ? `OpenFeatureGeneratedClient_${domain}` : "OpenFeatureGeneratedClient_default"; 24 | } 25 | 26 | /** 27 | * Options for injecting an OpenFeature client into a constructor. 28 | */ 29 | interface FeatureClientProps { 30 | /** 31 | * The domain of the OpenFeature client, if a domain scoped client should be used. 32 | * @see {@link Client.getBooleanDetails} 33 | */ 34 | domain?: string; 35 | } 36 | 37 | /** 38 | * Injects a generated typesafe feature client into a constructor or property of a class. 39 | * @param {FeatureClientProps} [props] The options for injecting the client. 40 | * @returns {PropertyDecorator & ParameterDecorator} The decorator function. 41 | */ 42 | export const GeneratedOpenFeatureClient = (props?: FeatureClientProps): PropertyDecorator & ParameterDecorator => 43 | Inject(getOpenFeatureGeneratedClientToken(props?.domain)); 44 | 45 | /** 46 | * GeneratedOpenFeatureModule is a generated typesafe NestJS wrapper for OpenFeature Server-SDK. 47 | */ 48 | @Module({}) 49 | export class GeneratedOpenFeatureModule extends OpenFeatureModule { 50 | static override forRoot({ useGlobalInterceptor = true, ...options }: OpenFeatureModuleOptions): DynamicModule { 51 | const module = super.forRoot({ useGlobalInterceptor, ...options }); 52 | 53 | const clientValueProviders: NestFactoryProvider[] = [ 54 | { 55 | provide: getOpenFeatureGeneratedClientToken(), 56 | useFactory: () => getGeneratedClient(), 57 | }, 58 | ]; 59 | 60 | if (options?.providers) { 61 | const domainClientProviders: NestFactoryProvider[] = Object.keys(options.providers).map( 62 | (domain) => ({ 63 | provide: getOpenFeatureGeneratedClientToken(domain), 64 | useFactory: () => getGeneratedClient(domain), 65 | }), 66 | ); 67 | 68 | clientValueProviders.push(...domainClientProviders); 69 | } 70 | 71 | return { 72 | ...module, 73 | providers: module.providers ? [...module.providers, ...clientValueProviders] : clientValueProviders, 74 | exports: module.exports ? [...module.exports, ...clientValueProviders] : clientValueProviders, 75 | }; 76 | } 77 | } 78 | 79 | /** 80 | * Options for injecting a typed feature flag into a route handler. 81 | */ 82 | interface TypedFeatureProps { 83 | /** 84 | * The domain of the OpenFeature client, if a domain scoped client should be used. 85 | * @see {@link OpenFeature#getClient} 86 | */ 87 | domain?: string; 88 | /** 89 | * The {@link EvaluationContext} for evaluating the feature flag. 90 | * @see {@link OpenFeature#getClient} 91 | */ 92 | context?: EvaluationContext; 93 | } 94 | 95 | 96 | /** 97 | * Gets the {@link EvaluationDetails} for `discountPercentage` from a domain scoped or the default OpenFeature 98 | * client and populates the annotated parameter with the {@link EvaluationDetails} wrapped in an {@link Observable}. 99 | * 100 | * **Details:** 101 | * - flag key: `discountPercentage` 102 | * - description: `Discount percentage applied to purchases.` 103 | * - default value: `0.15` 104 | * - type: `number` 105 | * 106 | * Usage: 107 | * ```typescript 108 | * @Get("/") 109 | * public async handleRequest( 110 | * @DiscountPercentage() 111 | * discountPercentage: Observable>, 112 | * ) 113 | * ``` 114 | * @param {TypedFeatureProps} props The options for injecting the feature flag. 115 | * @returns {ParameterDecorator} The decorator function. 116 | */ 117 | export function DiscountPercentage(props?: TypedFeatureProps): ParameterDecorator { 118 | return NumberFeatureFlag({ flagKey: "discountPercentage", defaultValue: 0.15, ...props }); 119 | } 120 | 121 | /** 122 | * Gets the {@link EvaluationDetails} for `enableFeatureA` from a domain scoped or the default OpenFeature 123 | * client and populates the annotated parameter with the {@link EvaluationDetails} wrapped in an {@link Observable}. 124 | * 125 | * **Details:** 126 | * - flag key: `enableFeatureA` 127 | * - description: `Controls whether Feature A is enabled.` 128 | * - default value: `false` 129 | * - type: `boolean` 130 | * 131 | * Usage: 132 | * ```typescript 133 | * @Get("/") 134 | * public async handleRequest( 135 | * @EnableFeatureA() 136 | * enableFeatureA: Observable>, 137 | * ) 138 | * ``` 139 | * @param {TypedFeatureProps} props The options for injecting the feature flag. 140 | * @returns {ParameterDecorator} The decorator function. 141 | */ 142 | export function EnableFeatureA(props?: TypedFeatureProps): ParameterDecorator { 143 | return BooleanFeatureFlag({ flagKey: "enableFeatureA", defaultValue: false, ...props }); 144 | } 145 | 146 | /** 147 | * Gets the {@link EvaluationDetails} for `greetingMessage` from a domain scoped or the default OpenFeature 148 | * client and populates the annotated parameter with the {@link EvaluationDetails} wrapped in an {@link Observable}. 149 | * 150 | * **Details:** 151 | * - flag key: `greetingMessage` 152 | * - description: `The message to use for greeting users.` 153 | * - default value: `Hello there!` 154 | * - type: `string` 155 | * 156 | * Usage: 157 | * ```typescript 158 | * @Get("/") 159 | * public async handleRequest( 160 | * @GreetingMessage() 161 | * greetingMessage: Observable>, 162 | * ) 163 | * ``` 164 | * @param {TypedFeatureProps} props The options for injecting the feature flag. 165 | * @returns {ParameterDecorator} The decorator function. 166 | */ 167 | export function GreetingMessage(props?: TypedFeatureProps): ParameterDecorator { 168 | return StringFeatureFlag({ flagKey: "greetingMessage", defaultValue: "Hello there!", ...props }); 169 | } 170 | 171 | /** 172 | * Gets the {@link EvaluationDetails} for `usernameMaxLength` from a domain scoped or the default OpenFeature 173 | * client and populates the annotated parameter with the {@link EvaluationDetails} wrapped in an {@link Observable}. 174 | * 175 | * **Details:** 176 | * - flag key: `usernameMaxLength` 177 | * - description: `Maximum allowed length for usernames.` 178 | * - default value: `50` 179 | * - type: `number` 180 | * 181 | * Usage: 182 | * ```typescript 183 | * @Get("/") 184 | * public async handleRequest( 185 | * @UsernameMaxLength() 186 | * usernameMaxLength: Observable>, 187 | * ) 188 | * ``` 189 | * @param {TypedFeatureProps} props The options for injecting the feature flag. 190 | * @returns {ParameterDecorator} The decorator function. 191 | */ 192 | export function UsernameMaxLength(props?: TypedFeatureProps): ParameterDecorator { 193 | return NumberFeatureFlag({ flagKey: "usernameMaxLength", defaultValue: 50, ...props }); 194 | } 195 | -------------------------------------------------------------------------------- /internal/cmd/testdata/success_react.golden: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | type ReactFlagEvaluationOptions, 5 | type ReactFlagEvaluationNoSuspenseOptions, 6 | useFlag, 7 | useSuspenseFlag, 8 | } from "@openfeature/react-sdk"; 9 | 10 | /** 11 | * Discount percentage applied to purchases. 12 | * 13 | * **Details:** 14 | * - flag key: `discountPercentage` 15 | * - default value: `0.15` 16 | * - type: `number` 17 | */ 18 | export const useDiscountPercentage = (options?: ReactFlagEvaluationOptions) => { 19 | return useFlag("discountPercentage", 0.15, options); 20 | }; 21 | 22 | /** 23 | * Discount percentage applied to purchases. 24 | * 25 | * **Details:** 26 | * - flag key: `discountPercentage` 27 | * - default value: `0.15` 28 | * - type: `number` 29 | * 30 | * Equivalent to useFlag with options: `{ suspend: true }` 31 | * @experimental — Suspense is an experimental feature subject to change in future versions. 32 | */ 33 | export const useSuspenseDiscountPercentage = (options?: ReactFlagEvaluationNoSuspenseOptions) => { 34 | return useSuspenseFlag("discountPercentage", 0.15, options); 35 | }; 36 | 37 | /** 38 | * Controls whether Feature A is enabled. 39 | * 40 | * **Details:** 41 | * - flag key: `enableFeatureA` 42 | * - default value: `false` 43 | * - type: `boolean` 44 | */ 45 | export const useEnableFeatureA = (options?: ReactFlagEvaluationOptions) => { 46 | return useFlag("enableFeatureA", false, options); 47 | }; 48 | 49 | /** 50 | * Controls whether Feature A is enabled. 51 | * 52 | * **Details:** 53 | * - flag key: `enableFeatureA` 54 | * - default value: `false` 55 | * - type: `boolean` 56 | * 57 | * Equivalent to useFlag with options: `{ suspend: true }` 58 | * @experimental — Suspense is an experimental feature subject to change in future versions. 59 | */ 60 | export const useSuspenseEnableFeatureA = (options?: ReactFlagEvaluationNoSuspenseOptions) => { 61 | return useSuspenseFlag("enableFeatureA", false, options); 62 | }; 63 | 64 | /** 65 | * The message to use for greeting users. 66 | * 67 | * **Details:** 68 | * - flag key: `greetingMessage` 69 | * - default value: `Hello there!` 70 | * - type: `string` 71 | */ 72 | export const useGreetingMessage = (options?: ReactFlagEvaluationOptions) => { 73 | return useFlag("greetingMessage", "Hello there!", options); 74 | }; 75 | 76 | /** 77 | * The message to use for greeting users. 78 | * 79 | * **Details:** 80 | * - flag key: `greetingMessage` 81 | * - default value: `Hello there!` 82 | * - type: `string` 83 | * 84 | * Equivalent to useFlag with options: `{ suspend: true }` 85 | * @experimental — Suspense is an experimental feature subject to change in future versions. 86 | */ 87 | export const useSuspenseGreetingMessage = (options?: ReactFlagEvaluationNoSuspenseOptions) => { 88 | return useSuspenseFlag("greetingMessage", "Hello there!", options); 89 | }; 90 | 91 | /** 92 | * Maximum allowed length for usernames. 93 | * 94 | * **Details:** 95 | * - flag key: `usernameMaxLength` 96 | * - default value: `50` 97 | * - type: `number` 98 | */ 99 | export const useUsernameMaxLength = (options?: ReactFlagEvaluationOptions) => { 100 | return useFlag("usernameMaxLength", 50, options); 101 | }; 102 | 103 | /** 104 | * Maximum allowed length for usernames. 105 | * 106 | * **Details:** 107 | * - flag key: `usernameMaxLength` 108 | * - default value: `50` 109 | * - type: `number` 110 | * 111 | * Equivalent to useFlag with options: `{ suspend: true }` 112 | * @experimental — Suspense is an experimental feature subject to change in future versions. 113 | */ 114 | export const useSuspenseUsernameMaxLength = (options?: ReactFlagEvaluationNoSuspenseOptions) => { 115 | return useSuspenseFlag("usernameMaxLength", 50, options); 116 | }; 117 | -------------------------------------------------------------------------------- /internal/cmd/testdata/target_manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag_manifest.json", 3 | "flags": { 4 | "darkMode": { 5 | "flagType": "boolean", 6 | "description": "Enable dark mode for the application", 7 | "defaultValue": true 8 | }, 9 | "backgroundColor": { 10 | "flagType": "string", 11 | "description": "Background color for the application", 12 | "defaultValue": "black" 13 | }, 14 | "welcomeMessage": { 15 | "flagType": "string", 16 | "description": "Welcome message to display", 17 | "defaultValue": "Hello, Welcome to OpenFeature!" 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /internal/cmd/utils.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/pterm/pterm" 4 | 5 | func printBanner() { 6 | ivrit := ` 7 | ___ _____ _ 8 | / _ \ _ __ ___ _ __ | ___|__ __ _| |_ _ _ _ __ ___ 9 | | | | | '_ \ / _ \ '_ \| |_ / _ \/ _` + "`" + ` | __| | | | '__/ _ \ 10 | | |_| | |_) | __/ | | | _| __/ (_| | |_| |_| | | | __/ 11 | \___/| .__/ \___|_| |_|_| \___|\__,_|\__|\__,_|_| \___| 12 | |_| 13 | CLI 14 | ` 15 | 16 | pterm.Println(ivrit) 17 | pterm.Println() 18 | pterm.Printf("version: %s | compiled: %s\n", pterm.LightGreen(Version), pterm.LightGreen(Date)) 19 | pterm.Println(pterm.Cyan("🔗 https://openfeature.dev | https://github.com/open-feature/cli")) 20 | } 21 | -------------------------------------------------------------------------------- /internal/cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "runtime/debug" 6 | 7 | "github.com/open-feature/cli/internal/logger" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func GetVersionCmd() *cobra.Command { 12 | versionCmd := &cobra.Command{ 13 | Use: "version", 14 | Short: "Print the version number of the OpenFeature CLI", 15 | Long: ``, 16 | Run: func(cmd *cobra.Command, args []string) { 17 | if Version == "dev" { 18 | logger.Default.Debug("Development version detected, attempting to get build info") 19 | details, ok := debug.ReadBuildInfo() 20 | if ok && details.Main.Version != "" && details.Main.Version != "(devel)" { 21 | Version = details.Main.Version 22 | for _, i := range details.Settings { 23 | if i.Key == "vcs.time" { 24 | Date = i.Value 25 | logger.Default.Debug(fmt.Sprintf("Found build date: %s", Date)) 26 | } 27 | if i.Key == "vcs.revision" { 28 | Commit = i.Value 29 | logger.Default.Debug(fmt.Sprintf("Found commit: %s", Commit)) 30 | } 31 | } 32 | } 33 | } 34 | 35 | versionInfo := fmt.Sprintf("OpenFeature CLI: %s (%s), built at: %s", Version, Commit, Date) 36 | logger.Default.Info(versionInfo) 37 | }, 38 | } 39 | 40 | return versionCmd 41 | } 42 | -------------------------------------------------------------------------------- /internal/config/flags.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | // Flag name constants to avoid duplication 8 | const ( 9 | DebugFlagName = "debug" 10 | ManifestFlagName = "manifest" 11 | OutputFlagName = "output" 12 | NoInputFlagName = "no-input" 13 | GoPackageFlagName = "package-name" 14 | CSharpNamespaceName = "namespace" 15 | OverrideFlagName = "override" 16 | JavaPackageFlagName = "package-name" 17 | ) 18 | 19 | // Default values for flags 20 | const ( 21 | DefaultManifestPath = "flags.json" 22 | DefaultOutputPath = "" 23 | DefaultGoPackageName = "openfeature" 24 | DefaultCSharpNamespace = "OpenFeature" 25 | DefaultJavaPackageName = "com.example.openfeature" 26 | ) 27 | 28 | // AddRootFlags adds the common flags to the given command 29 | func AddRootFlags(cmd *cobra.Command) { 30 | cmd.PersistentFlags().StringP(ManifestFlagName, "m", DefaultManifestPath, "Path to the flag manifest") 31 | cmd.PersistentFlags().Bool(NoInputFlagName, false, "Disable interactive prompts") 32 | cmd.PersistentFlags().Bool(DebugFlagName, false, "Enable debug logging") 33 | } 34 | 35 | // AddGenerateFlags adds the common generate flags to the given command 36 | func AddGenerateFlags(cmd *cobra.Command) { 37 | cmd.PersistentFlags().StringP(OutputFlagName, "o", DefaultOutputPath, "Path to where the generated files should be saved") 38 | } 39 | 40 | // AddGoGenerateFlags adds the go generator specific flags to the given command 41 | func AddGoGenerateFlags(cmd *cobra.Command) { 42 | cmd.Flags().String(GoPackageFlagName, DefaultGoPackageName, "Name of the generated Go package") 43 | } 44 | 45 | // AddCSharpGenerateFlags adds the C# generator specific flags to the given command 46 | func AddCSharpGenerateFlags(cmd *cobra.Command) { 47 | cmd.Flags().String(CSharpNamespaceName, DefaultCSharpNamespace, "Namespace for the generated C# code") 48 | } 49 | 50 | // AddJavaGenerateFlags adds the Java generator specific flags to the given command 51 | func AddJavaGenerateFlags(cmd *cobra.Command) { 52 | cmd.Flags().String(JavaPackageFlagName, DefaultJavaPackageName, "Name of the generated Java package") 53 | } 54 | 55 | // AddInitFlags adds the init command specific flags 56 | func AddInitFlags(cmd *cobra.Command) { 57 | cmd.Flags().Bool(OverrideFlagName, false, "Override an existing configuration") 58 | } 59 | 60 | // GetManifestPath gets the manifest path from the given command 61 | func GetManifestPath(cmd *cobra.Command) string { 62 | manifestPath, _ := cmd.Flags().GetString(ManifestFlagName) 63 | return manifestPath 64 | } 65 | 66 | // GetOutputPath gets the output path from the given command 67 | func GetOutputPath(cmd *cobra.Command) string { 68 | outputPath, _ := cmd.Flags().GetString(OutputFlagName) 69 | return outputPath 70 | } 71 | 72 | // GetGoPackageName gets the Go package name from the given command 73 | func GetGoPackageName(cmd *cobra.Command) string { 74 | goPackageName, _ := cmd.Flags().GetString(GoPackageFlagName) 75 | return goPackageName 76 | } 77 | 78 | // GetCSharpNamespace gets the C# namespace from the given command 79 | func GetCSharpNamespace(cmd *cobra.Command) string { 80 | namespace, _ := cmd.Flags().GetString(CSharpNamespaceName) 81 | return namespace 82 | } 83 | 84 | // GetJavaPackageName gets the Java package name from the given command 85 | func GetJavaPackageName(cmd *cobra.Command) string { 86 | javaPackageName, _ := cmd.Flags().GetString(JavaPackageFlagName) 87 | return javaPackageName 88 | } 89 | 90 | // GetNoInput gets the no-input flag from the given command 91 | func GetNoInput(cmd *cobra.Command) bool { 92 | noInput, _ := cmd.Flags().GetBool(NoInputFlagName) 93 | return noInput 94 | } 95 | 96 | // GetOverride gets the override flag from the given command 97 | func GetOverride(cmd *cobra.Command) bool { 98 | override, _ := cmd.Flags().GetBool(OverrideFlagName) 99 | return override 100 | } 101 | -------------------------------------------------------------------------------- /internal/filesystem/filesystem.go: -------------------------------------------------------------------------------- 1 | // Package filesystem contains the filesystem interface. 2 | package filesystem 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/spf13/afero" 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | var viperKey = "filesystem" 14 | 15 | // Get the filesystem interface from the viper configuration. 16 | // If the filesystem interface is not set, the default filesystem interface is returned. 17 | func FileSystem() afero.Fs { 18 | return viper.Get(viperKey).(afero.Fs) 19 | } 20 | 21 | // Set the filesystem interface in the viper configuration. 22 | // This is useful for testing purposes. 23 | func SetFileSystem(fs afero.Fs) { 24 | viper.Set(viperKey, fs) 25 | } 26 | 27 | // Writes data to a file at the given path using the filesystem interface. 28 | // If the file does not exist, it will be created, including all necessary directories. 29 | // If the file exists, it will be overwritten. 30 | func WriteFile(path string, data []byte) error { 31 | fs := FileSystem() 32 | if err := fs.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { 33 | return err 34 | } 35 | f, err := fs.Create(path) 36 | if err != nil { 37 | return fmt.Errorf("error creating file %q: %v", path, err) 38 | } 39 | defer f.Close() 40 | writtenBytes, err := f.Write(data) 41 | if err != nil { 42 | return fmt.Errorf("error writing contents to file %q: %v", path, err) 43 | } 44 | if writtenBytes != len(data) { 45 | return fmt.Errorf("error writing entire file %v: writtenBytes != expectedWrittenBytes", path) 46 | } 47 | 48 | return nil 49 | } 50 | 51 | // Checks if a file exists at the given path using the filesystem interface. 52 | func Exists(path string) (bool, error) { 53 | fs := FileSystem() 54 | _, err := fs.Stat(path) 55 | if err != nil { 56 | if os.IsNotExist(err) { 57 | return false, nil 58 | } 59 | return false, err 60 | } 61 | return true, nil 62 | } 63 | 64 | func init() { 65 | viper.SetDefault(viperKey, afero.NewOsFs()) 66 | } 67 | -------------------------------------------------------------------------------- /internal/flagset/flagset.go: -------------------------------------------------------------------------------- 1 | package flagset 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "sort" 8 | "strings" 9 | 10 | "github.com/open-feature/cli/internal/filesystem" 11 | "github.com/open-feature/cli/internal/manifest" 12 | "github.com/spf13/afero" 13 | ) 14 | 15 | // FlagType are the primitive types of flags. 16 | type FlagType int 17 | 18 | // Collection of the different kinds of flag types 19 | const ( 20 | UnknownFlagType FlagType = iota 21 | IntType 22 | FloatType 23 | BoolType 24 | StringType 25 | ObjectType 26 | ) 27 | 28 | func (f FlagType) String() string { 29 | switch f { 30 | case IntType: 31 | return "int" 32 | case FloatType: 33 | return "float" 34 | case BoolType: 35 | return "bool" 36 | case StringType: 37 | return "string" 38 | case ObjectType: 39 | return "object" 40 | default: 41 | return "unknown" 42 | } 43 | } 44 | 45 | type Flag struct { 46 | Key string 47 | Type FlagType 48 | Description string 49 | DefaultValue any 50 | } 51 | 52 | type Flagset struct { 53 | Flags []Flag 54 | } 55 | 56 | // Loads, validates, and unmarshals the manifest file at the given path into a flagset 57 | func Load(manifestPath string) (*Flagset, error) { 58 | fs := filesystem.FileSystem() 59 | data, err := afero.ReadFile(fs, manifestPath) 60 | if err != nil { 61 | return nil, fmt.Errorf("error reading contents from file %q", manifestPath) 62 | } 63 | 64 | validationErrors, err := manifest.Validate(data) 65 | if err != nil { 66 | return nil, err 67 | } else if len(validationErrors) > 0 { 68 | return nil, errors.New(FormatValidationError(validationErrors)) 69 | } 70 | 71 | var flagset Flagset 72 | if err := json.Unmarshal(data, &flagset); err != nil { 73 | return nil, fmt.Errorf("error unmarshaling JSON: %v", validationErrors) 74 | } 75 | 76 | return &flagset, nil 77 | } 78 | 79 | // Filter removes flags from the Flagset that are of unsupported types. 80 | func (fs *Flagset) Filter(unsupportedFlagTypes map[FlagType]bool) *Flagset { 81 | var filtered Flagset 82 | for _, flag := range fs.Flags { 83 | if !unsupportedFlagTypes[flag.Type] { 84 | filtered.Flags = append(filtered.Flags, flag) 85 | } 86 | } 87 | return &filtered 88 | } 89 | 90 | // UnmarshalJSON unmarshals the JSON data into a Flagset. It is used by json.Unmarshal. 91 | func (fs *Flagset) UnmarshalJSON(data []byte) error { 92 | var manifest struct { 93 | Flags map[string]struct { 94 | FlagType string `json:"flagType"` 95 | Description string `json:"description"` 96 | DefaultValue any `json:"defaultValue"` 97 | } `json:"flags"` 98 | } 99 | 100 | if err := json.Unmarshal(data, &manifest); err != nil { 101 | return err 102 | } 103 | 104 | for key, flag := range manifest.Flags { 105 | var flagType FlagType 106 | switch flag.FlagType { 107 | case "integer": 108 | flagType = IntType 109 | case "float": 110 | flagType = FloatType 111 | case "boolean": 112 | flagType = BoolType 113 | case "string": 114 | flagType = StringType 115 | case "object": 116 | flagType = ObjectType 117 | default: 118 | return errors.New("unknown flag type") 119 | } 120 | 121 | fs.Flags = append(fs.Flags, Flag{ 122 | Key: key, 123 | Type: flagType, 124 | Description: flag.Description, 125 | DefaultValue: flag.DefaultValue, 126 | }) 127 | } 128 | 129 | // Ensure consistency of order of flag generation. 130 | sort.Slice(fs.Flags, func(i, j int) bool { 131 | return fs.Flags[i].Key < fs.Flags[j].Key 132 | }) 133 | 134 | return nil 135 | } 136 | func FormatValidationError(issues []manifest.ValidationError) string { 137 | var sb strings.Builder 138 | sb.WriteString("flag manifest validation failed:\n\n") 139 | 140 | // Group messages by flag path 141 | grouped := make(map[string]struct { 142 | flagType string 143 | messages []string 144 | }) 145 | 146 | for _, issue := range issues { 147 | entry := grouped[issue.Path] 148 | entry.flagType = issue.Type 149 | entry.messages = append(entry.messages, issue.Message) 150 | grouped[issue.Path] = entry 151 | } 152 | 153 | // Sort paths for consistent output 154 | paths := make([]string, 0, len(grouped)) 155 | for path := range grouped { 156 | paths = append(paths, path) 157 | } 158 | sort.Strings(paths) 159 | 160 | // Format each row 161 | for _, path := range paths { 162 | entry := grouped[path] 163 | flagType := entry.flagType 164 | if flagType == "" { 165 | flagType = "missing" 166 | } 167 | sb.WriteString(fmt.Sprintf( 168 | "- flagType: %s\n flagPath: %s\n errors:\n ~ %s\n \tSuggestions:\n \t- flagType: boolean\n \t- defaultValue: true\n\n", 169 | flagType, 170 | path, 171 | strings.Join(entry.messages, "\n ~ "), 172 | )) 173 | } 174 | return sb.String() 175 | } 176 | -------------------------------------------------------------------------------- /internal/flagset/flagset_test.go: -------------------------------------------------------------------------------- 1 | package flagset 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/open-feature/cli/internal/manifest" 8 | ) 9 | 10 | // Sample test for FormatValidationError 11 | func TestFormatValidationError_SortsByPath(t *testing.T) { 12 | issues := []manifest.ValidationError{ 13 | {Path: "zeta.flag", Type: "boolean", Message: "must not be empty"}, 14 | {Path: "alpha.flag", Type: "string", Message: "invalid value"}, 15 | {Path: "beta.flag", Type: "number", Message: "must be greater than zero"}, 16 | } 17 | 18 | output := FormatValidationError(issues) 19 | 20 | // The output should mention 'alpha.flag' before 'beta.flag', and 'beta.flag' before 'zeta.flag' 21 | alphaIdx := strings.Index(output, "flagPath: alpha.flag") 22 | betaIdx := strings.Index(output, "flagPath: beta.flag") 23 | zetaIdx := strings.Index(output, "flagPath: zeta.flag") 24 | 25 | if !(alphaIdx < betaIdx && betaIdx < zetaIdx) { 26 | t.Errorf("flag paths are not sorted: alphaIdx=%d, betaIdx=%d, zetaIdx=%d\nOutput:\n%s", 27 | alphaIdx, betaIdx, zetaIdx, output) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /internal/generators/README.md: -------------------------------------------------------------------------------- 1 | # Generators 2 | 3 | This directory contains the code generators for different programming languages. Each generator is responsible for generating code based on the OpenFeature flag manifest. 4 | 5 | ## Structure 6 | 7 | Each generator should be placed in its own directory under `/internal/generators`. The directory should be named after the target language (e.g., `golang`, `react`). 8 | 9 | Each generator directory should contain the following files: 10 | 11 | - `language.go`: This file contains the implementation of the generator logic for the target language. Replace `language` with the name of the target language (e.g., `golang.go`, `react.go`). 12 | - `language.tmpl`: This file contains the template used by the generator to produce the output code. Replace `language` with the name of the target language (e.g., `golang.tmpl`, `react.tmpl`). 13 | 14 | ## How Generators Work 15 | 16 | Each generator consists of two main components: the `language.go` file and the `language.tmpl` file. The `language.go` file contains the logic for processing the feature flag manifest and generating the output code, while the `language.tmpl` file defines the template used to produce the final code. 17 | 18 | ### `language.go` 19 | 20 | The `language.go` file is responsible for reading the feature flag manifest, processing the data, and applying it to the template defined in the `language.tmpl` file. This file typically includes functions for parsing the manifest, preparing the data for the template, and writing the generated code to the appropriate output files. 21 | 22 | ### `language.tmpl` 23 | 24 | The `language.tmpl` file is a text template that defines the structure of the generated code. It uses the Go template syntax to insert data from the feature flag manifest into the appropriate places in the template. The `language.go` file processes this template and fills in the data to produce the final code. 25 | 26 | ### Example Workflow 27 | 28 | 1. The `language.go` file reads the feature flag manifest and parses the data. 29 | 2. The data is processed and prepared for the template. 30 | 3. The `language.go` file applies the data to the `language.tmpl` file using the Go template engine. 31 | 4. The generated code is written to the appropriate output files. 32 | 33 | By following this pattern, you can create generators for different programming languages that produce consistent and reliable code based on the feature flag manifest. 34 | 35 | ## Example 36 | 37 | Here is an example structure for a Go generator: 38 | 39 | ``` 40 | /internal/generators/ 41 | golang/ 42 | golang.go 43 | golang.tmpl 44 | ``` 45 | 46 | ## Adding a New Generator 47 | 48 | To add a new generator, follow these steps: 49 | 50 | 1. Create a new directory under `/internal/generators` with the name of the target language. 51 | 2. Add the `language.go` and `language.tmpl` files to the new directory. 52 | 3. Implement the generator logic in the `language.go` file. 53 | 4. Create the template in the `language.tmpl` file. 54 | 5. Ensure that your generator follows the existing patterns and conventions used in the project. 55 | 6. Write tests for your generator to ensure it works as expected. 56 | 7. Update the documentation to include information about your new generator. 57 | 58 | We appreciate your contributions and look forward to seeing your new generators! -------------------------------------------------------------------------------- /internal/generators/csharp/csharp.go: -------------------------------------------------------------------------------- 1 | package csharp 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "text/template" 7 | 8 | "github.com/open-feature/cli/internal/flagset" 9 | "github.com/open-feature/cli/internal/generators" 10 | ) 11 | 12 | type CsharpGenerator struct { 13 | generators.CommonGenerator 14 | } 15 | 16 | type Params struct { 17 | // Add C# specific parameters here if needed 18 | Namespace string 19 | } 20 | 21 | //go:embed csharp.tmpl 22 | var csharpTmpl string 23 | 24 | func openFeatureType(t flagset.FlagType) string { 25 | switch t { 26 | case flagset.IntType: 27 | return "int" 28 | case flagset.FloatType: 29 | return "double" // .NET uses double, not float 30 | case flagset.BoolType: 31 | return "bool" 32 | case flagset.StringType: 33 | return "string" 34 | default: 35 | return "" 36 | } 37 | } 38 | 39 | func formatDefaultValue(flag flagset.Flag) string { 40 | switch flag.Type { 41 | case flagset.StringType: 42 | return fmt.Sprintf("\"%s\"", flag.DefaultValue) 43 | case flagset.BoolType: 44 | if flag.DefaultValue == true { 45 | return "true" 46 | } 47 | return "false" 48 | default: 49 | return fmt.Sprintf("%v", flag.DefaultValue) 50 | } 51 | } 52 | 53 | func (g *CsharpGenerator) Generate(params *generators.Params[Params]) error { 54 | funcs := template.FuncMap{ 55 | "OpenFeatureType": openFeatureType, 56 | "FormatDefaultValue": formatDefaultValue, 57 | } 58 | 59 | newParams := &generators.Params[any]{ 60 | OutputPath: params.OutputPath, 61 | Custom: params.Custom, 62 | } 63 | 64 | return g.GenerateFile(funcs, csharpTmpl, newParams, "OpenFeature.g.cs") 65 | } 66 | 67 | // NewGenerator creates a generator for C#. 68 | func NewGenerator(fs *flagset.Flagset) *CsharpGenerator { 69 | return &CsharpGenerator{ 70 | CommonGenerator: *generators.NewGenerator(fs, map[flagset.FlagType]bool{ 71 | flagset.ObjectType: true, 72 | }), 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /internal/generators/csharp/csharp.tmpl: -------------------------------------------------------------------------------- 1 | // AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Threading.Tasks; 5 | using System.Threading; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using OpenFeature; 8 | using OpenFeature.Model; 9 | 10 | namespace {{ if .Params.Custom.Namespace }}{{ .Params.Custom.Namespace }}{{ else }}OpenFeatureGenerated{{ end }} 11 | { 12 | /// 13 | /// Service collection extensions for OpenFeature 14 | /// 15 | public static class OpenFeatureServiceExtensions 16 | { 17 | /// 18 | /// Adds OpenFeature services to the service collection with the generated client 19 | /// 20 | /// The service collection to add services to 21 | /// The service collection for chaining 22 | public static IServiceCollection AddOpenFeature(this IServiceCollection services) 23 | { 24 | return services 25 | .AddSingleton(_ => Api.Instance) 26 | .AddSingleton(provider => provider.GetRequiredService().GetClient()) 27 | .AddSingleton(); 28 | } 29 | 30 | /// 31 | /// Adds OpenFeature services to the service collection with the generated client for a specific domain 32 | /// 33 | /// The service collection to add services to 34 | /// The domain to get the client for 35 | /// The service collection for chaining 36 | public static IServiceCollection AddOpenFeature(this IServiceCollection services, string domain) 37 | { 38 | return services 39 | .AddSingleton(_ => Api.Instance) 40 | .AddSingleton(provider => provider.GetRequiredService().GetClient(domain)) 41 | .AddSingleton(); 42 | } 43 | } 44 | 45 | /// 46 | /// Generated OpenFeature client for typesafe flag access 47 | /// 48 | public class GeneratedClient 49 | { 50 | private readonly IFeatureClient _client; 51 | 52 | /// 53 | /// Initializes a new instance of the class. 54 | /// 55 | /// The OpenFeature client to use for flag evaluations. 56 | public GeneratedClient(IFeatureClient client) 57 | { 58 | _client = client ?? throw new ArgumentNullException(nameof(client)); 59 | } 60 | 61 | {{- range .Flagset.Flags }} 62 | /// 63 | /// {{ .Description }} 64 | /// 65 | /// 66 | /// Flag key: {{ .Key }} 67 | /// Default value: {{ .DefaultValue }} 68 | /// Type: {{ .Type | OpenFeatureType }} 69 | /// 70 | /// Optional context for the flag evaluation 71 | /// Options for flag evaluation 72 | /// The flag value 73 | public async Task<{{ .Type | OpenFeatureType }}> {{ .Key | ToPascal }}Async(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) 74 | { 75 | {{- if eq .Type 1 }} 76 | return await _client.GetIntegerValueAsync("{{ .Key }}", {{ . | FormatDefaultValue }}, evaluationContext, options); 77 | {{- else if eq .Type 2 }} 78 | return await _client.GetDoubleValueAsync("{{ .Key }}", {{ . | FormatDefaultValue }}, evaluationContext, options); 79 | {{- else if eq .Type 3 }} 80 | return await _client.GetBooleanValueAsync("{{ .Key }}", {{ . | FormatDefaultValue }}, evaluationContext, options); 81 | {{- else if eq .Type 4 }} 82 | return await _client.GetStringValueAsync("{{ .Key }}", {{ . | FormatDefaultValue }}, evaluationContext, options); 83 | {{- else }} 84 | throw new NotSupportedException("Unsupported flag type"); 85 | {{- end }} 86 | } 87 | 88 | /// 89 | /// {{ .Description }} 90 | /// 91 | /// 92 | /// Flag key: {{ .Key }} 93 | /// Default value: {{ .DefaultValue }} 94 | /// Type: {{ .Type | OpenFeatureType }} 95 | /// 96 | /// Optional context for the flag evaluation 97 | /// Options for flag evaluation 98 | /// The evaluation details containing the flag value and metadata 99 | public async Task> {{ .Key | ToPascal }}DetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) 100 | { 101 | {{- if eq .Type 1 }} 102 | return await _client.GetIntegerDetailsAsync("{{ .Key }}", {{ . | FormatDefaultValue }}, evaluationContext, options); 103 | {{- else if eq .Type 2 }} 104 | return await _client.GetDoubleDetailsAsync("{{ .Key }}", {{ . | FormatDefaultValue }}, evaluationContext, options); 105 | {{- else if eq .Type 3 }} 106 | return await _client.GetBooleanDetailsAsync("{{ .Key }}", {{ . | FormatDefaultValue }}, evaluationContext, options); 107 | {{- else if eq .Type 4 }} 108 | return await _client.GetStringDetailsAsync("{{ .Key }}", {{ . | FormatDefaultValue }}, evaluationContext, options); 109 | {{- else }} 110 | throw new NotSupportedException("Unsupported flag type"); 111 | {{- end }} 112 | } 113 | {{ end }} 114 | 115 | /// 116 | /// Creates a new GeneratedClient using the default OpenFeature client 117 | /// 118 | /// A new GeneratedClient instance 119 | public static GeneratedClient CreateClient() 120 | { 121 | return new GeneratedClient(Api.Instance.GetClient()); 122 | } 123 | 124 | /// 125 | /// Creates a new GeneratedClient using a domain-specific OpenFeature client 126 | /// 127 | /// The domain to get the client for 128 | /// A new GeneratedClient instance 129 | public static GeneratedClient CreateClient(string domain) 130 | { 131 | return new GeneratedClient(Api.Instance.GetClient(domain)); 132 | } 133 | 134 | /// 135 | /// Creates a new GeneratedClient using a domain-specific OpenFeature client with context 136 | /// 137 | /// The domain to get the client for 138 | /// Default context to use for evaluations 139 | /// A new GeneratedClient instance 140 | public static GeneratedClient CreateClient(string domain, EvaluationContext? evaluationContext = null) 141 | { 142 | return new GeneratedClient(Api.Instance.GetClient(domain)); 143 | } 144 | } 145 | } -------------------------------------------------------------------------------- /internal/generators/func.go: -------------------------------------------------------------------------------- 1 | package generators 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | "text/template" 7 | 8 | "github.com/iancoleman/strcase" 9 | "golang.org/x/text/cases" 10 | ) 11 | 12 | func defaultFuncs() template.FuncMap { 13 | // Update the contributing doc when adding a new function 14 | return template.FuncMap{ 15 | // Remapping ToCamel to ToPascal to match the expected behavior 16 | // Ref: https://github.com/iancoleman/strcase/issues/53 17 | "ToPascal": strcase.ToCamel, 18 | // Remapping ToLowerCamel to ToCamel to match the expected behavior 19 | // Ref: See above 20 | "ToCamel": strcase.ToLowerCamel, 21 | "ToKebab": strcase.ToKebab, 22 | "ToScreamingKebab": strcase.ToScreamingKebab, 23 | "ToSnake": strcase.ToSnake, 24 | "ToScreamingSnake": strcase.ToScreamingSnake, 25 | "ToUpper": strings.ToUpper, 26 | "ToLower": strings.ToLower, 27 | "Title": cases.Title, 28 | "Quote": strconv.Quote, 29 | "QuoteString": func(input any) any { 30 | if str, ok := input.(string); ok { 31 | return strconv.Quote(str) 32 | } 33 | return input 34 | }, 35 | } 36 | } 37 | 38 | func init() { 39 | // results in "Api" using ToCamel("API") 40 | // results in "api" using ToLowerCamel("API") 41 | strcase.ConfigureAcronym("API", "api") 42 | } 43 | -------------------------------------------------------------------------------- /internal/generators/generators.go: -------------------------------------------------------------------------------- 1 | package generators 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "path/filepath" 7 | "text/template" 8 | 9 | "maps" 10 | 11 | "github.com/open-feature/cli/internal/filesystem" 12 | "github.com/open-feature/cli/internal/flagset" 13 | "github.com/open-feature/cli/internal/logger" 14 | ) 15 | 16 | // Represents the stability level of a generator 17 | type Stability string 18 | 19 | const ( 20 | Alpha Stability = "alpha" 21 | Beta Stability = "beta" 22 | Stable Stability = "stable" 23 | ) 24 | 25 | type CommonGenerator struct { 26 | Flagset *flagset.Flagset 27 | } 28 | 29 | type Params[T any] struct { 30 | OutputPath string 31 | Custom T 32 | } 33 | 34 | type TemplateData struct { 35 | CommonGenerator 36 | Params[any] 37 | } 38 | 39 | // NewGenerator creates a new generator 40 | func NewGenerator(flagset *flagset.Flagset, UnsupportedFlagTypes map[flagset.FlagType]bool) *CommonGenerator { 41 | return &CommonGenerator{ 42 | Flagset: flagset.Filter(UnsupportedFlagTypes), 43 | } 44 | } 45 | 46 | func (g *CommonGenerator) GenerateFile(customFunc template.FuncMap, tmpl string, params *Params[any], name string) error { 47 | funcs := defaultFuncs() 48 | maps.Copy(funcs, customFunc) 49 | 50 | logger.Default.Debug(fmt.Sprintf("Generating file: %s", name)) 51 | 52 | generatorTemplate, err := template.New("generator").Funcs(funcs).Parse(tmpl) 53 | if err != nil { 54 | return fmt.Errorf("error initializing template: %v", err) 55 | } 56 | 57 | var buf bytes.Buffer 58 | data := TemplateData{ 59 | CommonGenerator: *g, 60 | Params: *params, 61 | } 62 | if err := generatorTemplate.Execute(&buf, data); err != nil { 63 | return fmt.Errorf("error executing template: %v", err) 64 | } 65 | 66 | fullPath := filepath.Join(params.OutputPath, name) 67 | if err := filesystem.WriteFile(fullPath, buf.Bytes()); err != nil { 68 | logger.Default.FileFailed(fullPath, err) 69 | return err 70 | } 71 | 72 | // Log successful file creation 73 | logger.Default.FileCreated(fullPath) 74 | 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /internal/generators/golang/golang.go: -------------------------------------------------------------------------------- 1 | package golang 2 | 3 | import ( 4 | _ "embed" 5 | "sort" 6 | "text/template" 7 | 8 | "github.com/open-feature/cli/internal/flagset" 9 | "github.com/open-feature/cli/internal/generators" 10 | ) 11 | 12 | type GolangGenerator struct { 13 | generators.CommonGenerator 14 | } 15 | 16 | type Params struct { 17 | GoPackage string 18 | } 19 | 20 | //go:embed golang.tmpl 21 | var golangTmpl string 22 | 23 | func openFeatureType(t flagset.FlagType) string { 24 | switch t { 25 | case flagset.IntType: 26 | return "Int" 27 | case flagset.FloatType: 28 | return "Float" 29 | case flagset.BoolType: 30 | return "Boolean" 31 | case flagset.StringType: 32 | return "String" 33 | default: 34 | return "" 35 | } 36 | } 37 | 38 | func typeString(flagType flagset.FlagType) string { 39 | switch flagType { 40 | case flagset.StringType: 41 | return "string" 42 | case flagset.IntType: 43 | return "int64" 44 | case flagset.BoolType: 45 | return "bool" 46 | case flagset.FloatType: 47 | return "float64" 48 | default: 49 | return "" 50 | } 51 | } 52 | 53 | func supportImports(flags []flagset.Flag) []string { 54 | var res []string 55 | if len(flags) > 0 { 56 | res = append(res, "\"context\"") 57 | res = append(res, "\"github.com/open-feature/go-sdk/openfeature\"") 58 | } 59 | sort.Strings(res) 60 | return res 61 | } 62 | 63 | func (g *GolangGenerator) Generate(params *generators.Params[Params]) error { 64 | funcs := template.FuncMap{ 65 | "SupportImports": supportImports, 66 | "OpenFeatureType": openFeatureType, 67 | "TypeString": typeString, 68 | } 69 | 70 | newParams := &generators.Params[any]{ 71 | OutputPath: params.OutputPath, 72 | Custom: Params{ 73 | GoPackage: params.Custom.GoPackage, 74 | }, 75 | } 76 | 77 | return g.GenerateFile(funcs, golangTmpl, newParams, params.Custom.GoPackage+".go") 78 | } 79 | 80 | // NewGenerator creates a generator for Go. 81 | func NewGenerator(fs *flagset.Flagset) *GolangGenerator { 82 | return &GolangGenerator{ 83 | CommonGenerator: *generators.NewGenerator(fs, map[flagset.FlagType]bool{ 84 | flagset.ObjectType: true, 85 | }), 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /internal/generators/golang/golang.tmpl: -------------------------------------------------------------------------------- 1 | // AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. 2 | package {{ .Params.Custom.GoPackage }} 3 | 4 | import ( 5 | {{- range $_, $p := SupportImports .Flagset.Flags}} 6 | {{$p}} 7 | {{- end}} 8 | ) 9 | 10 | type BooleanProvider func(ctx context.Context, evalCtx openfeature.EvaluationContext) (bool, error) 11 | type BooleanProviderDetails func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.BooleanEvaluationDetails, error) 12 | type FloatProvider func(ctx context.Context, evalCtx openfeature.EvaluationContext) (float64, error) 13 | type FloatProviderDetails func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.FloatEvaluationDetails, error) 14 | type IntProvider func(ctx context.Context, evalCtx openfeature.EvaluationContext) (int64, error) 15 | type IntProviderDetails func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.IntEvaluationDetails, error) 16 | type StringProvider func(ctx context.Context, evalCtx openfeature.EvaluationContext) (string, error) 17 | type StringProviderDetails func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.StringEvaluationDetails, error) 18 | 19 | var client openfeature.IClient = nil 20 | 21 | {{- range .Flagset.Flags }} 22 | // {{.Description}} 23 | var {{ .Key | ToPascal }} = struct { 24 | // Value returns the value of the flag {{ .Key | ToPascal }}, 25 | // as well as the evaluation error, if present. 26 | Value {{ .Type | OpenFeatureType }}Provider 27 | 28 | // ValueWithDetails returns the value of the flag {{ .Key | ToPascal }}, 29 | // the evaluation error, if any, and the evaluation details. 30 | ValueWithDetails {{ .Type | OpenFeatureType }}ProviderDetails 31 | }{ 32 | Value: func(ctx context.Context, evalCtx openfeature.EvaluationContext) ({{ .Type | TypeString }}, error) { 33 | return client.{{ .Type | OpenFeatureType }}Value(ctx, {{ .Key | Quote }}, {{ .DefaultValue | QuoteString }}, evalCtx) 34 | }, 35 | ValueWithDetails: func(ctx context.Context, evalCtx openfeature.EvaluationContext) (openfeature.{{ .Type | OpenFeatureType }}EvaluationDetails, error){ 36 | return client.{{ .Type | OpenFeatureType }}ValueDetails(ctx, {{ .Key | Quote }}, {{ .DefaultValue | QuoteString }}, evalCtx) 37 | }, 38 | } 39 | {{- end}} 40 | 41 | func init() { 42 | client = openfeature.GetApiInstance().GetClient() 43 | } 44 | -------------------------------------------------------------------------------- /internal/generators/java/java.go: -------------------------------------------------------------------------------- 1 | package java 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "text/template" 7 | 8 | "github.com/open-feature/cli/internal/flagset" 9 | "github.com/open-feature/cli/internal/generators" 10 | ) 11 | 12 | type JavaGenerator struct { 13 | generators.CommonGenerator 14 | } 15 | 16 | type Params struct { 17 | // Add Java parameters here if needed 18 | JavaPackage string 19 | } 20 | 21 | //go:embed java.tmpl 22 | var javaTmpl string 23 | 24 | func openFeatureType(t flagset.FlagType) string { 25 | switch t { 26 | case flagset.IntType: 27 | return "Integer" 28 | case flagset.FloatType: 29 | return "Double" //using Double as per openfeature Java-SDK 30 | case flagset.BoolType: 31 | return "Boolean" 32 | case flagset.StringType: 33 | return "String" 34 | default: 35 | return "" 36 | } 37 | } 38 | 39 | func formatDefaultValueForJava(flag flagset.Flag) string { 40 | switch flag.Type { 41 | case flagset.StringType: 42 | return fmt.Sprintf("\"%s\"", flag.DefaultValue) 43 | case flagset.BoolType: 44 | if flag.DefaultValue == true { 45 | return "true" 46 | } 47 | return "false" 48 | default: 49 | return fmt.Sprintf("%v", flag.DefaultValue) 50 | } 51 | } 52 | 53 | func (g *JavaGenerator) Generate(params *generators.Params[Params]) error { 54 | funcs := template.FuncMap{ 55 | "OpenFeatureType": openFeatureType, 56 | "FormatDefaultValue": formatDefaultValueForJava, 57 | } 58 | 59 | newParams := &generators.Params[any]{ 60 | OutputPath: params.OutputPath, 61 | Custom: params.Custom, 62 | } 63 | 64 | return g.GenerateFile(funcs, javaTmpl, newParams, "OpenFeature.java") 65 | } 66 | 67 | // NewGenerator creates a generator for Java. 68 | func NewGenerator(fs *flagset.Flagset) *JavaGenerator { 69 | return &JavaGenerator{ 70 | CommonGenerator: *generators.NewGenerator(fs, map[flagset.FlagType]bool{ 71 | flagset.ObjectType: true, 72 | }), 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /internal/generators/java/java.tmpl: -------------------------------------------------------------------------------- 1 | // AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. 2 | package {{ .Params.Custom.JavaPackage }}; 3 | 4 | import dev.openfeature.sdk.Client; 5 | import dev.openfeature.sdk.EvaluationContext; 6 | import dev.openfeature.sdk.FlagEvaluationDetails; 7 | import dev.openfeature.sdk.OpenFeatureAPI; 8 | 9 | public final class OpenFeature { 10 | 11 | private OpenFeature() {} // prevent instantiation 12 | 13 | public interface GeneratedClient { 14 | {{ range .Flagset.Flags }} 15 | /** 16 | * {{ .Description }} 17 | * Details: 18 | * - Flag key: {{ .Key }} 19 | * - Type: {{ .Type | OpenFeatureType }} 20 | * - Default value: {{ .DefaultValue }} 21 | * Returns the flag value 22 | */ 23 | {{ .Type | OpenFeatureType }} {{ .Key | ToCamel }}(EvaluationContext ctx); 24 | 25 | /** 26 | * {{ .Description }} 27 | * Details: 28 | * - Flag key: {{ .Key }} 29 | * - Type: {{ .Type | OpenFeatureType }} 30 | * - Default value: {{ .DefaultValue }} 31 | * Returns the evaluation details containing the flag value and metadata 32 | */ 33 | FlagEvaluationDetails<{{ .Type | OpenFeatureType }}> {{ .Key | ToCamel }}Details(EvaluationContext ctx); 34 | {{ end }} 35 | } 36 | 37 | private static final class OpenFeatureGeneratedClient implements GeneratedClient { 38 | private final Client client; 39 | 40 | private OpenFeatureGeneratedClient(Client client) { 41 | this.client = client; 42 | } 43 | 44 | {{ range .Flagset.Flags }} 45 | @Override 46 | public {{ .Type | OpenFeatureType }} {{ .Key | ToCamel }}(EvaluationContext ctx) { 47 | return client.get{{ .Type | OpenFeatureType | ToPascal }}Value("{{ .Key }}", {{ . | FormatDefaultValue }}, ctx); 48 | } 49 | 50 | @Override 51 | public FlagEvaluationDetails<{{ .Type | OpenFeatureType }}> {{ .Key | ToCamel }}Details(EvaluationContext ctx) { 52 | return client.get{{ .Type | OpenFeatureType | ToPascal }}Details("{{ .Key }}", {{ . | FormatDefaultValue }}, ctx); 53 | } 54 | {{ end }} 55 | } 56 | 57 | public static GeneratedClient getClient() { 58 | return new OpenFeatureGeneratedClient(OpenFeatureAPI.getInstance().getClient()); 59 | } 60 | 61 | public static GeneratedClient getClient(String domain) { 62 | return new OpenFeatureGeneratedClient(OpenFeatureAPI.getInstance().getClient(domain)); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /internal/generators/manager.go: -------------------------------------------------------------------------------- 1 | package generators 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/pterm/pterm" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // GeneratorCreator is a function that creates a generator command 11 | type GeneratorCreator func() *cobra.Command 12 | 13 | // GeneratorInfo contains metadata about a generator 14 | type GeneratorInfo struct { 15 | Name string 16 | Description string 17 | Stability Stability 18 | Creator GeneratorCreator 19 | } 20 | 21 | // GeneratorManager maintains a registry of available generators 22 | type GeneratorManager struct { 23 | generators map[string]GeneratorInfo 24 | } 25 | 26 | // NewGeneratorManager creates a new generator manager 27 | func NewGeneratorManager() *GeneratorManager { 28 | return &GeneratorManager{ 29 | generators: make(map[string]GeneratorInfo), 30 | } 31 | } 32 | 33 | // Register adds a generator to the registry 34 | func (m *GeneratorManager) Register(cmdCreator func() *cobra.Command) { 35 | cmd := cmdCreator() 36 | m.generators[cmd.Use] = GeneratorInfo{ 37 | Name: cmd.Use, 38 | Description: cmd.Short, 39 | Stability: Stability(cmd.Annotations["stability"]), 40 | Creator: cmdCreator, 41 | } 42 | } 43 | 44 | // GetAll returns all registered generators 45 | func (m *GeneratorManager) GetAll() map[string]GeneratorInfo { 46 | return m.generators 47 | } 48 | 49 | // GetCommands returns cobra commands for all registered generators 50 | func (m *GeneratorManager) GetCommands() []*cobra.Command { 51 | var commands []*cobra.Command 52 | 53 | for _, info := range m.generators { 54 | commands = append(commands, info.Creator()) 55 | } 56 | 57 | return commands 58 | } 59 | 60 | // PrintGeneratorsTable prints a table of all available generators with their stability 61 | func (m *GeneratorManager) PrintGeneratorsTable() error { 62 | tableData := [][]string{ 63 | {"Generator", "Description", "Stability"}, 64 | } 65 | 66 | // Get generator names for consistent ordering 67 | var names []string 68 | for name := range m.generators { 69 | names = append(names, name) 70 | } 71 | sort.Strings(names) 72 | 73 | for _, name := range names { 74 | info := m.generators[name] 75 | tableData = append(tableData, []string{ 76 | name, 77 | info.Description, 78 | string(info.Stability), 79 | }) 80 | } 81 | 82 | return pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() 83 | } 84 | 85 | // DefaultManager is the default instance of the generator manager 86 | var DefaultManager = NewGeneratorManager() 87 | -------------------------------------------------------------------------------- /internal/generators/nestjs/nestjs.go: -------------------------------------------------------------------------------- 1 | package nestjs 2 | 3 | import ( 4 | _ "embed" 5 | "text/template" 6 | 7 | "github.com/open-feature/cli/internal/flagset" 8 | "github.com/open-feature/cli/internal/generators" 9 | ) 10 | 11 | type NestJsGenerator struct { 12 | generators.CommonGenerator 13 | } 14 | 15 | type Params struct { 16 | } 17 | 18 | //go:embed nestjs.tmpl 19 | var nestJsTmpl string 20 | 21 | func openFeatureType(t flagset.FlagType) string { 22 | switch t { 23 | case flagset.IntType: 24 | fallthrough 25 | case flagset.FloatType: 26 | return "number" 27 | case flagset.BoolType: 28 | return "boolean" 29 | case flagset.StringType: 30 | return "string" 31 | default: 32 | return "" 33 | } 34 | } 35 | 36 | func (g *NestJsGenerator) Generate(params *generators.Params[Params]) error { 37 | funcs := template.FuncMap{ 38 | "OpenFeatureType": openFeatureType, 39 | } 40 | 41 | newParams := &generators.Params[any]{ 42 | OutputPath: params.OutputPath, 43 | Custom: Params{}, 44 | } 45 | 46 | return g.GenerateFile(funcs, nestJsTmpl, newParams, "openfeature-decorators.ts") 47 | } 48 | 49 | // NewGenerator creates a generator for NestJS. 50 | func NewGenerator(fs *flagset.Flagset) *NestJsGenerator { 51 | return &NestJsGenerator{ 52 | CommonGenerator: *generators.NewGenerator(fs, map[flagset.FlagType]bool{ 53 | flagset.ObjectType: true, 54 | }), 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /internal/generators/nestjs/nestjs.tmpl: -------------------------------------------------------------------------------- 1 | import type { DynamicModule, FactoryProvider as NestFactoryProvider } from "@nestjs/common"; 2 | import { Inject, Module } from "@nestjs/common"; 3 | import type { Observable } from "rxjs"; 4 | 5 | import type { 6 | OpenFeature, 7 | Client, 8 | EvaluationContext, 9 | EvaluationDetails, 10 | OpenFeatureModuleOptions, 11 | } from "@openfeature/nestjs-sdk"; 12 | import { OpenFeatureModule, BooleanFeatureFlag, StringFeatureFlag, NumberFeatureFlag } from "@openfeature/nestjs-sdk"; 13 | 14 | import type { GeneratedClient } from "./openfeature"; 15 | import { getGeneratedClient } from "./openfeature"; 16 | 17 | /** 18 | * Returns an injection token for a (domain scoped) generated OpenFeature client. 19 | * @param {string} domain The domain of the generated OpenFeature client. 20 | * @returns {string} The injection token. 21 | */ 22 | export function getOpenFeatureGeneratedClientToken(domain?: string): string { 23 | return domain ? `OpenFeatureGeneratedClient_${domain}` : "OpenFeatureGeneratedClient_default"; 24 | } 25 | 26 | /** 27 | * Options for injecting an OpenFeature client into a constructor. 28 | */ 29 | interface FeatureClientProps { 30 | /** 31 | * The domain of the OpenFeature client, if a domain scoped client should be used. 32 | * @see {@link Client.getBooleanDetails} 33 | */ 34 | domain?: string; 35 | } 36 | 37 | /** 38 | * Injects a generated typesafe feature client into a constructor or property of a class. 39 | * @param {FeatureClientProps} [props] The options for injecting the client. 40 | * @returns {PropertyDecorator & ParameterDecorator} The decorator function. 41 | */ 42 | export const GeneratedOpenFeatureClient = (props?: FeatureClientProps): PropertyDecorator & ParameterDecorator => 43 | Inject(getOpenFeatureGeneratedClientToken(props?.domain)); 44 | 45 | /** 46 | * GeneratedOpenFeatureModule is a generated typesafe NestJS wrapper for OpenFeature Server-SDK. 47 | */ 48 | @Module({}) 49 | export class GeneratedOpenFeatureModule extends OpenFeatureModule { 50 | static override forRoot({ useGlobalInterceptor = true, ...options }: OpenFeatureModuleOptions): DynamicModule { 51 | const module = super.forRoot({ useGlobalInterceptor, ...options }); 52 | 53 | const clientValueProviders: NestFactoryProvider[] = [ 54 | { 55 | provide: getOpenFeatureGeneratedClientToken(), 56 | useFactory: () => getGeneratedClient(), 57 | }, 58 | ]; 59 | 60 | if (options?.providers) { 61 | const domainClientProviders: NestFactoryProvider[] = Object.keys(options.providers).map( 62 | (domain) => ({ 63 | provide: getOpenFeatureGeneratedClientToken(domain), 64 | useFactory: () => getGeneratedClient(domain), 65 | }), 66 | ); 67 | 68 | clientValueProviders.push(...domainClientProviders); 69 | } 70 | 71 | return { 72 | ...module, 73 | providers: module.providers ? [...module.providers, ...clientValueProviders] : clientValueProviders, 74 | exports: module.exports ? [...module.exports, ...clientValueProviders] : clientValueProviders, 75 | }; 76 | } 77 | } 78 | 79 | /** 80 | * Options for injecting a typed feature flag into a route handler. 81 | */ 82 | interface TypedFeatureProps { 83 | /** 84 | * The domain of the OpenFeature client, if a domain scoped client should be used. 85 | * @see {@link OpenFeature#getClient} 86 | */ 87 | domain?: string; 88 | /** 89 | * The {@link EvaluationContext} for evaluating the feature flag. 90 | * @see {@link OpenFeature#getClient} 91 | */ 92 | context?: EvaluationContext; 93 | } 94 | 95 | {{ range .Flagset.Flags }} 96 | /** 97 | * Gets the {@link EvaluationDetails} for `{{ .Key }}` from a domain scoped or the default OpenFeature 98 | * client and populates the annotated parameter with the {@link EvaluationDetails} wrapped in an {@link Observable}. 99 | * 100 | * **Details:** 101 | * - flag key: `{{ .Key }}` 102 | * - description: `{{ .Description }}` 103 | * - default value: `{{ .DefaultValue }}` 104 | * - type: `{{ .Type | OpenFeatureType }}` 105 | * 106 | * Usage: 107 | * ```typescript 108 | * @Get("/") 109 | * public async handleRequest( 110 | * @{{ .Key | ToPascal }}() 111 | * {{ .Key | ToCamel }}: Observable>, 112 | * ) 113 | * ``` 114 | * @param {TypedFeatureProps} props The options for injecting the feature flag. 115 | * @returns {ParameterDecorator} The decorator function. 116 | */ 117 | export function {{ .Key | ToPascal }}(props?: TypedFeatureProps): ParameterDecorator { 118 | return {{ .Type | OpenFeatureType | ToPascal }}FeatureFlag({ flagKey: {{ .Key | Quote }}, defaultValue: {{ .DefaultValue | QuoteString }}, ...props }); 119 | } 120 | {{ end -}} 121 | -------------------------------------------------------------------------------- /internal/generators/nodejs/nodejs.go: -------------------------------------------------------------------------------- 1 | package nodejs 2 | 3 | import ( 4 | _ "embed" 5 | "text/template" 6 | 7 | "github.com/open-feature/cli/internal/flagset" 8 | "github.com/open-feature/cli/internal/generators" 9 | ) 10 | 11 | type NodejsGenerator struct { 12 | generators.CommonGenerator 13 | } 14 | 15 | type Params struct { 16 | } 17 | 18 | //go:embed nodejs.tmpl 19 | var nodejsTmpl string 20 | 21 | func openFeatureType(t flagset.FlagType) string { 22 | switch t { 23 | case flagset.IntType: 24 | fallthrough 25 | case flagset.FloatType: 26 | return "number" 27 | case flagset.BoolType: 28 | return "boolean" 29 | case flagset.StringType: 30 | return "string" 31 | default: 32 | return "" 33 | } 34 | } 35 | 36 | func (g *NodejsGenerator) Generate(params *generators.Params[Params]) error { 37 | funcs := template.FuncMap{ 38 | "OpenFeatureType": openFeatureType, 39 | } 40 | 41 | newParams := &generators.Params[any]{ 42 | OutputPath: params.OutputPath, 43 | Custom: Params{}, 44 | } 45 | 46 | return g.GenerateFile(funcs, nodejsTmpl, newParams, "openfeature.ts") 47 | } 48 | 49 | // NewGenerator creates a generator for NodeJS. 50 | func NewGenerator(fs *flagset.Flagset) *NodejsGenerator { 51 | return &NodejsGenerator{ 52 | CommonGenerator: *generators.NewGenerator(fs, map[flagset.FlagType]bool{ 53 | flagset.ObjectType: true, 54 | }), 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /internal/generators/nodejs/nodejs.tmpl: -------------------------------------------------------------------------------- 1 | // AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. 2 | import { 3 | OpenFeature, 4 | stringOrUndefined, 5 | objectOrUndefined, 6 | } from "@openfeature/server-sdk"; 7 | import type { 8 | EvaluationContext, 9 | EvaluationDetails, 10 | FlagEvaluationOptions, 11 | } from "@openfeature/server-sdk"; 12 | 13 | export interface GeneratedClient { 14 | {{- range .Flagset.Flags }} 15 | /** 16 | * {{ .Description }} 17 | * 18 | * **Details:** 19 | * - flag key: `{{ .Key }}` 20 | * - default value: `{{ .DefaultValue }}` 21 | * - type: `{{ .Type | OpenFeatureType }}` 22 | * 23 | * Performs a flag evaluation that returns a {{ .Type | OpenFeatureType }}. 24 | * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation 25 | * @param {FlagEvaluationOptions} options Additional flag evaluation options 26 | * @returns {Promise<{{ .Type | OpenFeatureType }}>} Flag evaluation response 27 | */ 28 | {{ .Key | ToCamel }}(context?: EvaluationContext, options?: FlagEvaluationOptions): Promise<{{ .Type | OpenFeatureType }}>; 29 | 30 | /** 31 | * {{ .Description }} 32 | * 33 | * **Details:** 34 | * - flag key: `{{ .Key }}` 35 | * - default value: `{{ .DefaultValue }}` 36 | * - type: `{{ .Type | OpenFeatureType }}` 37 | * 38 | * Performs a flag evaluation that a returns an evaluation details object. 39 | * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation 40 | * @param {FlagEvaluationOptions} options Additional flag evaluation options 41 | * @returns {Promise>} Flag evaluation details response 42 | */ 43 | {{ .Key | ToCamel }}Details(context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; 44 | {{ end -}} 45 | } 46 | 47 | /** 48 | * A factory function that returns a generated client that not bound to a domain. 49 | * It was generated using the OpenFeature CLI and is compatible with `@openfeature/server-sdk`. 50 | * 51 | * All domainless or unbound clients use the default provider set via {@link OpenFeature.setProvider}. 52 | * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations 53 | * @returns {GeneratedClient} Generated OpenFeature Client 54 | */ 55 | export function getGeneratedClient(context?: EvaluationContext): GeneratedClient 56 | /** 57 | * A factory function that returns a domain-bound generated client that was 58 | * created using the OpenFeature CLI and is compatible with the `@openfeature/server-sdk`. 59 | * 60 | * If there is already a provider bound to this domain via {@link OpenFeature.setProvider}, this provider will be used. 61 | * Otherwise, the default provider is used until a provider is assigned to that domain. 62 | * @param {string} domain An identifier which logically binds clients with providers 63 | * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations 64 | * @returns {GeneratedClient} Generated OpenFeature Client 65 | */ 66 | export function getGeneratedClient(domain: string, context?: EvaluationContext): GeneratedClient 67 | export function getGeneratedClient(domainOrContext?: string | EvaluationContext, contextOrUndefined?: EvaluationContext): GeneratedClient { 68 | const domain = stringOrUndefined(domainOrContext); 69 | const context = 70 | objectOrUndefined(domainOrContext) ?? 71 | objectOrUndefined(contextOrUndefined); 72 | 73 | const client = domain ? OpenFeature.getClient(domain, context) : OpenFeature.getClient(context) 74 | 75 | return { 76 | {{- range .Flagset.Flags }} 77 | {{ .Key | ToCamel }}: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise<{{ .Type | OpenFeatureType }}> => { 78 | return client.get{{ .Type | OpenFeatureType | ToPascal }}Value({{ .Key | Quote }}, {{ .DefaultValue | QuoteString }}, context, options); 79 | }, 80 | 81 | {{ .Key | ToCamel }}Details: (context?: EvaluationContext, options?: FlagEvaluationOptions): Promise> => { 82 | return client.get{{ .Type | OpenFeatureType | ToPascal }}Details({{ .Key | Quote }}, {{ .DefaultValue | QuoteString }}, context, options); 83 | }, 84 | {{ end -}} 85 | {{ printf " " }}} 86 | } -------------------------------------------------------------------------------- /internal/generators/python/python.go: -------------------------------------------------------------------------------- 1 | package python 2 | 3 | import ( 4 | _ "embed" 5 | "text/template" 6 | 7 | "github.com/open-feature/cli/internal/flagset" 8 | "github.com/open-feature/cli/internal/generators" 9 | ) 10 | 11 | type PythonGenerator struct { 12 | generators.CommonGenerator 13 | } 14 | 15 | type Params struct { 16 | } 17 | 18 | //go:embed python.tmpl 19 | var pythonTmpl string 20 | 21 | func openFeatureType(t flagset.FlagType) string { 22 | switch t { 23 | case flagset.IntType: 24 | return "int" 25 | case flagset.FloatType: 26 | return "float" 27 | case flagset.BoolType: 28 | return "bool" 29 | case flagset.StringType: 30 | return "str" 31 | default: 32 | return "object" 33 | } 34 | } 35 | 36 | func methodType(flagType flagset.FlagType) string { 37 | switch flagType { 38 | case flagset.StringType: 39 | return "string" 40 | case flagset.IntType: 41 | return "integer" 42 | case flagset.BoolType: 43 | return "boolean" 44 | case flagset.FloatType: 45 | return "float" 46 | default: 47 | panic("unsupported flag type") 48 | } 49 | } 50 | 51 | func typedGetMethodSync(flagType flagset.FlagType) string { 52 | return "get_" + methodType(flagType) + "_value" 53 | } 54 | 55 | func typedGetMethodAsync(flagType flagset.FlagType) string { 56 | return "get_" + methodType(flagType) + "_value_async" 57 | } 58 | 59 | func typedDetailsMethodSync(flagType flagset.FlagType) string { 60 | return "get_" + methodType(flagType) + "_details" 61 | } 62 | 63 | func typedDetailsMethodAsync(flagType flagset.FlagType) string { 64 | return "get_" + methodType(flagType) + "_details_async" 65 | } 66 | 67 | func pythonBoolLiteral(value interface{}) interface{} { 68 | if v, ok := value.(bool); ok { 69 | if v { 70 | return "True" 71 | } 72 | return "False" 73 | } 74 | return value 75 | } 76 | 77 | func (g *PythonGenerator) Generate(params *generators.Params[Params]) error { 78 | funcs := template.FuncMap{ 79 | "OpenFeatureType": openFeatureType, 80 | "TypedGetMethodSync": typedGetMethodSync, 81 | "TypedGetMethodAsync": typedGetMethodAsync, 82 | "TypedDetailsMethodSync": typedDetailsMethodSync, 83 | "TypedDetailsMethodAsync": typedDetailsMethodAsync, 84 | "PythonBoolLiteral": pythonBoolLiteral, 85 | } 86 | 87 | newParams := &generators.Params[any]{ 88 | OutputPath: params.OutputPath, 89 | Custom: Params{}, 90 | } 91 | 92 | return g.GenerateFile(funcs, pythonTmpl, newParams, "openfeature.py") 93 | } 94 | 95 | // NewGenerator creates a generator for Python. 96 | func NewGenerator(fs *flagset.Flagset) *PythonGenerator { 97 | return &PythonGenerator{ 98 | CommonGenerator: *generators.NewGenerator(fs, map[flagset.FlagType]bool{ 99 | flagset.ObjectType: true, 100 | }), 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /internal/generators/python/python.tmpl: -------------------------------------------------------------------------------- 1 | # AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. 2 | from typing import Optional 3 | 4 | from openfeature.client import OpenFeatureClient 5 | from openfeature.evaluation_context import EvaluationContext 6 | from openfeature.flag_evaluation import FlagEvaluationDetails, FlagEvaluationOptions 7 | from openfeature.hook import Hook 8 | 9 | 10 | class GeneratedClient: 11 | def __init__( 12 | self, 13 | client: OpenFeatureClient, 14 | ) -> None: 15 | self.client = client 16 | {{ printf "" }} 17 | {{- range .Flagset.Flags }} 18 | def {{ .Key | ToSnake }}( 19 | self, 20 | evaluation_context: Optional[EvaluationContext] = None, 21 | flag_evaluation_options: Optional[FlagEvaluationOptions] = None, 22 | ) -> {{ .Type | OpenFeatureType }}: 23 | """ 24 | {{ .Description }} 25 | 26 | **Details:** 27 | - flag key: `{{ .Key }}` 28 | - default value: `{{ .DefaultValue | PythonBoolLiteral }}` 29 | - type: `{{ .Type | OpenFeatureType }}` 30 | 31 | Performs a flag evaluation that returns a `{{ .Type | OpenFeatureType }}`. 32 | """ 33 | return self.client.{{ .Type | TypedGetMethodSync }}( 34 | flag_key={{ .Key | Quote }}, 35 | default_value={{ .DefaultValue | QuoteString | PythonBoolLiteral }}, 36 | evaluation_context=evaluation_context, 37 | flag_evaluation_options=flag_evaluation_options, 38 | ) 39 | 40 | def {{ .Key | ToSnake }}_details( 41 | self, 42 | evaluation_context: Optional[EvaluationContext] = None, 43 | flag_evaluation_options: Optional[FlagEvaluationOptions] = None, 44 | ) -> FlagEvaluationDetails: 45 | """ 46 | {{ .Description }} 47 | 48 | **Details:** 49 | - flag key: `{{ .Key }}` 50 | - default value: `{{ .DefaultValue | PythonBoolLiteral }}` 51 | - type: `{{ .Type | OpenFeatureType }}` 52 | 53 | Performs a flag evaluation that returns a `FlagEvaluationDetails` instance. 54 | """ 55 | return self.client.{{ .Type | TypedDetailsMethodSync }}( 56 | flag_key={{ .Key | Quote }}, 57 | default_value={{ .DefaultValue | QuoteString | PythonBoolLiteral }}, 58 | evaluation_context=evaluation_context, 59 | flag_evaluation_options=flag_evaluation_options, 60 | ) 61 | 62 | async def {{ .Key | ToSnake }}_async( 63 | self, 64 | evaluation_context: Optional[EvaluationContext] = None, 65 | flag_evaluation_options: Optional[FlagEvaluationOptions] = None, 66 | ) -> {{ .Type | OpenFeatureType }}: 67 | """ 68 | {{ .Description }} 69 | 70 | **Details:** 71 | - flag key: `{{ .Key }}` 72 | - default value: `{{ .DefaultValue | PythonBoolLiteral }}` 73 | - type: `{{ .Type | OpenFeatureType }}` 74 | 75 | Performs a flag evaluation asynchronously and returns a `{{ .Type | OpenFeatureType }}`. 76 | """ 77 | return await self.client.{{ .Type | TypedGetMethodAsync }}( 78 | flag_key={{ .Key | Quote }}, 79 | default_value={{ .DefaultValue | QuoteString | PythonBoolLiteral }}, 80 | evaluation_context=evaluation_context, 81 | flag_evaluation_options=flag_evaluation_options, 82 | ) 83 | 84 | async def {{ .Key | ToSnake }}_details_async( 85 | self, 86 | evaluation_context: Optional[EvaluationContext] = None, 87 | flag_evaluation_options: Optional[FlagEvaluationOptions] = None, 88 | ) -> FlagEvaluationDetails: 89 | """ 90 | {{ .Description }} 91 | 92 | **Details:** 93 | - flag key: `{{ .Key }}` 94 | - default value: `{{ .DefaultValue | PythonBoolLiteral }}` 95 | - type: `{{ .Type | OpenFeatureType }}` 96 | 97 | Performs a flag evaluation asynchronously and returns a `FlagEvaluationDetails` instance. 98 | """ 99 | return await self.client.{{ .Type | TypedDetailsMethodAsync }}( 100 | flag_key={{ .Key | Quote }}, 101 | default_value={{ .DefaultValue | QuoteString | PythonBoolLiteral }}, 102 | evaluation_context=evaluation_context, 103 | flag_evaluation_options=flag_evaluation_options, 104 | ) 105 | {{ end -}} 106 | {{ printf "\n" }} 107 | def get_generated_client( 108 | client: Optional[OpenFeatureClient] = None, 109 | domain: Optional[str] = None, 110 | version: Optional[str] = None, 111 | context: Optional[EvaluationContext] = None, 112 | hooks: Optional[list[Hook]] = None, 113 | ) -> GeneratedClient: 114 | if not client: 115 | client = OpenFeatureClient( 116 | domain=domain, 117 | version=version, 118 | context=context, 119 | hooks=hooks, 120 | ) 121 | return GeneratedClient(client) 122 | -------------------------------------------------------------------------------- /internal/generators/react/react.go: -------------------------------------------------------------------------------- 1 | package react 2 | 3 | import ( 4 | _ "embed" 5 | "text/template" 6 | 7 | "github.com/open-feature/cli/internal/flagset" 8 | "github.com/open-feature/cli/internal/generators" 9 | ) 10 | 11 | type ReactGenerator struct { 12 | generators.CommonGenerator 13 | } 14 | 15 | type Params struct { 16 | } 17 | 18 | //go:embed react.tmpl 19 | var reactTmpl string 20 | 21 | func openFeatureType(t flagset.FlagType) string { 22 | switch t { 23 | case flagset.IntType: 24 | fallthrough 25 | case flagset.FloatType: 26 | return "number" 27 | case flagset.BoolType: 28 | return "boolean" 29 | case flagset.StringType: 30 | return "string" 31 | default: 32 | return "" 33 | } 34 | } 35 | 36 | func (g *ReactGenerator) Generate(params *generators.Params[Params]) error { 37 | funcs := template.FuncMap{ 38 | "OpenFeatureType": openFeatureType, 39 | } 40 | 41 | newParams := &generators.Params[any]{ 42 | OutputPath: params.OutputPath, 43 | Custom: Params{}, 44 | } 45 | 46 | return g.GenerateFile(funcs, reactTmpl, newParams, "openfeature.ts") 47 | } 48 | 49 | // NewGenerator creates a generator for React. 50 | func NewGenerator(fs *flagset.Flagset) *ReactGenerator { 51 | return &ReactGenerator{ 52 | CommonGenerator: *generators.NewGenerator(fs, map[flagset.FlagType]bool{ 53 | flagset.ObjectType: true, 54 | }), 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /internal/generators/react/react.tmpl: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | type ReactFlagEvaluationOptions, 5 | type ReactFlagEvaluationNoSuspenseOptions, 6 | useFlag, 7 | useSuspenseFlag, 8 | } from "@openfeature/react-sdk"; 9 | {{ range .Flagset.Flags }} 10 | /** 11 | * {{ .Description }} 12 | * 13 | * **Details:** 14 | * - flag key: `{{ .Key }}` 15 | * - default value: `{{ .DefaultValue }}` 16 | * - type: `{{ .Type | OpenFeatureType }}` 17 | */ 18 | export const use{{ .Key | ToPascal }} = (options?: ReactFlagEvaluationOptions) => { 19 | return useFlag({{ .Key | Quote }}, {{ .DefaultValue | QuoteString }}, options); 20 | }; 21 | 22 | /** 23 | * {{ .Description }} 24 | * 25 | * **Details:** 26 | * - flag key: `{{ .Key }}` 27 | * - default value: `{{ .DefaultValue }}` 28 | * - type: `{{ .Type | OpenFeatureType }}` 29 | * 30 | * Equivalent to useFlag with options: `{ suspend: true }` 31 | * @experimental — Suspense is an experimental feature subject to change in future versions. 32 | */ 33 | export const useSuspense{{ .Key | ToPascal }} = (options?: ReactFlagEvaluationNoSuspenseOptions) => { 34 | return useSuspenseFlag({{ .Key | Quote }}, {{ .DefaultValue | QuoteString }}, options); 35 | }; 36 | {{ end}} -------------------------------------------------------------------------------- /internal/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/pterm/pterm" 7 | ) 8 | 9 | // Logger provides methods for logging different types of messages 10 | type Logger interface { 11 | // Println logs a message without logging level 12 | Println(message string) 13 | // Info logs general information 14 | Info(message string) 15 | // Success logs successful operations 16 | Success(message string) 17 | // Warning logs warnings 18 | Warning(message string) 19 | // Error logs errors 20 | Error(message string) 21 | // Debug logs debug information (only when debug mode is enabled) 22 | Debug(message string) 23 | // SetDebug enables or disables debug mode 24 | SetDebug(enabled bool) 25 | // IsDebugEnabled returns whether debug mode is enabled 26 | IsDebugEnabled() bool 27 | // FileCreated logs a file creation event 28 | FileCreated(path string) 29 | // FileFailed logs a file creation failure 30 | FileFailed(path string, err error) 31 | // GenerationStarted logs the start of a generation process 32 | GenerationStarted(generatorType string) 33 | // GenerationComplete logs the completion of a generation process 34 | GenerationComplete(generatorType string) 35 | } 36 | 37 | // DefaultLogger is the default implementation of Logger 38 | type DefaultLogger struct { 39 | debugEnabled bool 40 | } 41 | 42 | // New creates a new DefaultLogger 43 | func New() *DefaultLogger { 44 | return &DefaultLogger{ 45 | debugEnabled: false, 46 | } 47 | } 48 | 49 | // SetDebug enables or disables debug mode 50 | func (l *DefaultLogger) SetDebug(enabled bool) { 51 | l.debugEnabled = enabled 52 | if enabled { 53 | pterm.EnableDebugMessages() 54 | } 55 | } 56 | 57 | // IsDebugEnabled returns whether debug mode is enabled 58 | func (l *DefaultLogger) IsDebugEnabled() bool { 59 | return l.debugEnabled 60 | } 61 | 62 | // Println logs a message without logging level 63 | func (l *DefaultLogger) Println(message string) { 64 | pterm.Println(message) 65 | } 66 | 67 | // Info logs general information 68 | func (l *DefaultLogger) Info(message string) { 69 | pterm.Info.Println(message) 70 | } 71 | 72 | // Success logs successful operations 73 | func (l *DefaultLogger) Success(message string) { 74 | pterm.Success.Println(message) 75 | } 76 | 77 | // Warning logs warnings 78 | func (l *DefaultLogger) Warning(message string) { 79 | pterm.Warning.Println(message) 80 | } 81 | 82 | // Error logs errors 83 | func (l *DefaultLogger) Error(message string) { 84 | pterm.Error.Println(message) 85 | } 86 | 87 | // Debug logs debug information (only when debug mode is enabled) 88 | func (l *DefaultLogger) Debug(message string) { 89 | if l.debugEnabled { 90 | pterm.Debug.Println(message) 91 | } 92 | } 93 | 94 | // FileCreated logs a file creation event 95 | func (l *DefaultLogger) FileCreated(path string) { 96 | prettyPath := pterm.LightWhite(filepath.Clean(path)) 97 | pterm.Success.Printf("Created %s\n", prettyPath) 98 | } 99 | 100 | // FileFailed logs a file creation failure 101 | func (l *DefaultLogger) FileFailed(path string, err error) { 102 | prettyPath := pterm.LightWhite(filepath.Clean(path)) 103 | pterm.Error.Printf("Failed to create %s: %v\n", prettyPath, err) 104 | } 105 | 106 | // GenerationStarted logs the start of a generation process 107 | func (l *DefaultLogger) GenerationStarted(generatorType string) { 108 | pterm.Info.Printf("Generating a typesafe client for %s\n", generatorType) 109 | } 110 | 111 | // GenerationComplete logs the completion of a generation process 112 | func (l *DefaultLogger) GenerationComplete(generatorType string) { 113 | pterm.Success.Printf("Successfully generated client. Happy coding!\n") 114 | } 115 | 116 | // Default is a singleton instance of DefaultLogger 117 | var Default Logger = New() 118 | -------------------------------------------------------------------------------- /internal/manifest/compare.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | ) 7 | 8 | type Change struct { 9 | Type string `json:"type"` 10 | Path string `json:"path"` 11 | OldValue any `json:"oldValue,omitempty"` 12 | NewValue any `json:"newValue,omitempty"` 13 | } 14 | 15 | func Compare(oldManifest, newManifest *Manifest) ([]Change, error) { 16 | var changes []Change 17 | oldFlags := oldManifest.Flags 18 | newFlags := newManifest.Flags 19 | 20 | for key, newFlag := range newFlags { 21 | if oldFlag, exists := oldFlags[key]; exists { 22 | if !reflect.DeepEqual(oldFlag, newFlag) { 23 | changes = append(changes, Change{ 24 | Type: "change", 25 | Path: fmt.Sprintf("flags.%s", key), 26 | OldValue: oldFlag, 27 | NewValue: newFlag, 28 | }) 29 | } 30 | } else { 31 | changes = append(changes, Change{ 32 | Type: "add", 33 | Path: fmt.Sprintf("flags.%s", key), 34 | NewValue: newFlag, 35 | }) 36 | } 37 | } 38 | 39 | for key, oldFlag := range oldFlags { 40 | if _, exists := newFlags[key]; !exists { 41 | changes = append(changes, Change{ 42 | Type: "remove", 43 | Path: fmt.Sprintf("flags.%s", key), 44 | OldValue: oldFlag, 45 | }) 46 | } 47 | } 48 | 49 | return changes, nil 50 | } 51 | -------------------------------------------------------------------------------- /internal/manifest/compare_test.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "reflect" 5 | "sort" 6 | "testing" 7 | ) 8 | 9 | func TestCompareDifferentManifests(t *testing.T) { 10 | oldManifest := &Manifest{ 11 | Flags: map[string]any{ 12 | "flag1": "value1", 13 | "flag2": "value2", 14 | }, 15 | } 16 | 17 | newManifest := &Manifest{ 18 | Flags: map[string]any{ 19 | "flag1": "value1", 20 | "flag2": "newValue2", 21 | "flag3": "value3", 22 | }, 23 | } 24 | 25 | changes, err := Compare(oldManifest, newManifest) 26 | if err != nil { 27 | t.Fatalf("unexpected error: %v", err) 28 | } 29 | 30 | expectedChanges := []Change{ 31 | {Type: "change", Path: "flags.flag2", OldValue: "value2", NewValue: "newValue2"}, 32 | {Type: "add", Path: "flags.flag3", NewValue: "value3"}, 33 | } 34 | 35 | sortChanges(changes) 36 | sortChanges(expectedChanges) 37 | 38 | if !reflect.DeepEqual(changes, expectedChanges) { 39 | t.Errorf("expected %v, got %v", expectedChanges, changes) 40 | } 41 | } 42 | 43 | func TestCompareIdenticalManifests(t *testing.T) { 44 | manifest := &Manifest{ 45 | Flags: map[string]any{ 46 | "flag1": "value1", 47 | "flag2": "value2", 48 | }, 49 | } 50 | 51 | changes, err := Compare(manifest, manifest) 52 | if err != nil { 53 | t.Fatalf("unexpected error: %v", err) 54 | } 55 | 56 | if len(changes) != 0 { 57 | t.Errorf("expected no changes, got %v", changes) 58 | } 59 | } 60 | 61 | func sortChanges(changes []Change) { 62 | sort.Slice(changes, func(i, j int) bool { 63 | return changes[i].Path < changes[j].Path 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /internal/manifest/json-schema.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/invopop/jsonschema" 7 | "github.com/pterm/pterm" 8 | ) 9 | 10 | type BooleanFlag struct { 11 | BaseFlag 12 | // The type of feature flag (e.g., boolean, string, integer, float) 13 | Type string `json:"flagType,omitempty" jsonschema:"enum=boolean"` 14 | // The value returned from an unsuccessful flag evaluation 15 | DefaultValue bool `json:"defaultValue,omitempty"` 16 | } 17 | 18 | type StringFlag struct { 19 | BaseFlag 20 | // The type of feature flag (e.g., boolean, string, integer, float) 21 | Type string `json:"flagType,omitempty" jsonschema:"enum=string"` 22 | // The value returned from an unsuccessful flag evaluation 23 | DefaultValue string `json:"defaultValue,omitempty"` 24 | } 25 | 26 | type IntegerFlag struct { 27 | BaseFlag 28 | // The type of feature flag (e.g., boolean, string, integer, float) 29 | Type string `json:"flagType,omitempty" jsonschema:"enum=integer"` 30 | // The value returned from an unsuccessful flag evaluation 31 | DefaultValue int `json:"defaultValue,omitempty"` 32 | } 33 | 34 | type FloatFlag struct { 35 | BaseFlag 36 | // The type of feature flag (e.g., boolean, string, integer, float) 37 | Type string `json:"flagType,omitempty" jsonschema:"enum=float"` 38 | // The value returned from an unsuccessful flag evaluation 39 | DefaultValue float64 `json:"defaultValue,omitempty"` 40 | } 41 | 42 | type ObjectFlag struct { 43 | BaseFlag 44 | // The type of feature flag (e.g., boolean, string, integer, float) 45 | Type string `json:"flagType,omitempty" jsonschema:"enum=object"` 46 | // The value returned from an unsuccessful flag evaluation 47 | DefaultValue any `json:"defaultValue,omitempty"` 48 | } 49 | 50 | type BaseFlag struct { 51 | // The type of feature flag (e.g., boolean, string, integer, float) 52 | Type string `json:"flagType,omitempty" jsonschema:"required"` 53 | // A concise description of this feature flag's purpose. 54 | Description string `json:"description,omitempty"` 55 | } 56 | 57 | // Feature flag manifest for the OpenFeature CLI 58 | type Manifest struct { 59 | // Collection of feature flag definitions 60 | Flags map[string]any `json:"flags" jsonschema:"title=Flags,required"` 61 | } 62 | 63 | // Converts the Manifest struct to a JSON schema. 64 | func ToJSONSchema() *jsonschema.Schema { 65 | reflector := &jsonschema.Reflector{ 66 | ExpandedStruct: true, 67 | AllowAdditionalProperties: true, 68 | BaseSchemaID: "openfeature-cli", 69 | } 70 | 71 | if err := reflector.AddGoComments("github.com/open-feature/cli", "./internal/manifest"); err != nil { 72 | pterm.Error.Printf("Error extracting comments from types.go: %v\n", err) 73 | } 74 | 75 | schema := reflector.Reflect(Manifest{}) 76 | schema.Version = "http://json-schema.org/draft-07/schema#" 77 | schema.Title = "OpenFeature CLI Manifest" 78 | flags, ok := schema.Properties.Get("flags") 79 | if !ok { 80 | log.Fatal("flags not found") 81 | } 82 | flags.PatternProperties = map[string]*jsonschema.Schema{ 83 | "^.{1,}$": { 84 | Ref: "#/$defs/flag", 85 | }, 86 | } 87 | // We only want flags keys that matches the pattern properties 88 | flags.AdditionalProperties = jsonschema.FalseSchema 89 | 90 | schema.Definitions = jsonschema.Definitions{ 91 | "flag": &jsonschema.Schema{ 92 | OneOf: []*jsonschema.Schema{ 93 | {Ref: "#/$defs/booleanFlag"}, 94 | {Ref: "#/$defs/stringFlag"}, 95 | {Ref: "#/$defs/integerFlag"}, 96 | {Ref: "#/$defs/floatFlag"}, 97 | {Ref: "#/$defs/objectFlag"}, 98 | }, 99 | Required: []string{"flagType", "defaultValue"}, 100 | }, 101 | "booleanFlag": &jsonschema.Schema{ 102 | Type: "object", 103 | Properties: reflector.Reflect(BooleanFlag{}).Properties, 104 | }, 105 | "stringFlag": &jsonschema.Schema{ 106 | Type: "object", 107 | Properties: reflector.Reflect(StringFlag{}).Properties, 108 | }, 109 | "integerFlag": &jsonschema.Schema{ 110 | Type: "object", 111 | Properties: reflector.Reflect(IntegerFlag{}).Properties, 112 | }, 113 | "floatFlag": &jsonschema.Schema{ 114 | Type: "object", 115 | Properties: reflector.Reflect(FloatFlag{}).Properties, 116 | }, 117 | "objectFlag": &jsonschema.Schema{ 118 | Type: "object", 119 | Properties: reflector.Reflect(ObjectFlag{}).Properties, 120 | }, 121 | } 122 | 123 | return schema 124 | } 125 | -------------------------------------------------------------------------------- /internal/manifest/manage.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/open-feature/cli/internal/filesystem" 7 | "github.com/spf13/afero" 8 | ) 9 | 10 | type initManifest struct { 11 | Schema string `json:"$schema,omitempty"` 12 | Manifest 13 | } 14 | 15 | // Create creates a new manifest file at the given path. 16 | func Create(path string) error { 17 | m := &initManifest{ 18 | Schema: "https://raw.githubusercontent.com/open-feature/cli/main/schema/v0/flag-manifest.json", 19 | Manifest: Manifest{ 20 | Flags: map[string]any{}, 21 | }, 22 | } 23 | formattedInitManifest, err := json.MarshalIndent(m, "", " ") 24 | if err != nil { 25 | return err 26 | } 27 | return filesystem.WriteFile(path, formattedInitManifest) 28 | } 29 | 30 | // Load loads a manifest from a JSON file, unmarshals it, and returns a Manifest object. 31 | func Load(path string) (*Manifest, error) { 32 | fs := filesystem.FileSystem() 33 | data, err := afero.ReadFile(fs, path) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | var m Manifest 39 | if err := json.Unmarshal(data, &m); err != nil { 40 | return nil, err 41 | } 42 | 43 | return &m, nil 44 | } 45 | -------------------------------------------------------------------------------- /internal/manifest/output.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | // OutputFormat represents the available output formats for the compare command 4 | type OutputFormat string 5 | 6 | const ( 7 | // OutputFormatTree represents the tree output format (default) 8 | OutputFormatTree OutputFormat = "tree" 9 | // OutputFormatFlat represents the flat output format 10 | OutputFormatFlat OutputFormat = "flat" 11 | // OutputFormatJSON represents the JSON output format 12 | OutputFormatJSON OutputFormat = "json" 13 | // OutputFormatYAML represents the YAML output format 14 | OutputFormatYAML OutputFormat = "yaml" 15 | ) 16 | 17 | // IsValidOutputFormat checks if the given format is a valid output format 18 | func IsValidOutputFormat(format string) bool { 19 | switch OutputFormat(format) { 20 | case OutputFormatTree, OutputFormatFlat, OutputFormatJSON, OutputFormatYAML: 21 | return true 22 | default: 23 | return false 24 | } 25 | } 26 | 27 | // GetValidOutputFormats returns a list of all valid output formats 28 | func GetValidOutputFormats() []string { 29 | return []string{ 30 | string(OutputFormatTree), 31 | string(OutputFormatFlat), 32 | string(OutputFormatJSON), 33 | string(OutputFormatYAML), 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /internal/manifest/validate.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/open-feature/cli/schema/v0" 8 | "github.com/xeipuuv/gojsonschema" 9 | ) 10 | 11 | type ValidationError struct { 12 | Type string `json:"type"` 13 | Path string `json:"path"` 14 | Message string `json:"message"` 15 | } 16 | 17 | func Validate(data []byte) ([]ValidationError, error) { 18 | schemaLoader := gojsonschema.NewStringLoader(schema.SchemaFile) 19 | manifestLoader := gojsonschema.NewBytesLoader(data) 20 | 21 | result, err := gojsonschema.Validate(schemaLoader, manifestLoader) 22 | if err != nil { 23 | return nil, fmt.Errorf("failed to validate manifest: %w", err) 24 | } 25 | 26 | var issues []ValidationError 27 | for _, err := range result.Errors() { 28 | if strings.HasPrefix(err.Field(), "flags") && err.Type() == "number_one_of" { 29 | issues = append(issues, ValidationError{ 30 | Type: err.Type(), 31 | Path: err.Field(), 32 | Message: "flagType must be 'boolean', 'string', 'integer', 'float', or 'object'", 33 | }) 34 | } else { 35 | issues = append(issues, ValidationError{ 36 | Type: err.Type(), 37 | Path: err.Field(), 38 | Message: err.Description(), 39 | }) 40 | } 41 | } 42 | 43 | return issues, nil 44 | } 45 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | # This file configures Lefthook, a Git hooks manager, for the project. 2 | # For detailed instructions on how to contribute and set up Lefthook, 3 | # please refer to the relevant section in the contributing documentation (CONTRIBUTING.md). 4 | pre-commit: 5 | commands: 6 | go-fmt: 7 | run: go fmt ./... 8 | stage_fixed: true 9 | pre-push: 10 | commands: 11 | generate-docs: 12 | run: | 13 | make generate-docs 14 | if ! git diff --quiet; then 15 | echo "Documentation is outdated. Please run 'make generate-docs' and commit the changes." 16 | exit 1 17 | fi 18 | skip: false 19 | tests: 20 | run: make test 21 | skip: false 22 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "signoff": "OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com>", 3 | "packages": { 4 | ".": { 5 | "release-type": "go", 6 | "prerelease": false, 7 | "bump-minor-pre-major": true, 8 | "bump-patch-for-minor-pre-major": true, 9 | "extra-files": ["README.md"], 10 | "changelog-sections": [ 11 | { 12 | "type": "fix", 13 | "section": "🐛 Bug Fixes" 14 | }, 15 | { 16 | "type": "feat", 17 | "section": "✨ New Features" 18 | }, 19 | { 20 | "type": "chore", 21 | "section": "🧹 Chore" 22 | }, 23 | { 24 | "type": "docs", 25 | "section": "📚 Documentation" 26 | }, 27 | { 28 | "type": "perf", 29 | "section": "🚀 Performance" 30 | }, 31 | { 32 | "type": "build", 33 | "hidden": true, 34 | "section": "🛠️ Build" 35 | }, 36 | { 37 | "type": "deps", 38 | "section": "📦 Dependencies" 39 | }, 40 | { 41 | "type": "ci", 42 | "hidden": true, 43 | "section": "🚦 CI" 44 | }, 45 | { 46 | "type": "refactor", 47 | "section": "🔄 Refactoring" 48 | }, 49 | { 50 | "type": "revert", 51 | "section": "🔙 Reverts" 52 | }, 53 | { 54 | "type": "style", 55 | "hidden": true, 56 | "section": "🎨 Styling" 57 | }, 58 | { 59 | "type": "test", 60 | "hidden": true, 61 | "section": "🧪 Tests" 62 | } 63 | ] 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /sample/sample_manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../schema/v0/flag-manifest.json", 3 | "flags": { 4 | "enableFeatureA": { 5 | "flagType": "boolean", 6 | "defaultValue": false, 7 | "description": "Controls whether Feature A is enabled." 8 | }, 9 | "usernameMaxLength": { 10 | "flagType": "integer", 11 | "defaultValue": 50, 12 | "description": "Maximum allowed length for usernames." 13 | }, 14 | "greetingMessage": { 15 | "flagType": "string", 16 | "defaultValue": "Hello there!", 17 | "description": "The message to use for greeting users." 18 | }, 19 | "discountPercentage": { 20 | "flagType": "float", 21 | "defaultValue": 0.15, 22 | "description": "Discount percentage applied to purchases." 23 | }, 24 | "themeCustomization": { 25 | "flagType": "object", 26 | "defaultValue": { 27 | "primaryColor": "#007bff", 28 | "secondaryColor": "#6c757d" 29 | }, 30 | "description": "Allows customization of theme colors." 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /schema/generate-schema.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | "github.com/open-feature/cli/internal/manifest" 10 | ) 11 | 12 | const schemaPath = "schema/v0/flag-manifest.json" 13 | 14 | func main() { 15 | schema := manifest.ToJSONSchema() 16 | data, err := json.MarshalIndent(schema, "", " ") 17 | if err != nil { 18 | log.Fatal(fmt.Errorf("failed to marshal JSON schema: %w", err)) 19 | } 20 | 21 | if err := os.MkdirAll("schema/v0", os.ModePerm); err != nil { 22 | log.Fatal(fmt.Errorf("failed to create directory: %w", err)) 23 | } 24 | 25 | file, err := os.Create(schemaPath) 26 | if err != nil { 27 | log.Fatal(fmt.Errorf("failed to create file: %w", err)) 28 | } 29 | defer file.Close() 30 | 31 | if _, err := file.Write(data); err != nil { 32 | log.Fatal(fmt.Errorf("failed to write JSON schema to file: %w", err)) 33 | } 34 | 35 | fmt.Println("JSON schema generated successfully at " + schemaPath) 36 | } 37 | -------------------------------------------------------------------------------- /schema/v0/flag-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$id": "openfeature-cli/manifest", 4 | "$defs": { 5 | "booleanFlag": { 6 | "properties": { 7 | "flagType": { 8 | "type": "string", 9 | "enum": [ 10 | "boolean" 11 | ], 12 | "description": "The type of feature flag (e.g., boolean, string, integer, float)" 13 | }, 14 | "description": { 15 | "type": "string", 16 | "description": "A concise description of this feature flag's purpose." 17 | }, 18 | "defaultValue": { 19 | "type": "boolean", 20 | "description": "The value returned from an unsuccessful flag evaluation" 21 | } 22 | }, 23 | "type": "object" 24 | }, 25 | "flag": { 26 | "oneOf": [ 27 | { 28 | "$ref": "#/$defs/booleanFlag" 29 | }, 30 | { 31 | "$ref": "#/$defs/stringFlag" 32 | }, 33 | { 34 | "$ref": "#/$defs/integerFlag" 35 | }, 36 | { 37 | "$ref": "#/$defs/floatFlag" 38 | }, 39 | { 40 | "$ref": "#/$defs/objectFlag" 41 | } 42 | ], 43 | "required": [ 44 | "flagType", 45 | "defaultValue" 46 | ] 47 | }, 48 | "floatFlag": { 49 | "properties": { 50 | "flagType": { 51 | "type": "string", 52 | "enum": [ 53 | "float" 54 | ], 55 | "description": "The type of feature flag (e.g., boolean, string, integer, float)" 56 | }, 57 | "description": { 58 | "type": "string", 59 | "description": "A concise description of this feature flag's purpose." 60 | }, 61 | "defaultValue": { 62 | "type": "number", 63 | "description": "The value returned from an unsuccessful flag evaluation" 64 | } 65 | }, 66 | "type": "object" 67 | }, 68 | "integerFlag": { 69 | "properties": { 70 | "flagType": { 71 | "type": "string", 72 | "enum": [ 73 | "integer" 74 | ], 75 | "description": "The type of feature flag (e.g., boolean, string, integer, float)" 76 | }, 77 | "description": { 78 | "type": "string", 79 | "description": "A concise description of this feature flag's purpose." 80 | }, 81 | "defaultValue": { 82 | "type": "integer", 83 | "description": "The value returned from an unsuccessful flag evaluation" 84 | } 85 | }, 86 | "type": "object" 87 | }, 88 | "objectFlag": { 89 | "properties": { 90 | "flagType": { 91 | "type": "string", 92 | "enum": [ 93 | "object" 94 | ], 95 | "description": "The type of feature flag (e.g., boolean, string, integer, float)" 96 | }, 97 | "description": { 98 | "type": "string", 99 | "description": "A concise description of this feature flag's purpose." 100 | }, 101 | "defaultValue": { 102 | "description": "The value returned from an unsuccessful flag evaluation" 103 | } 104 | }, 105 | "type": "object" 106 | }, 107 | "stringFlag": { 108 | "properties": { 109 | "flagType": { 110 | "type": "string", 111 | "enum": [ 112 | "string" 113 | ], 114 | "description": "The type of feature flag (e.g., boolean, string, integer, float)" 115 | }, 116 | "description": { 117 | "type": "string", 118 | "description": "A concise description of this feature flag's purpose." 119 | }, 120 | "defaultValue": { 121 | "type": "string", 122 | "description": "The value returned from an unsuccessful flag evaluation" 123 | } 124 | }, 125 | "type": "object" 126 | } 127 | }, 128 | "properties": { 129 | "flags": { 130 | "patternProperties": { 131 | "^.{1,}$": { 132 | "$ref": "#/$defs/flag" 133 | } 134 | }, 135 | "additionalProperties": false, 136 | "type": "object", 137 | "title": "Flags", 138 | "description": "Collection of feature flag definitions" 139 | } 140 | }, 141 | "type": "object", 142 | "required": [ 143 | "flags" 144 | ], 145 | "title": "OpenFeature CLI Manifest", 146 | "description": "Feature flag manifest for the OpenFeature CLI" 147 | } -------------------------------------------------------------------------------- /schema/v0/schema.go: -------------------------------------------------------------------------------- 1 | // Package schema embeds the flag manifest into a code module. 2 | package schema 3 | 4 | import _ "embed" 5 | 6 | // Schema contains the embedded flag manifest schema. 7 | // 8 | //go:embed flag-manifest.json 9 | var SchemaFile string 10 | -------------------------------------------------------------------------------- /schema/v0/schema_test.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/xeipuuv/gojsonschema" 13 | ) 14 | 15 | func TestPositiveFlagManifest(t *testing.T) { 16 | if err := walkPath(true, "./testdata/positive"); err != nil { 17 | t.Error(err) 18 | t.FailNow() 19 | } 20 | } 21 | 22 | func TestNegativeFlagManifest(t *testing.T) { 23 | if err := walkPath(false, "./testdata/negative"); err != nil { 24 | t.Error(err) 25 | t.FailNow() 26 | } 27 | } 28 | 29 | func walkPath(shouldPass bool, root string) error { 30 | return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { 31 | if err != nil { 32 | return err 33 | } 34 | ps := strings.Split(path, ".") 35 | if ps[len(ps)-1] != "json" { 36 | return nil 37 | } 38 | 39 | file, err := os.ReadFile(path) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | var v any 45 | if err := json.Unmarshal([]byte(file), &v); err != nil { 46 | log.Fatal(err) 47 | } 48 | 49 | schemaLoader := gojsonschema.NewStringLoader(SchemaFile) 50 | manifestLoader := gojsonschema.NewGoLoader(v) 51 | result, err := gojsonschema.Validate(schemaLoader, manifestLoader) 52 | if err != nil { 53 | return fmt.Errorf("Error validating json schema: %v", err) 54 | } 55 | 56 | if len(result.Errors()) >= 1 && shouldPass == true { 57 | var errorMessage strings.Builder 58 | 59 | errorMessage.WriteString("file " + path + " should be valid, but had the following issues:\n") 60 | for _, error := range result.Errors() { 61 | errorMessage.WriteString(" - " + error.String() + "\n") 62 | } 63 | return fmt.Errorf("%s", errorMessage.String()) 64 | } 65 | 66 | if len(result.Errors()) == 0 && shouldPass == false { 67 | return fmt.Errorf("file %s should be invalid, but no issues were detected", path) 68 | } 69 | 70 | return nil 71 | }) 72 | } 73 | -------------------------------------------------------------------------------- /schema/v0/testdata/negative/empty-flag-key.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../flag-manifest.json", 3 | "flags": { 4 | "": { 5 | "flagType": "boolean", 6 | "defaultValue": true, 7 | "description": "A flag that tests the invalid character case in flag keys." 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /schema/v0/testdata/negative/missing-flag-type.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../flag-manifest.json", 3 | "flags": { 4 | "booleanFlag": { 5 | "codeDefault": true 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /schema/v0/testdata/positive/min-flag-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../flag-manifest.json", 3 | "flags": { 4 | "booleanFlag": { 5 | "flagType": "boolean", 6 | "defaultValue": true 7 | }, 8 | "stringFlag": { 9 | "flagType": "string", 10 | "defaultValue": "default" 11 | }, 12 | "integerFlag": { 13 | "flagType": "integer", 14 | "defaultValue": 50 15 | }, 16 | "floatFlag": { 17 | "flagType": "float", 18 | "defaultValue": 0.15 19 | }, 20 | "objectFlag": { 21 | "flagType": "object", 22 | "defaultValue": { 23 | "primaryColor": "#007bff", 24 | "secondaryColor": "#6c757d" 25 | } 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | # OpenFeature CLI Integration Testing 2 | 3 | This directory contains integration tests for validating the OpenFeature CLI generators. 4 | 5 | ## Integration Test Structure 6 | 7 | The integration tests use [Dagger](https://dagger.io/) to create reproducible test environments without needing to install dependencies locally. 8 | 9 | Each integration test: 10 | 11 | 1. Builds the CLI from source 12 | 2. Generates code using a sample manifest file 13 | 3. Compiles and tests the generated code in a language-specific container 14 | 4. Reports success or failure 15 | 16 | ## Running Tests 17 | 18 | ### Run all integration tests 19 | 20 | ```bash 21 | make test-integration 22 | ``` 23 | 24 | ### Run a specific integration test 25 | 26 | ```bash 27 | # For C# tests 28 | make test-csharp-dagger 29 | ``` 30 | 31 | ## Adding a New Integration Test 32 | 33 | To add an integration test for a new generator: 34 | 35 | 1. Create a combined implementation and runner file in `test/integration/cmd//run.go` 36 | 2. Update the main runner in `test/integration/cmd/run.go` to execute your new test 37 | 3. Add a Makefile target for running your test individually 38 | 39 | See the step-by-step guide in [new-language.md](new-language.md) for detailed instructions. 40 | 41 | ## How It Works 42 | 43 | The testing framework uses the following components: 44 | 45 | - `test/integration/integration.go`: Defines the `Test` interface and common utilities 46 | - `test/integration/cmd/run.go`: Runner for all integration tests that executes each language-specific test 47 | - `test/integration/cmd//run.go`: Combined implementation and runner for each language 48 | - `test/-integration/`: Contains language-specific test files (code samples, project files) 49 | 50 | Each integration test uses Dagger to: 51 | 52 | 1. Build the CLI in a clean environment 53 | 2. Generate code using a sample manifest 54 | 3. Compile and test the generated code in a language-specific container 55 | 4. Report success or failure 56 | 57 | ## Benefits Over Shell Scripts 58 | 59 | Using Dagger for integration tests provides several advantages: 60 | 61 | 1. **Reproducibility**: Tests run in containerized environments that are identical locally and in CI 62 | 2. **Language Support**: Easy to add new language tests with the same pattern 63 | 3. **Improved Debugging**: Clear separation of build, generate, and test steps 64 | 4. **Parallelization**: Tests can run in parallel when executed in different containers 65 | 5. **No Dependencies**: No need to install language-specific tooling locally 66 | -------------------------------------------------------------------------------- /test/csharp-integration/CompileTest.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net8.0 6 | enable 7 | enable 8 | false 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /test/csharp-integration/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:8.0 2 | 3 | WORKDIR /app 4 | 5 | # Copy necessary files 6 | COPY expected/OpenFeature.cs /app/ 7 | COPY CompileTest.csproj /app/ 8 | COPY Program.cs /app/ 9 | 10 | # Restore dependencies 11 | RUN dotnet restore 12 | 13 | # Build the project 14 | RUN dotnet build 15 | 16 | # The image will be used to validate C# compilation only 17 | ENTRYPOINT ["dotnet", "run"] -------------------------------------------------------------------------------- /test/csharp-integration/OpenFeature.cs: -------------------------------------------------------------------------------- 1 | // AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Threading.Tasks; 5 | using OpenFeature; 6 | using OpenFeature.Model; 7 | 8 | namespace OpenFeature 9 | { 10 | /// 11 | /// Generated OpenFeature client for typesafe flag access 12 | /// 13 | public class GeneratedClient 14 | { 15 | private readonly IFeatureClient _client; 16 | 17 | /// 18 | /// Initializes a new instance of the class. 19 | /// 20 | /// The OpenFeature client to use for flag evaluations. 21 | public GeneratedClient(IFeatureClient client) 22 | { 23 | _client = client ?? throw new ArgumentNullException(nameof(client)); 24 | } 25 | /// 26 | /// Discount percentage applied to purchases. 27 | /// 28 | /// 29 | /// Flag key: discountPercentage 30 | /// Default value: 0.15 31 | /// Type: float 32 | /// 33 | /// Optional context for the flag evaluation 34 | /// The flag value 35 | public async Task DiscountPercentageAsync(EvaluationContext evaluationContext = null) 36 | { 37 | return await _client.GetFloatValueAsync("discountPercentage", 0.15, evaluationContext); 38 | } 39 | 40 | /// 41 | /// Discount percentage applied to purchases. 42 | /// 43 | /// 44 | /// Flag key: discountPercentage 45 | /// Default value: 0.15 46 | /// Type: float 47 | /// 48 | /// Optional context for the flag evaluation 49 | /// The evaluation details containing the flag value and metadata 50 | public async Task> DiscountPercentageDetailsAsync(EvaluationContext evaluationContext = null) 51 | { 52 | return await _client.GetFloatDetailsAsync("discountPercentage", 0.15, evaluationContext); 53 | } 54 | 55 | /// 56 | /// Controls whether Feature A is enabled. 57 | /// 58 | /// 59 | /// Flag key: enableFeatureA 60 | /// Default value: false 61 | /// Type: bool 62 | /// 63 | /// Optional context for the flag evaluation 64 | /// The flag value 65 | public async Task EnableFeatureAAsync(EvaluationContext evaluationContext = null) 66 | { 67 | return await _client.GetBoolValueAsync("enableFeatureA", false, evaluationContext); 68 | } 69 | 70 | /// 71 | /// Controls whether Feature A is enabled. 72 | /// 73 | /// 74 | /// Flag key: enableFeatureA 75 | /// Default value: false 76 | /// Type: bool 77 | /// 78 | /// Optional context for the flag evaluation 79 | /// The evaluation details containing the flag value and metadata 80 | public async Task> EnableFeatureADetailsAsync(EvaluationContext evaluationContext = null) 81 | { 82 | return await _client.GetBoolDetailsAsync("enableFeatureA", false, evaluationContext); 83 | } 84 | 85 | /// 86 | /// The message to use for greeting users. 87 | /// 88 | /// 89 | /// Flag key: greetingMessage 90 | /// Default value: Hello there! 91 | /// Type: string 92 | /// 93 | /// Optional context for the flag evaluation 94 | /// The flag value 95 | public async Task GreetingMessageAsync(EvaluationContext evaluationContext = null) 96 | { 97 | return await _client.GetStringValueAsync("greetingMessage", "Hello there!", evaluationContext); 98 | } 99 | 100 | /// 101 | /// The message to use for greeting users. 102 | /// 103 | /// 104 | /// Flag key: greetingMessage 105 | /// Default value: Hello there! 106 | /// Type: string 107 | /// 108 | /// Optional context for the flag evaluation 109 | /// The evaluation details containing the flag value and metadata 110 | public async Task> GreetingMessageDetailsAsync(EvaluationContext evaluationContext = null) 111 | { 112 | return await _client.GetStringDetailsAsync("greetingMessage", "Hello there!", evaluationContext); 113 | } 114 | 115 | /// 116 | /// Maximum allowed length for usernames. 117 | /// 118 | /// 119 | /// Flag key: usernameMaxLength 120 | /// Default value: 50 121 | /// Type: int 122 | /// 123 | /// Optional context for the flag evaluation 124 | /// The flag value 125 | public async Task UsernameMaxLengthAsync(EvaluationContext evaluationContext = null) 126 | { 127 | return await _client.GetIntValueAsync("usernameMaxLength", 50, evaluationContext); 128 | } 129 | 130 | /// 131 | /// Maximum allowed length for usernames. 132 | /// 133 | /// 134 | /// Flag key: usernameMaxLength 135 | /// Default value: 50 136 | /// Type: int 137 | /// 138 | /// Optional context for the flag evaluation 139 | /// The evaluation details containing the flag value and metadata 140 | public async Task> UsernameMaxLengthDetailsAsync(EvaluationContext evaluationContext = null) 141 | { 142 | return await _client.GetIntDetailsAsync("usernameMaxLength", 50, evaluationContext); 143 | } 144 | 145 | 146 | /// 147 | /// Creates a new GeneratedClient using the default OpenFeature client 148 | /// 149 | /// A new GeneratedClient instance 150 | public static GeneratedClient CreateClient() 151 | { 152 | return new GeneratedClient(Api.GetClient()); 153 | } 154 | 155 | /// 156 | /// Creates a new GeneratedClient using a domain-specific OpenFeature client 157 | /// 158 | /// The domain to get the client for 159 | /// A new GeneratedClient instance 160 | public static GeneratedClient CreateClient(string domain) 161 | { 162 | return new GeneratedClient(Api.GetClient(domain)); 163 | } 164 | 165 | /// 166 | /// Creates a new GeneratedClient using a domain-specific OpenFeature client with context 167 | /// 168 | /// The domain to get the client for 169 | /// Default context to use for evaluations 170 | /// A new GeneratedClient instance 171 | public static GeneratedClient CreateClient(string domain, EvaluationContext evaluationContext) 172 | { 173 | return new GeneratedClient(Api.GetClient(domain, evaluationContext)); 174 | } 175 | } 176 | } -------------------------------------------------------------------------------- /test/csharp-integration/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using OpenFeature; 5 | using OpenFeature.Model; 6 | using TestNamespace; 7 | 8 | // This program just validates that the generated OpenFeature C# client code compiles 9 | // We don't need to run the code since the goal is to test compilation only 10 | namespace CompileTest 11 | { 12 | class Program 13 | { 14 | static void Main(string[] args) 15 | { 16 | Console.WriteLine("Testing compilation of generated OpenFeature client..."); 17 | 18 | // Test DI initialization 19 | var services = new ServiceCollection(); 20 | // Register OpenFeature services manually for the test 21 | services.AddSingleton(_ => Api.Instance); 22 | services.AddSingleton(_ => Api.Instance.GetClient()); 23 | services.AddSingleton(); 24 | var serviceProvider = services.BuildServiceProvider(); 25 | 26 | // Test client retrieval from DI 27 | var client = serviceProvider.GetRequiredService(); 28 | 29 | // Also test the traditional factory method 30 | var clientFromFactory = GeneratedClient.CreateClient(); 31 | 32 | // Success! 33 | Console.WriteLine("Generated C# code compiles successfully!"); 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /test/csharp-integration/README.md: -------------------------------------------------------------------------------- 1 | # C# Integration Testing 2 | 3 | This directory contains integration tests for the C# code generator. 4 | 5 | ## Running the tests 6 | 7 | Run the C# integration tests with Dagger: 8 | 9 | ```bash 10 | make test-csharp-dagger 11 | ``` 12 | 13 | This will: 14 | 1. Build the OpenFeature CLI 15 | 2. Generate C# client code using the sample manifest 16 | 3. Run the C# compilation test in an isolated environment 17 | 4. Report success or failure 18 | 19 | ## What the test does 20 | 21 | The integration test: 22 | 1. Builds the OpenFeature CLI inside a container 23 | 2. Generates C# client code using a sample manifest 24 | 3. Compiles the generated code with a sample program 25 | 4. Runs the compiled program to verify it works correctly 26 | 27 | ## Test Files 28 | 29 | - `CompileTest.csproj`: .NET project file for compilation testing 30 | - `Program.cs`: Test program that uses the generated code 31 | - `expected/`: Directory containing expected output files (used for verification) 32 | 33 | ## Implementation 34 | 35 | The C# integration test uses Dagger to create a reproducible test environment: 36 | 37 | 1. It builds the CLI in a Go container 38 | 2. Generates C# code using the CLI 39 | 3. Tests the generated code in a .NET container 40 | 41 | The implementation is located in `test/integration/cmd/csharp/run.go`. 42 | 43 | For more implementation details, see the main [test/README.md](../README.md) file. -------------------------------------------------------------------------------- /test/integration/cmd/csharp/run.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "dagger.io/dagger" 10 | "github.com/open-feature/cli/test/integration" 11 | ) 12 | 13 | // Test implements the integration test for the C# generator 14 | type Test struct { 15 | // ProjectDir is the absolute path to the root of the project 16 | ProjectDir string 17 | // TestDir is the absolute path to the test directory 18 | TestDir string 19 | } 20 | 21 | // New creates a new Test 22 | func New(projectDir, testDir string) *Test { 23 | return &Test{ 24 | ProjectDir: projectDir, 25 | TestDir: testDir, 26 | } 27 | } 28 | 29 | // Run executes the C# integration test using Dagger 30 | func (t *Test) Run(ctx context.Context, client *dagger.Client) (*dagger.Container, error) { 31 | // Source code container 32 | source := client.Host().Directory(t.ProjectDir) 33 | testFiles := client.Host().Directory(t.TestDir, dagger.HostDirectoryOpts{ 34 | Include: []string{"CompileTest.csproj", "Program.cs"}, 35 | }) 36 | 37 | // Build the CLI 38 | cli := client.Container(). 39 | From("golang:1.24-alpine"). 40 | WithDirectory("/src", source). 41 | WithWorkdir("/src"). 42 | WithExec([]string{"go", "build", "-o", "cli", "./cmd/openfeature"}) 43 | 44 | // Generate C# client 45 | generated := cli.WithExec([]string{ 46 | "./cli", "generate", "csharp", 47 | "--manifest=/src/sample/sample_manifest.json", 48 | "--output=/tmp/generated", 49 | "--namespace=TestNamespace", 50 | }) 51 | 52 | // Get generated files 53 | generatedFiles := generated.Directory("/tmp/generated") 54 | 55 | // Test C# compilation with the generated files 56 | dotnetContainer := client.Container(). 57 | From("mcr.microsoft.com/dotnet/sdk:8.0"). 58 | WithDirectory("/app/generated", generatedFiles). 59 | WithDirectory("/app", testFiles). 60 | WithWorkdir("/app"). 61 | WithExec([]string{"dotnet", "restore"}). 62 | WithExec([]string{"dotnet", "build"}). 63 | WithExec([]string{"dotnet", "run"}) 64 | 65 | return dotnetContainer, nil 66 | } 67 | 68 | // Name returns the name of the integration test 69 | func (t *Test) Name() string { 70 | return "csharp" 71 | } 72 | 73 | func main() { 74 | ctx := context.Background() 75 | 76 | // Get project root 77 | projectDir, err := filepath.Abs(os.Getenv("PWD")) 78 | if err != nil { 79 | fmt.Fprintf(os.Stderr, "Failed to get project dir: %v\n", err) 80 | os.Exit(1) 81 | } 82 | 83 | // Get test directory 84 | testDir, err := filepath.Abs(filepath.Join(projectDir, "test/csharp-integration")) 85 | if err != nil { 86 | fmt.Fprintf(os.Stderr, "Failed to get test dir: %v\n", err) 87 | os.Exit(1) 88 | } 89 | 90 | // Create and run the C# integration test 91 | test := New(projectDir, testDir) 92 | 93 | if err := integration.RunTest(ctx, test); err != nil { 94 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 95 | os.Exit(1) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /test/integration/cmd/run.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | ) 8 | 9 | func main() { 10 | 11 | // Run the language-specific tests 12 | fmt.Println("=== Running all integration tests ===") 13 | 14 | // Run the C# integration test 15 | csharpCmd := exec.Command("go", "run", "github.com/open-feature/cli/test/integration/cmd/csharp") 16 | csharpCmd.Stdout = os.Stdout 17 | csharpCmd.Stderr = os.Stderr 18 | if err := csharpCmd.Run(); err != nil { 19 | fmt.Fprintf(os.Stderr, "Error running C# integration test: %v\n", err) 20 | os.Exit(1) 21 | } 22 | 23 | // Add more tests here as they are available 24 | 25 | fmt.Println("=== All integration tests passed successfully ===") 26 | } 27 | -------------------------------------------------------------------------------- /test/integration/integration.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "dagger.io/dagger" 9 | ) 10 | 11 | // Test defines the interface for all integration tests 12 | type Test interface { 13 | // Run executes the integration test with the given Dagger client 14 | Run(ctx context.Context, client *dagger.Client) (*dagger.Container, error) 15 | // Name returns the name of the integration test 16 | Name() string 17 | } 18 | 19 | // RunTest runs a single integration test 20 | func RunTest(ctx context.Context, test Test) error { 21 | // Initialize Dagger client 22 | client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stdout)) 23 | if err != nil { 24 | return fmt.Errorf("failed to connect to Dagger engine: %w", err) 25 | } 26 | defer client.Close() 27 | 28 | fmt.Printf("=== Running %s integration test ===\n", test.Name()) 29 | 30 | // Run the integration test 31 | container, err := test.Run(ctx, client) 32 | if err != nil { 33 | return fmt.Errorf("failed to run %s integration test: %w", test.Name(), err) 34 | } 35 | 36 | // Execute the pipeline and wait for it to complete 37 | _, err = container.Stdout(ctx) 38 | if err != nil { 39 | return fmt.Errorf("%s integration test failed: %w", test.Name(), err) 40 | } 41 | 42 | fmt.Printf("=== Success: %s integration test passed ===\n", test.Name()) 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /test/new-generator.md: -------------------------------------------------------------------------------- 1 | # Adding a New Generator Integration Test 2 | 3 | This guide explains how to add integration tests for a new generator. 4 | 5 | ## Directory Structure 6 | 7 | The integration testing framework has the following directory structure: 8 | 9 | ``` 10 | test/ 11 | integration/ # Core integration test framework 12 | integration.go # Test interface definition 13 | cmd/ # Command-line runners and implementations 14 | run.go # Runner for all tests 15 | csharp/ # C# specific implementation and runner 16 | run.go 17 | python/ # Python specific implementation and runner (future) 18 | run.go 19 | csharp-integration/ # C# test files 20 | python-integration/ # Python test files (future) 21 | ``` 22 | 23 | ## Step 1: Create a generator-specific implementation and runner 24 | 25 | Create a file at `test/integration/cmd/python/run.go`: 26 | 27 | ```go 28 | package main 29 | 30 | import ( 31 | "context" 32 | "fmt" 33 | "os" 34 | "path/filepath" 35 | 36 | "dagger.io/dagger" 37 | "github.com/open-feature/cli/test/integration" 38 | ) 39 | 40 | // Test implements the integration test for the Python generator 41 | type Test struct { 42 | ProjectDir string 43 | TestDir string 44 | } 45 | 46 | // New creates a new Test 47 | func New(projectDir, testDir string) *Test { 48 | return &Test{ 49 | ProjectDir: projectDir, 50 | TestDir: testDir, 51 | } 52 | } 53 | 54 | // Run executes the Python integration test 55 | func (t *Test) Run(ctx context.Context, client *dagger.Client) (*dagger.Container, error) { 56 | // Source code container 57 | source := client.Host().Directory(t.ProjectDir) 58 | testFiles := client.Host().Directory(t.TestDir, dagger.HostDirectoryOpts{ 59 | Include: []string{"test_openfeature.py", "requirements.txt"}, 60 | }) 61 | 62 | // Build the CLI 63 | cli := client.Container(). 64 | From("golang:1.24-alpine"). 65 | WithDirectory("/src", source). 66 | WithWorkdir("/src"). 67 | WithExec([]string{"go", "build", "-o", "cli"}) 68 | 69 | // Generate Python client 70 | generated := cli.WithExec([]string{ 71 | "./cli", "generate", "python", 72 | "--manifest=/src/sample/sample_manifest.json", 73 | "--output=/tmp/generated", 74 | "--package=openfeature_test", 75 | }) 76 | 77 | // Get generated files 78 | generatedFiles := generated.Directory("/tmp/generated") 79 | 80 | // Test Python with the generated files 81 | pythonContainer := client.Container(). 82 | From("python:3.11-slim"). 83 | WithDirectory("/app/openfeature", generatedFiles). 84 | WithDirectory("/app/test", testFiles). 85 | WithWorkdir("/app"). 86 | WithExec([]string{"pip", "install", "-r", "test/requirements.txt"}). 87 | WithExec([]string{"python", "-m", "pytest", "test/test_openfeature.py", "-v"}) 88 | 89 | return pythonContainer, nil 90 | } 91 | 92 | // Name returns the name of the integration test 93 | func (t *Test) Name() string { 94 | return "python" 95 | } 96 | 97 | func main() { 98 | ctx := context.Background() 99 | 100 | // Get project root 101 | projectDir, err := filepath.Abs(os.Getenv("PWD")) 102 | if err != nil { 103 | fmt.Fprintf(os.Stderr, "Failed to get project dir: %v\n", err) 104 | os.Exit(1) 105 | } 106 | 107 | // Get test directory 108 | testDir, err := filepath.Abs(filepath.Join(projectDir, "test/python-integration")) 109 | if err != nil { 110 | fmt.Fprintf(os.Stderr, "Failed to get test dir: %v\n", err) 111 | os.Exit(1) 112 | } 113 | 114 | // Create and run the Python integration test 115 | test := New(projectDir, testDir) 116 | 117 | if err := integration.RunTest(ctx, test); err != nil { 118 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 119 | os.Exit(1) 120 | } 121 | } 122 | ``` 123 | 124 | ## Step 2: Add the test to the all-integration runner 125 | 126 | Update `test/integration/cmd/run.go` to include your test: 127 | 128 | ```go 129 | package main 130 | 131 | import ( 132 | "fmt" 133 | "os" 134 | "os/exec" 135 | ) 136 | 137 | func main() { 138 | // Run the generator-specific tests 139 | fmt.Println("=== Running all integration tests ===") 140 | 141 | // Run the C# integration test 142 | csharpCmd := exec.Command("go", "run", "github.com/open-feature/cli/test/integration/cmd/csharp") 143 | csharpCmd.Stdout = os.Stdout 144 | csharpCmd.Stderr = os.Stderr 145 | if err := csharpCmd.Run(); err != nil { 146 | fmt.Fprintf(os.Stderr, "Error running C# integration test: %v\n", err) 147 | os.Exit(1) 148 | } 149 | 150 | // Run the Python integration test 151 | pythonCmd := exec.Command("go", "run", "github.com/open-feature/cli/test/integration/cmd/python") 152 | pythonCmd.Stdout = os.Stdout 153 | pythonCmd.Stderr = os.Stderr 154 | if err := pythonCmd.Run(); err != nil { 155 | fmt.Fprintf(os.Stderr, "Error running Python integration test: %v\n", err) 156 | os.Exit(1) 157 | } 158 | 159 | // Add more tests here as they are available 160 | 161 | fmt.Println("=== All integration tests passed successfully ===") 162 | } 163 | ``` 164 | 165 | ## Step 3: Create test files 166 | 167 | Create the following directory structure with your test files: 168 | 169 | ``` 170 | test/ 171 | python-integration/ 172 | requirements.txt 173 | test_openfeature.py 174 | README.md 175 | ``` 176 | 177 | ## Step 4: Add a Makefile target 178 | 179 | Update the Makefile with a new target: 180 | 181 | ```makefile 182 | .PHONY: test-python-dagger 183 | test-python-dagger: 184 | @echo "Running Python integration test with Dagger..." 185 | @go run ./test/integration/cmd/python/run.go 186 | ``` 187 | 188 | ## Step 5: Update the documentation 189 | 190 | Update `test/README.md` to include your new test. --------------------------------------------------------------------------------