├── .expeditor ├── config.yml ├── run_linux_tests.sh ├── run_windows_tests.ps1 ├── update_version.sh └── verify.pipeline.yml ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── BUG_TEMPLATE.md │ ├── DESIGN_PROPOSAL.md │ ├── ENHANCEMENT_REQUEST_TEMPLATE.md │ └── SUPPORT_QUESTION.md ├── dependabot.yml └── workflows │ ├── lint.yml │ └── unit.yml ├── .gitignore ├── .rubocop.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE ├── NOTICE ├── README.md ├── Rakefile ├── VERSION ├── lib └── mixlib │ ├── cli.rb │ └── cli │ ├── formatter.rb │ └── version.rb ├── mixlib-cli.gemspec └── spec ├── mixlib ├── cli │ └── formatter_spec.rb └── cli_spec.rb └── spec_helper.rb /.expeditor/config.yml: -------------------------------------------------------------------------------- 1 | # Documentation available at https://expeditor.chef.io/docs/getting-started/ 2 | --- 3 | 4 | # Slack channel in Chef Software slack to send notifications about build failures, etc 5 | slack: 6 | notify_channel: chef-found-notify 7 | 8 | # This publish is triggered by the `built_in:publish_rubygems` artifact_action. 9 | rubygems: 10 | - mixlib-cli 11 | 12 | github: 13 | # This deletes the GitHub PR branch after successfully merged into the release branch 14 | delete_branch_on_merge: true 15 | # The tag format to use (e.g. v1.0.0) 16 | version_tag_format: "v{{version}}" 17 | # allow bumping the minor release via label 18 | minor_bump_labels: 19 | - "Expeditor: Bump Version Minor" 20 | # allow bumping the major release via label 21 | major_bump_labels: 22 | - "Expeditor: Bump Version Major" 23 | 24 | changelog: 25 | rollup_header: Changes not yet released to rubygems.org 26 | 27 | subscriptions: 28 | # These actions are taken, in order they are specified, anytime a Pull Request is merged. 29 | - workload: pull_request_merged:{{github_repo}}:{{release_branch}}:* 30 | actions: 31 | - built_in:bump_version: 32 | ignore_labels: 33 | - "Expeditor: Skip Version Bump" 34 | - "Expeditor: Skip All" 35 | - bash:.expeditor/update_version.sh: 36 | only_if: built_in:bump_version 37 | - built_in:update_changelog: 38 | ignore_labels: 39 | - "Expeditor: Skip Changelog" 40 | - "Expeditor: Skip All" 41 | - built_in:build_gem: 42 | only_if: built_in:bump_version 43 | 44 | - workload: project_promoted:{{agent_id}}:* 45 | actions: 46 | - built_in:rollover_changelog 47 | - built_in:publish_rubygems 48 | 49 | pipelines: 50 | - verify: 51 | description: Pull Request validation tests 52 | public: true 53 | -------------------------------------------------------------------------------- /.expeditor/run_linux_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This script runs a passed in command, but first setups up the bundler caching on the repo 4 | 5 | set -ue 6 | 7 | export USER="root" 8 | 9 | echo "--- dependencies" 10 | export LANG=C.UTF-8 LANGUAGE=C.UTF-8 11 | S3_URL="s3://public-cd-buildkite-cache/${BUILDKITE_PIPELINE_SLUG}/${BUILDKITE_LABEL}" 12 | 13 | pull_s3_file() { 14 | aws s3 cp "${S3_URL}/$1" "$1" || echo "Could not pull $1 from S3" 15 | } 16 | 17 | push_s3_file() { 18 | if [ -f "$1" ]; then 19 | aws s3 cp "$1" "${S3_URL}/$1" || echo "Could not push $1 to S3 for caching." 20 | fi 21 | } 22 | 23 | apt-get update -y 24 | apt-get install awscli -y 25 | 26 | echo "--- bundle install" 27 | pull_s3_file "bundle.tar.gz" 28 | pull_s3_file "bundle.sha256" 29 | 30 | if [ -f bundle.tar.gz ]; then 31 | tar -xzf bundle.tar.gz 32 | fi 33 | 34 | if [ -n "${RESET_BUNDLE_CACHE:-}" ]; then 35 | rm bundle.sha256 36 | fi 37 | 38 | bundle config --local path vendor/bundle 39 | bundle install --jobs=7 --retry=3 40 | 41 | echo "--- bundle cache" 42 | if test -f bundle.sha256 && shasum --check bundle.sha256 --status; then 43 | echo "Bundled gems have not changed. Skipping upload to s3" 44 | else 45 | echo "Bundled gems have changed. Uploading to s3" 46 | shasum -a 256 Gemfile.lock > bundle.sha256 47 | tar -czf bundle.tar.gz vendor/ 48 | push_s3_file bundle.tar.gz 49 | push_s3_file bundle.sha256 50 | fi 51 | 52 | echo "+++ bundle exec task" 53 | bundle exec $@ 54 | -------------------------------------------------------------------------------- /.expeditor/run_windows_tests.ps1: -------------------------------------------------------------------------------- 1 | # Stop script execution when a non-terminating error occurs 2 | $ErrorActionPreference = "Stop" 3 | 4 | # This will run ruby test on windows platform 5 | 6 | Write-Output "--- Bundle install" 7 | 8 | bundle config --local path vendor/bundle 9 | If ($lastexitcode -ne 0) { Exit $lastexitcode } 10 | 11 | bundle install --jobs=7 --retry=3 12 | If ($lastexitcode -ne 0) { Exit $lastexitcode } 13 | 14 | Write-Output "--- Bundle Execute" 15 | 16 | bundle exec rake 17 | If ($lastexitcode -ne 0) { Exit $lastexitcode } -------------------------------------------------------------------------------- /.expeditor/update_version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # After a PR merge, Chef Expeditor will bump the PATCH version in the VERSION file. 4 | # It then executes this file to update any other files/components with that new version. 5 | # 6 | 7 | set -evx 8 | 9 | VERSION=$(cat VERSION) 10 | 11 | sed -i -r "s/^(\\s*)VERSION = \".+\"/\\1VERSION = \"$VERSION\"/" lib/mixlib/cli/version.rb 12 | 13 | # Once Expeditor finshes executing this script, it will commit the changes and push 14 | # the commit as a new tag corresponding to the value in the VERSION file. 15 | -------------------------------------------------------------------------------- /.expeditor/verify.pipeline.yml: -------------------------------------------------------------------------------- 1 | --- 2 | expeditor: 3 | defaults: 4 | buildkite: 5 | timeout_in_minutes: 30 6 | 7 | steps: 8 | 9 | - label: run-lint-and-specs-ruby-3.1 10 | command: 11 | - .expeditor/run_linux_tests.sh rake 12 | expeditor: 13 | executor: 14 | docker: 15 | image: ruby:3.1 16 | 17 | - label: run-lint-and-specs-ruby-3.4 18 | command: 19 | - .expeditor/run_linux_tests.sh rake 20 | expeditor: 21 | executor: 22 | docker: 23 | image: ruby:3.4 24 | 25 | - label: run-specs-ruby-3.1-windows 26 | commands: 27 | - .expeditor/run_windows_tests.ps1 28 | 29 | expeditor: 30 | executor: 31 | docker: 32 | host_os: windows 33 | shell: ["powershell"] 34 | image: rubydistros/windows-2019:3.1 35 | 36 | - label: run-specs-ruby-3.4-windows 37 | commands: 38 | - .expeditor/run_windows_tests.ps1 39 | 40 | expeditor: 41 | executor: 42 | docker: 43 | host_os: windows 44 | shell: ["powershell"] 45 | image: rubydistros/windows-2019:3.4 46 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Order is important. The last matching pattern has the most precedence. 2 | 3 | * @chef/chef-infra-reviewers @chef/chef-infra-approvers @chef/chef-infra-owners @jaymzh 4 | .expeditor/ @chef/infra-packages 5 | *.md @chef/docs-team 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: � Bug Report 3 | about: If something isn't working as expected �. 4 | labels: "Status: Untriaged, Type: Bug" 5 | --- 6 | 7 | # Version: 8 | 9 | [Version of the project installed] 10 | 11 | # Environment: 12 | 13 | [Details about the environment such as the Operating System, cookbook details, etc...] 14 | 15 | # Scenario: 16 | 17 | [What you are trying to achieve and you can't?] 18 | 19 | # Steps to Reproduce: 20 | 21 | [If you are filing an issue what are the things we need to do in order to repro your problem?] 22 | 23 | # Expected Result: 24 | 25 | [What are you expecting to happen as the consequence of above reproduction steps?] 26 | 27 | # Actual Result: 28 | 29 | [What actually happens after the reproduction steps?] 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/DESIGN_PROPOSAL.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Design Proposal 3 | about: I have a significant change I would like to propose and discuss before starting 4 | labels: "Status: Untriaged, Type: Design Proposal" 5 | --- 6 | 7 | ### When a Change Needs a Design Proposal 8 | 9 | A design proposal should be opened any time a change meets one of the following qualifications: 10 | 11 | - Significantly changes the user experience of a project in a way that impacts users. 12 | - Significantly changes the underlying architecture of the project in a way that impacts other developers. 13 | - Changes the development or testing process of the project such as a change of CI systems or test frameworks. 14 | 15 | ### Why We Use This Process 16 | 17 | - Allows all interested parties (including any community member) to discuss large impact changes to a project. 18 | - Serves as a durable paper trail for discussions regarding project architecture. 19 | - Forces design discussions to occur before PRs are created. 20 | - Reduces PR refactoring and rejected PRs. 21 | 22 | --- 23 | 24 | 25 | 26 | ## Motivation 27 | 28 | 33 | 34 | ## Specification 35 | 36 | 37 | 38 | ## Downstream Impact 39 | 40 | 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/ENHANCEMENT_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Enhancement Request 3 | about: I have a suggestion (and may want to implement it 🙂)! 4 | labels: "Status: Untriaged" 5 | --- 6 | 7 | ### Describe the Enhancement: 8 | 9 | 10 | ### Describe the Need: 11 | 12 | 13 | ### Current Alternative 14 | 15 | 16 | ### Can We Help You Implement This?: 17 | 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/SUPPORT_QUESTION.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🤗 Support Question 3 | about: If you have a question 💬, please check out our Slack! 4 | --- 5 | 6 | We use GitHub issues to track bugs and feature requests. If you need help please post to our Mailing List or join the Chef Community Slack. 7 | 8 | * Chef Community Slack at https://community-slack.chef.io/. 9 | * Chef Mailing List https://discourse.chef.io/ 10 | 11 | Support issues opened here will be closed and redirected to Slack or Discourse. 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "06:00" 8 | timezone: America/Los_Angeles 9 | open-pull-requests-limit: 10 10 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: lint 3 | 4 | on: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | 10 | concurrency: 11 | group: lint-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | lint: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: 3.1 22 | bundler-cache: false 23 | - uses: r7kamura/rubocop-problem-matchers-action@v1 # this shows the failures in the PR 24 | - run: | 25 | gem install cookstyle 26 | cookstyle --chefstyle -c .rubocop.yml 27 | -------------------------------------------------------------------------------- /.github/workflows/unit.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: unit 3 | 4 | on: 5 | pull_request: 6 | push: 7 | branches: 8 | - master 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | test: 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | os: [windows-2019, windows-2022] 19 | ruby: ['3.1', '3.4'] 20 | name: Unit test on ${{ matrix.os }} with Ruby ${{ matrix.ruby }} 21 | runs-on: ${{ matrix.os }} 22 | env: 23 | RUBYOPT: '--disable-error_highlight' 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: ruby-setup 27 | uses: ruby/setup-ruby@v1 28 | with: 29 | ruby-version: ${{ matrix.ruby }} 30 | bundler-cache: true 31 | - run: bundle exec rake spec -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _yardoc 2 | .bundle 3 | .config 4 | .DS_Store 5 | .idea 6 | .rake_tasks~ 7 | .rspec 8 | .ruby-version 9 | .rvmrc 10 | .yardoc 11 | .yardopts 12 | *.gem 13 | *.rbc 14 | *.sw? 15 | bin/ 16 | coverage 17 | doc 18 | Gemfile.local 19 | Gemfile.lock 20 | InstalledFiles 21 | lib/bundler/man 22 | pkg 23 | spec/reports 24 | test/tmp 25 | test/version_tmp 26 | tmp 27 | vendor 28 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 3.1 3 | Exclude: 4 | - "spec/data/**/*" 5 | - "habitat/**/*" 6 | - "vendor/**/*" 7 | Security/Eval: 8 | Enabled: false 9 | Lint/UselessAssignment: 10 | Enabled: false 11 | Lint/DeprecatedClassMethods: 12 | Enabled: false 13 | Lint/AmbiguousRegexpLiteral: 14 | Enabled: false 15 | Lint/AssignmentInCondition: 16 | Enabled: false 17 | Lint/AmbiguousBlockAssociation: 18 | Enabled: false 19 | Layout/EndOfLine: 20 | Enabled: false 21 | Lint/ShadowingOuterLocalVariable: 22 | Enabled: false 23 | Lint/IneffectiveAccessModifier: 24 | Enabled: false 25 | Lint/InterpolationCheck: 26 | Enabled: true 27 | Exclude: 28 | - 'spec/unit/property_spec.rb' 29 | - 'spec/functional/shell_spec.rb' 30 | Lint/DeprecatedConstants: 31 | Enabled: true 32 | Exclude: 33 | - lib/chef/node/attribute.rb # false alarms 34 | 35 | 36 | # This cop shouldn't alert on the helper / specs itself 37 | Chef/Ruby/LegacyPowershellOutMethods: 38 | Exclude: 39 | - 'lib/chef/mixin/powershell_out.rb' 40 | - 'spec/functional/mixin/powershell_out_spec.rb' 41 | - 'spec/unit/mixin/powershell_out_spec.rb' 42 | - 'lib/chef/resource/windows_feature_powershell.rb' # https://github.com/chef/chef/issues/10927 43 | - 'lib/chef/provider/package/powershell.rb' # https://github.com/chef/chef/issues/10926 44 | 45 | # set additional paths 46 | Chef/Ruby/UnlessDefinedRequire: 47 | Include: 48 | - 'lib/**/*' 49 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # mixlib-cli Changelog 2 | 3 | 4 | ## [v2.2.1](https://github.com/chef/mixlib-cli/tree/v2.2.1) (2025-05-27) 5 | 6 | #### Merged Pull Requests 7 | - Update byebug requirement from ~> 11.1 to ~> 12.0 [#110](https://github.com/chef/mixlib-cli/pull/110) ([dependabot[bot]](https://github.com/dependabot[bot])) 8 | 9 | 10 | 11 | ### Changes not yet released to rubygems.org 12 | 13 | #### Merged Pull Requests 14 | - Update byebug requirement from ~> 11.1 to ~> 12.0 [#110](https://github.com/chef/mixlib-cli/pull/110) ([dependabot[bot]](https://github.com/dependabot[bot])) 15 | - Updating Ruby [#109](https://github.com/chef/mixlib-cli/pull/109) ([johnmccrae](https://github.com/johnmccrae)) 16 | - add myself to codeowners [#105](https://github.com/chef/mixlib-cli/pull/105) ([jaymzh](https://github.com/jaymzh)) 17 | - Drop rubocop-ast pin [#108](https://github.com/chef/mixlib-cli/pull/108) ([jaymzh](https://github.com/jaymzh)) 18 | - Update pry-stack_explorer requirement from ~> 0.4.0 to ~> 0.6.1 [#85](https://github.com/chef/mixlib-cli/pull/85) ([dependabot[bot]](https://github.com/dependabot[bot])) 19 | - Bump min version to 3.0 and lock byebug to a version that will work [#106](https://github.com/chef/mixlib-cli/pull/106) ([jaymzh](https://github.com/jaymzh)) 20 | - change http to https, replace 404 Open4 link [#101](https://github.com/chef/mixlib-cli/pull/101) ([hhthacker](https://github.com/hhthacker)) 21 | - Bump mixlib-cli to use at least 2.7 to fix tests [#102](https://github.com/chef/mixlib-cli/pull/102) ([jaymzh](https://github.com/jaymzh)) 22 | - Upgrade to GitHub-native Dependabot [#80](https://github.com/chef/mixlib-cli/pull/80) ([dependabot-preview[bot]](https://github.com/dependabot-preview[bot])) 23 | - Replaces uses of __FILE__ with __dir__ [#79](https://github.com/chef/mixlib-cli/pull/79) ([tas50](https://github.com/tas50)) 24 | 25 | 26 | 27 | ## [v2.1.8](https://github.com/chef/mixlib-cli/tree/v2.1.8) (2020-08-21) 28 | 29 | #### Merged Pull Requests 30 | - Fix minor typos [#77](https://github.com/chef/mixlib-cli/pull/77) ([tas50](https://github.com/tas50)) 31 | - Optimize our requires [#78](https://github.com/chef/mixlib-cli/pull/78) ([tas50](https://github.com/tas50)) 32 | 33 | 34 | ## [v2.1.6](https://github.com/chef/mixlib-cli/tree/v2.1.6) (2020-04-07) 35 | 36 | #### Merged Pull Requests 37 | - Substitute require for require_relative [#76](https://github.com/chef/mixlib-cli/pull/76) ([tas50](https://github.com/tas50)) 38 | 39 | ## [v2.1.5](https://github.com/chef/mixlib-cli/tree/v2.1.5) (2019-12-22) 40 | 41 | #### Merged Pull Requests 42 | - Use our standard rakefile [#68](https://github.com/chef/mixlib-cli/pull/68) ([tas50](https://github.com/tas50)) 43 | - Fix chef-style [#71](https://github.com/chef/mixlib-cli/pull/71) ([vsingh-msys](https://github.com/vsingh-msys)) 44 | - Add windows PR testing with Buildkite [#73](https://github.com/chef/mixlib-cli/pull/73) ([tas50](https://github.com/tas50)) 45 | - Test on Ruby 2.7 + random testing improvements [#75](https://github.com/chef/mixlib-cli/pull/75) ([tas50](https://github.com/tas50)) 46 | 47 | ## [2.1.1](https://github.com/chef/mixlib-cli/tree/2.1.1) (2019-06-10) 48 | 49 | #### Merged Pull Requests 50 | - Don't explode when there are unknown keys in 'config' [#66](https://github.com/chef/mixlib-cli/pull/66) ([marcparadise](https://github.com/marcparadise)) 51 | 52 | ## [2.1.0](https://github.com/chef/mixlib-cli/tree/2.1.0) (2019-06-07) 53 | 54 | #### Merged Pull Requests 55 | - Setup BuildKite for PR testing [#61](https://github.com/chef/mixlib-cli/pull/61) ([tas50](https://github.com/tas50)) 56 | - Disable Travis testing & Update codeowners [#62](https://github.com/chef/mixlib-cli/pull/62) ([tas50](https://github.com/tas50)) 57 | - Fix gem homepage url [#64](https://github.com/chef/mixlib-cli/pull/64) ([tsub](https://github.com/tsub)) 58 | - [MIXLIB-CLI-63] Add deprecated_option support [#65](https://github.com/chef/mixlib-cli/pull/65) ([marcparadise](https://github.com/marcparadise)) 59 | 60 | ## [v2.0.6](https://github.com/chef/mixlib-cli/tree/v2.0.6) (2019-05-14) 61 | 62 | #### Merged Pull Requests 63 | - Add additional github templates and update codeowners [#58](https://github.com/chef/mixlib-cli/pull/58) ([tas50](https://github.com/tas50)) 64 | - Improve the --help text output of 'in:' [#59](https://github.com/chef/mixlib-cli/pull/59) ([btm](https://github.com/btm)) 65 | - Print out human readable lists of allowed CLI options [#60](https://github.com/chef/mixlib-cli/pull/60) ([tas50](https://github.com/tas50)) 66 | 67 | ## [v2.0.3](https://github.com/chef/mixlib-cli/tree/v2.0.3) (2019-03-20) 68 | 69 | #### Merged Pull Requests 70 | - fix global state pollution issues across examples [#54](https://github.com/chef/mixlib-cli/pull/54) ([lamont-granquist](https://github.com/lamont-granquist)) 71 | - Add back support for Ruby 2.4 [#56](https://github.com/chef/mixlib-cli/pull/56) ([tas50](https://github.com/tas50)) 72 | 73 | ## [v2.0.1](https://github.com/chef/mixlib-cli/tree/v2.0.1) (2019-01-04) 74 | 75 | #### Merged Pull Requests 76 | - Don't ship the test files in the gem artifact [#51](https://github.com/chef/mixlib-cli/pull/51) ([tas50](https://github.com/tas50)) 77 | 78 | ## [v2.0.0](https://github.com/chef/mixlib-cli/tree/v2.0.0) (2019-01-04) 79 | 80 | #### Merged Pull Requests 81 | - remove hashrockets syntax [#43](https://github.com/chef/mixlib-cli/pull/43) ([lamont-granquist](https://github.com/lamont-granquist)) 82 | - Remove require rubygems [#44](https://github.com/chef/mixlib-cli/pull/44) ([tas50](https://github.com/tas50)) 83 | - Update testing and contributing boilerplate [#45](https://github.com/chef/mixlib-cli/pull/45) ([tas50](https://github.com/tas50)) 84 | - More testing / release boilerplate [#46](https://github.com/chef/mixlib-cli/pull/46) ([tas50](https://github.com/tas50)) 85 | - Update codeowners and add github PR template [#47](https://github.com/chef/mixlib-cli/pull/47) ([tas50](https://github.com/tas50)) 86 | - Lint the example code [#49](https://github.com/chef/mixlib-cli/pull/49) ([tas50](https://github.com/tas50)) 87 | - update travis, drop ruby < 2.5, major version bump [#52](https://github.com/chef/mixlib-cli/pull/52) ([lamont-granquist](https://github.com/lamont-granquist)) 88 | - actually do the major version bump [#53](https://github.com/chef/mixlib-cli/pull/53) ([lamont-granquist](https://github.com/lamont-granquist)) 89 | 90 | 91 | 92 | ## 1.7.0 93 | 94 | - Support two-argument procs for reducer style 95 | 96 | ## 1.6.0 97 | 98 | - Properly pass options during inheritance 99 | - Added option key ':in' to specify that option value should be included in the given list 100 | - Fixed ruby-warning "instance variable @{ivar} not initialized". - [Kenichi Kamiya](https://github.com/kachick) 101 | - Documented CLI arguments. - [C Chamblin](https://github.com/chamblin) 102 | - Added rake, rdoc, and rspec and development dependencies 103 | - Removed the contributions.md document and merged it with the changelog 104 | - Updated code to comply with chefstyle style guidelines 105 | - Fixed a missing comma from an example in the readme 106 | - Ship the Gemfile so that tests can run from the Gem 107 | 108 | ## 1.5.0 109 | 110 | - Added an API to access option parser without parsing options 111 | - Added this changelog and a contributions document 112 | - Documented how to use cli_arguments 113 | 114 | ## 1.4.0 115 | 116 | - Added cli_arguments--remaining arguments after stripping CLI options 117 | - Add Travis and Bundler support 118 | 119 | ## 1.3.0 120 | 121 | - Added the ability to optionally store default values separately 122 | - Added comments documenting the primary interfaces 123 | - Fix mixlib-cli to work with bundler in Ruby 1.9.2 124 | 125 | ## 1.2.2 126 | 127 | - :required works, and we now support Ruby-style boolean option negation (e.g. '--no-cookie' will set 'cookie' to false if the option is boolean) 128 | - The repo now includes a Gemspec file 129 | - Jeweler is no longer a dependency 130 | 131 | ## 1.2.0 132 | 133 | We no longer destructively manipulate ARGV. -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | Please refer to the Chef Community Code of Conduct at https://www.chef.io/code-of-conduct/ 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Please refer to https://github.com/chef/chef/blob/master/CONTRIBUTING.md 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | group :docs do 6 | gem "github-markup" 7 | gem "redcarpet" 8 | gem "yard" 9 | end 10 | 11 | group :test do 12 | gem "rake" 13 | gem "rspec", "~> 3.0" 14 | end 15 | 16 | group :debug do 17 | gem "pry" 18 | # 12+ requires ruby 3.1 19 | gem "byebug", "~> 12.0" 20 | gem "pry-byebug" 21 | gem "pry-stack_explorer", "~> 0.6.1" # pin until we drop ruby < 2.6 22 | gem "rb-readline" 23 | end 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | https://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | https://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Mixin::CLI NOTICE 2 | ================= 3 | 4 | Developed at Chef (https://www.chef.io). 5 | 6 | 7 | * Copyright 2009-2018, Chef Software, Inc. 8 | 9 | Mixin::CLI incorporates code from Chef. The Chef notice file follows: 10 | 11 | Chef NOTICE 12 | =========== 13 | 14 | Developed at Chef (https://www.chef.io). 15 | 16 | Contributors and Copyright holders: 17 | 18 | * Copyright 2008, Adam Jacob 19 | * Copyright 2008, Arjuna Christensen 20 | * Copyright 2008, Bryan McLellan 21 | * Copyright 2008, Ezra Zygmuntowicz 22 | * Copyright 2009, Sean Cribbs 23 | * Copyright 2009, Christopher Brown 24 | * Copyright 2009, Thom May 25 | 26 | Chef incorporates code modified from Open4 (https://github.com/ahoward/open4), which was written by Ara T. Howard. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mixlib::CLI 2 | 3 | [![Build Status](https://badge.buildkite.com/5b595abc5c79a69fa4da5aeb14efd8e9104ec3a4ca53fc904a.svg?branch=master)](https://buildkite.com/chef-oss/chef-mixlib-cli-master-verify) 4 | [![Gem Version](https://badge.fury.io/rb/mixlib-cli.svg)](https://badge.fury.io/rb/mixlib-cli) 5 | 6 | **Umbrella Project**: [Chef Foundation](https://github.com/chef/chef-oss-practices/blob/master/projects/chef-foundation.md) 7 | 8 | **Project State**: [Active](https://github.com/chef/chef-oss-practices/blob/master/repo-management/repo-states.md#active) 9 | 10 | **Issues [Response Time Maximum](https://github.com/chef/chef-oss-practices/blob/master/repo-management/repo-states.md)**: 14 days 11 | 12 | **Pull Request [Response Time Maximum](https://github.com/chef/chef-oss-practices/blob/master/repo-management/repo-states.md)**: 14 days 13 | 14 | Mixlib::CLI provides a class-based command line option parsing object, like the one used in Chef, Ohai and Relish. To use in your project: 15 | 16 | ```ruby 17 | require "mixlib/cli" 18 | 19 | class MyCLI 20 | include Mixlib::CLI 21 | 22 | option :config_file, 23 | short: "-c CONFIG", 24 | long: "--config CONFIG", 25 | default: "config.rb", 26 | description: "The configuration file to use" 27 | 28 | option :log_level, 29 | short: "-l LEVEL", 30 | long: "--log_level LEVEL", 31 | description: "Set the log level (debug, info, warn, error, fatal)", 32 | required: true, 33 | in: [:debug, :info, :warn, :error, :fatal], 34 | proc: Proc.new { |l| l.to_sym } 35 | 36 | option :help, 37 | short: "-h", 38 | long: "--help", 39 | description: "Show this message", 40 | on: :tail, 41 | boolean: true, 42 | show_options: true, 43 | exit: 0 44 | 45 | end 46 | 47 | # ARGV = [ '-c', 'foo.rb', '-l', 'debug' ] 48 | cli = MyCLI.new 49 | cli.parse_options 50 | cli.config[:config_file] # 'foo.rb' 51 | cli.config[:log_level] # :debug 52 | ``` 53 | 54 | If you are using this in conjunction with Mixlib::Config, you can do something like this (building on the above definition): 55 | 56 | ```ruby 57 | class MyConfig 58 | extend(Mixlib::Config) 59 | 60 | log_level :info 61 | config_file "default.rb" 62 | end 63 | 64 | class MyCLI 65 | def run(argv = ARGV) 66 | parse_options(argv) 67 | MyConfig.merge!(config) 68 | end 69 | end 70 | 71 | c = MyCLI.new 72 | # ARGV = [ '-l', 'debug' ] 73 | c.run 74 | MyConfig[:log_level] # :debug 75 | ``` 76 | 77 | For supported arguments to `option`, see the function documentation in [lib/mixlib/cli.rb](lib/mixlib/cli.rb). 78 | 79 | 80 | If you need access to the leftover options that aren't captured in the config, you can get at them through +cli_arguments+ (referring to the above definition of MyCLI). 81 | 82 | ```ruby 83 | # ARGV = [ '-c', 'foo.rb', '-l', 'debug', 'file1', 'file2', 'file3' ] 84 | cli = MyCLI.new 85 | cli.parse_options 86 | cli.cli_arguments # [ 'file1', 'file2', 'file3' ] 87 | ``` 88 | 89 | ## Deprecating CLI Options 90 | 91 | mixlib-cli 2.1.0 and later supports declaring options as deprecated. Using a deprecated option 92 | will result in a warning message being displayed. If a deprecated flag is supplied, 93 | its value is assigned to the replacement flag. You can control this assignment by specifying a 94 | `value_mapper` function in the arguments (see example below, and function docs) 95 | 96 | 97 | Usage notes (see docs for arguments to `Mixlib::CLI::ClassMethods#deprecated_option` for more): 98 | 99 | * Define deprecated items last, after all non-deprecated items have been defined. 100 | You will see errors if your deprecated item references a `replacement` that hasn't been defined yet. 101 | * deprecated\_option only takes a subset of 'option' configuration. You can only specify `short`, `long` - these should 102 | map to the short/long values of the option from before its deprecation. 103 | * item description will automatically be generated along the lines of "-f/--flag is deprecated. Use -n/--new-flag instead." 104 | * if the `replacement` argument is not given, item description will look like "-f/--flag is deprecated and will be removed in a future release" 105 | 106 | ### Example 107 | 108 | Given the following example: 109 | 110 | ```ruby 111 | 112 | # my_cli.rb 113 | 114 | class MyCLI 115 | include Mixlib::CLI 116 | 117 | 118 | option :arg_not_required, 119 | description: "This takes no argument.", 120 | long: "--arg-not-required", 121 | short: "-n" 122 | 123 | option :arg_required, 124 | description: "This takes an argument.", 125 | long: "--arg-required ARG", 126 | short: "-a", 127 | in: ["a", "b", "c"] 128 | 129 | deprecated_option :dep_one, 130 | short: "-1", 131 | long: "--dep-one", 132 | # this deprecated option maps to the '--arg-not-required' option: 133 | replacement: :arg_not_required, 134 | # Do not keep 'dep_one' in `config` after parsing. 135 | keep: false 136 | 137 | deprecated_option :dep_two, 138 | short: "-2", 139 | long: "--dep-two ARG", 140 | replacement: :arg_required, 141 | # will map the given value to a valid value for `--arg-required`. 142 | value_mapper: Proc.new { |arg| 143 | case arg 144 | when "q"; "invalid" # We'll use this to show validation still works 145 | when "z"; "a" 146 | when "x"; "b" 147 | else 148 | "c" 149 | end 150 | } 151 | 152 | end 153 | 154 | c = MyCLI.new() 155 | c.parse_options 156 | 157 | puts "arg_required: #{c.config[:arg_required]}" if c.config.key? :arg_required 158 | puts "arg_not_required: #{c.config[:arg_not_required]}" if c.config.key? :arg_not_required 159 | puts "dep_one: #{c.config[:dep_one]}" if c.config.key? :dep_one 160 | puts "dep_two: #{c.config[:dep_two]}" if c.config.key? :dep_two 161 | 162 | ``` 163 | 164 | In this example, --dep-one will be used. Note that dep_one will not have a value of its own in 165 | `options` because `keep: false` was given to the deprecated option. 166 | 167 | ```bash 168 | 169 | $ ruby my_cli.rb --dep-one 170 | 171 | -1/--dep-one: This flag is deprecated. Use -n/--arg-not-required instead 172 | arg_not_required: true 173 | 174 | ``` 175 | 176 | In this example, the value provided to dep-two will be converted to a value 177 | that --arg-required will accept,a nd populate `:arg\_required` with 178 | 179 | ```bash 180 | 181 | $ ruby my_cli.rb --dep-two z # 'q' maps to 'invalid' in the value_mapper proc above 182 | 183 | -2/--dep-two: This flag is deprecated. Use -a/--arg-required instead. 184 | 185 | arg_required: a # The value is mapped to its replacement using the function provided. 186 | dep_two: z # the deprecated value is kept by default 187 | ``` 188 | 189 | In this example, the value provided to dep-two will be converted to a value 190 | that --arg-required will reject, showing how content rules are applied even when 191 | the input is coming from a deprecated option: 192 | 193 | ```bash 194 | $ ruby my_cli.rb --dep-two q 195 | 196 | -2/--dep-two: This flag is deprecated. Use -a/--arg-required instead. 197 | -a/--arg-required: invalid is not one of the allowed values: 'a', 'b', or 'c' 198 | 199 | ``` 200 | ## Documentation 201 | 202 | Class and module documentation is maintained using YARD. You can generate it by running: 203 | 204 | ``` 205 | rake docs 206 | ``` 207 | 208 | You can serve them locally with live refresh using: 209 | 210 | ``` 211 | bundle exec yard server --reload 212 | ``` 213 | 214 | ## Contributing 215 | 216 | For information on contributing to this project please see our [Contributing Documentation](https://github.com/chef/chef/blob/master/CONTRIBUTING.md) 217 | 218 | ## License & Copyright 219 | 220 | - Copyright:: Copyright (c) 2008-2018 Chef Software, Inc. 221 | - License:: Apache License, Version 2.0 222 | 223 | ```text 224 | Licensed under the Apache License, Version 2.0 (the "License"); 225 | you may not use this file except in compliance with the License. 226 | You may obtain a copy of the License at 227 | 228 | https://www.apache.org/licenses/LICENSE-2.0 229 | 230 | Unless required by applicable law or agreed to in writing, software 231 | distributed under the License is distributed on an "AS IS" BASIS, 232 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 233 | See the License for the specific language governing permissions and 234 | limitations under the License. 235 | ``` 236 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | begin 4 | require "rspec/core/rake_task" 5 | RSpec::Core::RakeTask.new do |t| 6 | t.pattern = "spec/**/*_spec.rb" 7 | end 8 | rescue LoadError 9 | desc "rspec is not installed, this task is disabled" 10 | task :spec do 11 | abort "rspec is not installed. bundle install first to make sure all dependencies are installed." 12 | end 13 | end 14 | 15 | desc "Check Linting and code style." 16 | task :style do 17 | require "rubocop/rake_task" 18 | require "cookstyle/chefstyle" 19 | 20 | if RbConfig::CONFIG["host_os"] =~ /mswin|mingw|cygwin/ 21 | # Windows-specific command, rubocop erroneously reports the CRLF in each file which is removed when your PR is uploaeded to GitHub. 22 | # This is a workaround to ignore the CRLF from the files before running cookstyle. 23 | sh "cookstyle --chefstyle -c .rubocop.yml --except Layout/EndOfLine" 24 | else 25 | sh "cookstyle --chefstyle -c .rubocop.yml" 26 | end 27 | rescue LoadError 28 | puts "Rubocop or Cookstyle gems are not installed. bundle install first to make sure all dependencies are installed." 29 | end 30 | 31 | begin 32 | require "yard" unless defined?(YARD) 33 | YARD::Rake::YardocTask.new(:docs) 34 | rescue LoadError 35 | puts "yard is not available. bundle install first to make sure all dependencies are installed." 36 | end 37 | 38 | task :console do 39 | require "irb" 40 | require "irb/completion" 41 | require "mixlib/cli" unless defined?(Mixlib::CLI) 42 | ARGV.clear 43 | IRB.start 44 | end 45 | 46 | task default: %i{spec style} 47 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 2.2.1 -------------------------------------------------------------------------------- /lib/mixlib/cli.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Author:: Adam Jacob () 3 | # Copyright:: Copyright (c) 2008-2019 Chef Software, Inc. 4 | # License:: Apache License, Version 2.0 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | require "optparse" unless defined?(OptionParser) 20 | require_relative "cli/formatter" 21 | module Mixlib 22 | 23 | # == Mixlib::CLI 24 | # Adds a DSL for defining command line options and methods for parsing those 25 | # options to the including class. 26 | # 27 | # Mixlib::CLI does some setup in #initialize, so the including class must 28 | # call `super()` if it defines a custom initializer. 29 | # 30 | # === DSL 31 | # When included, Mixlib::CLI also extends the including class with its 32 | # ClassMethods, which define the DSL. The primary methods of the DSL are 33 | # ClassMethods#option, which defines a command line option; 34 | # ClassMethods#banner, which defines the "usage" banner; 35 | # and ClassMethods#deprecated_option, which defines a deprecated command-line option. 36 | # 37 | # === Parsing 38 | # Command line options are parsed by calling the instance method 39 | # #parse_options. After calling this method, the attribute #config will 40 | # contain a hash of `:option_name => value` pairs. 41 | module CLI 42 | 43 | module InheritMethods 44 | def inherited(receiver) 45 | receiver.options = deep_dup(options) 46 | receiver.extend(Mixlib::CLI::InheritMethods) 47 | end 48 | 49 | # object:: Instance to clone 50 | # This method will return a "deep clone" of the provided 51 | # `object`. If the provided `object` is an enumerable type the 52 | # contents will be iterated and cloned as well. 53 | def deep_dup(object) 54 | cloned_object = object.respond_to?(:dup) ? object.dup : object 55 | if cloned_object.is_a?(Enumerable) 56 | if cloned_object.is_a?(Hash) 57 | new_hash = cloned_object.class.new 58 | cloned_object.each do |key, value| 59 | cloned_key = deep_dup(key) 60 | cloned_value = deep_dup(value) 61 | new_hash[cloned_key] = cloned_value 62 | end 63 | cloned_object.replace(new_hash) 64 | else 65 | cloned_object.map! do |shallow_instance| 66 | deep_dup(shallow_instance) 67 | end 68 | end 69 | end 70 | cloned_object 71 | rescue TypeError 72 | # Symbol will happily provide a `#dup` method even though 73 | # attempts to clone it will result in an exception (atoms!). 74 | # So if we run into an issue of TypeErrors, just return the 75 | # original object as we gave our "best effort" 76 | object 77 | end 78 | 79 | end 80 | 81 | module ClassMethods 82 | # When this setting is set to +true+, default values supplied to the 83 | # mixlib-cli DSL will be stored in a separate Hash 84 | def use_separate_default_options(true_or_false) 85 | @separate_default_options = true_or_false 86 | end 87 | 88 | def use_separate_defaults? 89 | @separate_default_options ||= false 90 | end 91 | 92 | # Add a command line option. 93 | # 94 | # === Parameters 95 | # name:: The name of the option to add 96 | # args:: A hash of arguments for the option, specifying how it should be parsed. 97 | # Supported arguments: 98 | # :short - The short option, just like from optparse. Example: "-l LEVEL" 99 | # :long - The long option, just like from optparse. Example: "--level LEVEL" 100 | # :description - The description for this item, just like from optparse. 101 | # :default - A default value for this option. Default values will be populated 102 | # on parse into `config` or `default_default`, depending `use_separate_defaults` 103 | # :boolean - indicates the flag is a boolean. You can use this if the flag takes no arguments 104 | # The config value will be set to 'true' if the flag is provided on the CLI and this 105 | # argument is set to true. The config value will be set to false only 106 | # if it has a default value of false 107 | # :required - When set, the option is required. If the command is run without this option, 108 | # it will print a message informing the user of the missing requirement, and exit. Default is false. 109 | # :proc - Proc that will be invoked if the human has specified this option. 110 | # Two forms are supported: 111 | # Proc/1 - provided value is passed in. 112 | # Proc/2 - first argument is provided value. Second is the cli flag option hash. 113 | # Both versions return the value to be assigned to the option. 114 | # :show_options - this option is designated as one that shows all supported options/help when invoked. 115 | # :exit - exit your program with the exit code when this option is given. Example: 0 116 | # :in - array containing a list of valid values. The value provided at run-time for the option is 117 | # validated against this. If it is not in the list, it will print a message and exit. 118 | # :on :head OR :tail - force this option to display at the beginning or end of the 119 | # option list, respectively 120 | # = 121 | # @return :: the config hash for the created option 122 | # i 123 | def option(name, args) 124 | @options ||= {} 125 | raise(ArgumentError, "Option name must be a symbol") unless name.is_a?(Symbol) 126 | 127 | @options[name.to_sym] = args 128 | end 129 | 130 | # Declare a deprecated option 131 | # 132 | # Add a deprecated command line option. 133 | # 134 | # name :: The name of the deprecated option 135 | # replacement :: The name of the option that replaces this option. 136 | # long :: The original long flag name, or flag name with argument, eg "--user USER" 137 | # short :: The original short-form flag name, eg "-u USER" 138 | # boolean :: true if this is a boolean flag, eg "--[no-]option". 139 | # value_mapper :: a block that accepts the original value from the deprecated option, 140 | # and converts it to a value suitable for the new option. 141 | # If not provided, the value provided to the deprecated option will be 142 | # assigned directly to the converted option. 143 | # keep :: Defaults to true, this ensures that `options[:deprecated_flag]` is 144 | # populated when the deprecated flag is used. If set to false, 145 | # only the value in `replacement` will be set. Results undefined 146 | # if no replacement is provided. You can use this to enforce the transition 147 | # to non-deprecated keys in your code. 148 | # 149 | # === Returns 150 | # :: The config hash for the created option. 151 | def deprecated_option(name, 152 | replacement: nil, 153 | long: nil, 154 | short: nil, 155 | boolean: false, 156 | value_mapper: nil, 157 | keep: true) 158 | description = if replacement 159 | replacement_cfg = options[replacement] 160 | display_name = CLI::Formatter.combined_option_display_name(replacement_cfg[:short], replacement_cfg[:long]) 161 | "This flag is deprecated. Use #{display_name} instead." 162 | else 163 | "This flag is deprecated and will be removed in a future release." 164 | end 165 | value_mapper ||= Proc.new { |v| v } 166 | 167 | option(name, 168 | long: long, 169 | short: short, 170 | boolean: boolean, 171 | description: description, 172 | on: :tail, 173 | deprecated: true, 174 | keep: keep, 175 | replacement: replacement, 176 | value_mapper: value_mapper) 177 | end 178 | 179 | # Get the hash of current options. 180 | # 181 | # === Returns 182 | # @options:: The current options hash. 183 | def options 184 | @options ||= {} 185 | @options 186 | end 187 | 188 | # Set the current options hash 189 | # 190 | # === Parameters 191 | # val:: The hash to set the options to 192 | # 193 | # === Returns 194 | # @options:: The current options hash. 195 | def options=(val) 196 | raise(ArgumentError, "Options must receive a hash") unless val.is_a?(Hash) 197 | 198 | @options = val 199 | end 200 | 201 | # Change the banner. Defaults to: 202 | # Usage: #{0} (options) 203 | # 204 | # === Parameters 205 | # bstring:: The string to set the banner to 206 | # 207 | # === Returns 208 | # @banner:: The current banner 209 | def banner(bstring = nil) 210 | if bstring 211 | @banner = bstring 212 | else 213 | @banner ||= "Usage: #{$0} (options)" 214 | @banner 215 | end 216 | end 217 | end 218 | 219 | # Gives the command line options definition as configured in the DSL. These 220 | # are used by #parse_options to generate the option parsing code. To get 221 | # the values supplied by the user, see #config. 222 | attr_accessor :options 223 | 224 | # A Hash containing the values supplied by command line options. 225 | # 226 | # The behavior and contents of this Hash vary depending on whether 227 | # ClassMethods#use_separate_default_options is enabled. 228 | # ==== use_separate_default_options *disabled* 229 | # After initialization, +config+ will contain any default values defined 230 | # via the mixlib-config DSL. When #parse_options is called, user-supplied 231 | # values (from ARGV) will be merged in. 232 | # ==== use_separate_default_options *enabled* 233 | # After initialization, this will be an empty hash. When #parse_options is 234 | # called, +config+ is populated *only* with user-supplied values. 235 | attr_accessor :config 236 | 237 | # If ClassMethods#use_separate_default_options is enabled, this will be a 238 | # Hash containing key value pairs of `:option_name => default_value` 239 | # (populated during object initialization). 240 | # 241 | # If use_separate_default_options is disabled, it will always be an empty 242 | # hash. 243 | attr_accessor :default_config 244 | 245 | # Any arguments which were not parsed and placed in "config"--the leftovers. 246 | attr_accessor :cli_arguments 247 | 248 | # Banner for the option parser. If the option parser is printed, e.g., by 249 | # `puts opt_parser`, this string will be used as the first line. 250 | attr_accessor :banner 251 | 252 | # Create a new Mixlib::CLI class. If you override this, make sure you call super! 253 | # 254 | # === Parameters 255 | # *args:: The array of arguments passed to the initializer 256 | # 257 | # === Returns 258 | # object:: Returns an instance of whatever you wanted :) 259 | def initialize(*args) 260 | @options = {} 261 | @config = {} 262 | @default_config = {} 263 | @opt_parser = nil 264 | 265 | # Set the banner 266 | @banner = self.class.banner 267 | 268 | # Dupe the class options for this instance 269 | klass_options = self.class.options 270 | klass_options.keys.inject(@options) { |memo, key| memo[key] = klass_options[key].dup; memo } 271 | 272 | # If use_separate_defaults? is on, default values go in @default_config 273 | defaults_container = if self.class.use_separate_defaults? 274 | @default_config 275 | else 276 | @config 277 | end 278 | 279 | # Set the default configuration values for this instance 280 | @options.each do |config_key, config_opts| 281 | config_opts[:on] ||= :on 282 | config_opts[:boolean] ||= false 283 | config_opts[:required] ||= false 284 | config_opts[:proc] ||= nil 285 | config_opts[:show_options] ||= false 286 | config_opts[:exit] ||= nil 287 | config_opts[:in] ||= nil 288 | if config_opts.key?(:default) 289 | defaults_container[config_key] = config_opts[:default] 290 | end 291 | end 292 | 293 | super(*args) 294 | end 295 | 296 | # Parses an array, by default ARGV, for command line options (as configured at 297 | # the class level). 298 | # === Parameters 299 | # argv:: The array of arguments to parse; defaults to ARGV 300 | # 301 | # === Returns 302 | # argv:: Returns any un-parsed elements. 303 | def parse_options(argv = ARGV, show_deprecations: true) 304 | argv = argv.dup 305 | opt_parser.parse!(argv) 306 | # Do this before our custom validations, so that custom 307 | # validations apply to any converted deprecation values; 308 | # but after parse! so that everything is populated. 309 | handle_deprecated_options(show_deprecations) 310 | 311 | # Deal with any required values 312 | options.each do |opt_key, opt_config| 313 | if opt_config[:required] && !config.key?(opt_key) 314 | reqarg = opt_config[:short] || opt_config[:long] 315 | puts "You must supply #{reqarg}!" 316 | puts @opt_parser 317 | exit 2 318 | end 319 | if opt_config[:in] 320 | unless opt_config[:in].is_a?(Array) 321 | raise(ArgumentError, "Options config key :in must receive an Array") 322 | end 323 | 324 | if config[opt_key] && !opt_config[:in].include?(config[opt_key]) 325 | reqarg = Formatter.combined_option_display_name(opt_config[:short], opt_config[:long]) 326 | puts "#{reqarg}: #{config[opt_key]} is not one of the allowed values: #{Formatter.friendly_opt_list(opt_config[:in])}" 327 | # TODO - get rid of this. nobody wants to be spammed with a ton of information, particularly since we just told them the exact problem and how to fix it. 328 | puts @opt_parser 329 | exit 2 330 | end 331 | end 332 | end 333 | 334 | @cli_arguments = argv 335 | argv 336 | end 337 | 338 | # The option parser generated from the mixlib-cli DSL. +opt_parser+ can be 339 | # used to print a help message including the banner and any CLI options via 340 | # `puts opt_parser`. 341 | # === Returns 342 | # opt_parser:: The option parser object. 343 | def opt_parser 344 | @opt_parser ||= OptionParser.new do |opts| 345 | # Set the banner 346 | opts.banner = banner 347 | 348 | # Create new options 349 | options.sort { |a, b| a[0].to_s <=> b[0].to_s }.each do |opt_key, opt_val| 350 | opt_args = build_option_arguments(opt_val) 351 | opt_method = case opt_val[:on] 352 | when :on 353 | :on 354 | when :tail 355 | :on_tail 356 | when :head 357 | :on_head 358 | else 359 | raise ArgumentError, "You must pass :on, :tail, or :head to :on" 360 | end 361 | 362 | parse_block = 363 | Proc.new do |c| 364 | config[opt_key] = if opt_val[:proc] 365 | if opt_val[:proc].arity == 2 366 | # New hotness to allow for reducer-style procs. 367 | opt_val[:proc].call(c, config[opt_key]) 368 | else 369 | # Older single-argument proc. 370 | opt_val[:proc].call(c) 371 | end 372 | else 373 | # No proc. 374 | c 375 | end 376 | puts opts if opt_val[:show_options] 377 | exit opt_val[:exit] if opt_val[:exit] 378 | end 379 | 380 | full_opt = [ opt_method ] 381 | opt_args.inject(full_opt) { |memo, arg| memo << arg; memo } 382 | full_opt << parse_block 383 | opts.send(*full_opt) 384 | end 385 | end 386 | end 387 | 388 | # Iterates through options declared as deprecated, 389 | # maps values to their replacement options, 390 | # and prints deprecation warnings. 391 | # 392 | # @return NilClass 393 | def handle_deprecated_options(show_deprecations) 394 | merge_in_values = {} 395 | config.each_key do |opt_key| 396 | opt_cfg = options[opt_key] 397 | 398 | # Deprecated entries do not have defaults so no matter what 399 | # separate_default_options are set, if we see a 'config' 400 | # entry that contains a deprecated indicator, then the option was 401 | # explicitly provided by the caller. 402 | # 403 | # opt_cfg may not exist if an inheriting application 404 | # has directly inserted values info config. 405 | next unless opt_cfg && opt_cfg[:deprecated] 406 | 407 | replacement_key = opt_cfg[:replacement] 408 | if replacement_key 409 | # This is the value passed into the deprecated flag. We'll use 410 | # the declared value mapper (defaults to return the same value if caller hasn't 411 | # provided a mapper). 412 | deprecated_val = config[opt_key] 413 | 414 | # We can't modify 'config' since we're iterating it, apply updates 415 | # at the end. 416 | merge_in_values[replacement_key] = opt_cfg[:value_mapper].call(deprecated_val) 417 | config.delete(opt_key) unless opt_cfg[:keep] 418 | end 419 | 420 | # Warn about the deprecation. 421 | if show_deprecations 422 | # Description is also the deprecation message. 423 | display_name = CLI::Formatter.combined_option_display_name(opt_cfg[:short], opt_cfg[:long]) 424 | puts "#{display_name}: #{opt_cfg[:description]}" 425 | end 426 | end 427 | config.merge!(merge_in_values) 428 | nil 429 | end 430 | 431 | def build_option_arguments(opt_setting) 432 | arguments = [] 433 | 434 | arguments << opt_setting[:short] if opt_setting[:short] 435 | arguments << opt_setting[:long] if opt_setting[:long] 436 | if opt_setting.key?(:description) 437 | description = opt_setting[:description].dup 438 | description << " (required)" if opt_setting[:required] 439 | description << " (valid options: #{Formatter.friendly_opt_list(opt_setting[:in])})" if opt_setting[:in] 440 | opt_setting[:description] = description 441 | arguments << description 442 | end 443 | 444 | arguments 445 | end 446 | 447 | def self.included(receiver) 448 | receiver.extend(Mixlib::CLI::ClassMethods) 449 | receiver.extend(Mixlib::CLI::InheritMethods) 450 | end 451 | end 452 | end 453 | -------------------------------------------------------------------------------- /lib/mixlib/cli/formatter.rb: -------------------------------------------------------------------------------- 1 | 2 | module Mixlib 3 | module CLI 4 | class Formatter 5 | # Create a string that includes both versions (short/long) of a flag name 6 | # based on on whether short/long/both/neither are provided 7 | # 8 | # @param short [String] the short name of the option. Can be nil. 9 | # @param long [String] the long name of the option. Can be nil. 10 | # @return [String] the formatted flag name as described above 11 | def self.combined_option_display_name(short, long) 12 | usage = "" 13 | # short/long may have an argument (--long ARG) 14 | # splitting on " " and taking first ensures that we get just 15 | # the flag name without the argument if one is present. 16 | usage << short.split(" ").first if short 17 | usage << "/" if long && short 18 | usage << long.split(" ").first if long 19 | usage 20 | end 21 | 22 | # @param opt_array [Array] 23 | # 24 | # @return [String] a friendly quoted list of items complete with "or" 25 | def self.friendly_opt_list(opt_array) 26 | opts = opt_array.map { |x| "'#{x}'" } 27 | return opts.join(" or ") if opts.size < 3 28 | 29 | opts[0..-2].join(", ") + ", or " + opts[-1] 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/mixlib/cli/version.rb: -------------------------------------------------------------------------------- 1 | module Mixlib 2 | module CLI 3 | VERSION = "2.2.1".freeze 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /mixlib-cli.gemspec: -------------------------------------------------------------------------------- 1 | $:.unshift(__dir__ + "/lib") 2 | require "mixlib/cli/version" 3 | 4 | Gem::Specification.new do |s| 5 | s.name = "mixlib-cli" 6 | s.version = Mixlib::CLI::VERSION 7 | s.summary = "A simple mixin for CLI interfaces, including option parsing" 8 | s.description = s.summary 9 | s.author = "Chef Software, Inc." 10 | s.email = "info@chef.io" 11 | s.homepage = "https://github.com/chef/mixlib-cli" 12 | s.license = "Apache-2.0" 13 | s.required_ruby_version = ">= 3.1" 14 | 15 | s.require_path = "lib" 16 | s.files = %w{LICENSE NOTICE} + Dir.glob("lib/**/*", File::FNM_DOTMATCH).reject { |f| File.directory?(f) } 17 | s.add_development_dependency "cookstyle", "~> 8.1" 18 | end 19 | -------------------------------------------------------------------------------- /spec/mixlib/cli/formatter_spec.rb: -------------------------------------------------------------------------------- 1 | 2 | require "mixlib/cli/formatter" 3 | 4 | describe Mixlib::CLI::Formatter do 5 | Formatter = Mixlib::CLI::Formatter 6 | context "combined_option_display_name" do 7 | it "converts --option with short -o to '-s/--option'" do 8 | expect(Formatter.combined_option_display_name("-o", "--option")).to eql "-o/--option" 9 | end 10 | 11 | it "converts --option with no short to '--option'" do 12 | expect(Formatter.combined_option_display_name(nil, "--option")).to eql "--option" 13 | end 14 | it "converts short -o with no long option to '-o'" do 15 | expect(Formatter.combined_option_display_name("-o", nil)).to eql"-o" 16 | end 17 | 18 | it "converts options the same way even with an argument present" do 19 | expect(Formatter.combined_option_display_name("-o arg1", "--option arg1")).to eql "-o/--option" 20 | end 21 | 22 | it "converts options to a blank string if neither short nor long are present" do 23 | expect(Formatter.combined_option_display_name(nil, nil)).to eql "" 24 | end 25 | end 26 | 27 | context "friendly_opt_list" do 28 | it "for a single item it quotes it and returns it as a string" do 29 | expect(Formatter.friendly_opt_list(%w{hello})).to eql "'hello'" 30 | end 31 | it "for two items returns ..." do 32 | expect(Formatter.friendly_opt_list(%w{hello world})).to eql "'hello' or 'world'" 33 | end 34 | it "for three items returns..." do 35 | expect(Formatter.friendly_opt_list(%w{hello green world})).to eql "'hello', 'green', or 'world'" 36 | end 37 | it "for more than three items creates a list in the same was as three items" do 38 | expect(Formatter.friendly_opt_list(%w{hello green world good morning})).to eql "'hello', 'green', 'world', 'good', or 'morning'" 39 | end 40 | 41 | end 42 | 43 | end 44 | -------------------------------------------------------------------------------- /spec/mixlib/cli_spec.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Author:: Adam Jacob () 3 | # Copyright:: Copyright (c) 2008-2019, Chef Software Inc. 4 | # License:: Apache License, Version 2.0 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | require "spec_helper" 20 | 21 | describe Mixlib::CLI do 22 | after(:each) do 23 | TestCLI.options = {} 24 | TestCLI.banner("Usage: #{$0} (options)") 25 | end 26 | 27 | describe "class method" do 28 | describe "option" do 29 | it "allows you to set a config option with a hash" do 30 | expect(TestCLI.option(:config_file, short: "-c CONFIG")).to eql({ short: "-c CONFIG" }) 31 | end 32 | end 33 | 34 | describe "deprecated_option" do 35 | it "makes a deprecated option when you declare one" do 36 | TestCLI.deprecated_option(:option_d, short: "-d") 37 | expect(TestCLI.options[:option_d]).to include(deprecated: true) 38 | end 39 | end 40 | 41 | describe "options" do 42 | it "returns the current options hash" do 43 | TestCLI.option(:config_file, short: "-c CONFIG") 44 | expect(TestCLI.options).to eql({ config_file: { short: "-c CONFIG" } }) 45 | end 46 | it "includes deprecated options and their generated descriptions" do 47 | TestCLI.option(:config_file, short: "-c CONFIG") 48 | TestCLI.deprecated_option(:blah, short: "-b BLAH") 49 | TestCLI.deprecated_option(:blah2, long: "--blah2 BLAH", replacement: :config_file) 50 | opts = TestCLI.options 51 | expect(opts[:config_file][:short]).to eql("-c CONFIG") 52 | expect(opts[:config_file].key?(:deprecated)).to eql(false) 53 | expect(opts[:blah][:description]).to eql("This flag is deprecated and will be removed in a future release.") 54 | expect(opts[:blah][:deprecated]).to eql(true) 55 | expect(opts[:blah2][:description]).to eql("This flag is deprecated. Use -c instead.") 56 | expect(opts[:blah2][:deprecated]).to eql(true) 57 | end 58 | end 59 | 60 | describe "options=" do 61 | it "allows you to set the full options with a single hash" do 62 | TestCLI.options = { config_file: { short: "-c CONFIG" } } 63 | expect(TestCLI.options).to eql({ config_file: { short: "-c CONFIG" } }) 64 | end 65 | end 66 | 67 | describe "banner" do 68 | it "has a default value" do 69 | expect(TestCLI.banner).to match(/^Usage: (.+) \(options\)$/) 70 | end 71 | 72 | it "allows you to set the banner" do 73 | TestCLI.banner("Usage: foo") 74 | expect(TestCLI.banner).to eql("Usage: foo") 75 | end 76 | end 77 | end 78 | 79 | context "when configured with default single-config-hash behavior" do 80 | 81 | before(:each) do 82 | @cli = TestCLI.new 83 | end 84 | 85 | describe "initialize" do 86 | it "sets the banner to the class defined banner" do 87 | expect(@cli.banner).to eql(TestCLI.banner) 88 | end 89 | 90 | it "sets the options to the class defined options and deprecated options, with defaults" do 91 | TestCLI.option(:config_file, short: "-l FILE") 92 | TestCLI.deprecated_option(:option_file, short: "-o FILE", replacement: :config_file) 93 | cli = TestCLI.new 94 | expect(cli.options[:config_file]).to eql({ 95 | short: "-l FILE", 96 | on: :on, 97 | boolean: false, 98 | required: false, 99 | proc: nil, 100 | show_options: false, 101 | exit: nil, 102 | in: nil, 103 | }) 104 | 105 | expect(cli.options[:option_file]).to include( 106 | boolean: false, 107 | deprecated: true, 108 | description: "This flag is deprecated. Use -l instead.", 109 | exit: nil, 110 | in: nil, 111 | long: nil, 112 | keep: true, 113 | proc: nil, 114 | replacement: :config_file, 115 | required: false, 116 | short: "-o FILE", 117 | on: :tail, 118 | show_options: false 119 | ) 120 | expect(cli.options[:option_file][:value_mapper].class).to eql(Proc) 121 | end 122 | 123 | it "sets the default config value for any options that include it" do 124 | TestCLI.option(:config_file, short: "-l LOG", default: :debug) 125 | @cli = TestCLI.new 126 | expect(@cli.config[:config_file]).to eql(:debug) 127 | end 128 | end 129 | 130 | describe "opt_parser" do 131 | 132 | it "sets the banner in opt_parse" do 133 | expect(@cli.opt_parser.banner).to eql(@cli.banner) 134 | end 135 | 136 | it "presents the arguments in the banner" do 137 | TestCLI.option(:config_file, short: "-l LOG") 138 | @cli = TestCLI.new 139 | expect(@cli.opt_parser.to_s).to match(/-l LOG/) 140 | end 141 | 142 | it "honors :on => :tail options in the banner" do 143 | TestCLI.option(:config_file, short: "-l LOG") 144 | TestCLI.option(:help, short: "-h", boolean: true, on: :tail) 145 | @cli = TestCLI.new 146 | expect(@cli.opt_parser.to_s.split("\n").last).to match(/-h/) 147 | end 148 | 149 | it "honors :on => :head options in the banner" do 150 | TestCLI.option(:config_file, short: "-l LOG") 151 | TestCLI.option(:help, short: "-h", boolean: true, on: :head) 152 | @cli = TestCLI.new 153 | expect(@cli.opt_parser.to_s.split("\n")[1]).to match(/-h/) 154 | end 155 | 156 | it "presents the arguments in alphabetical order in the banner" do 157 | TestCLI.option(:alpha, short: "-a ALPHA") 158 | TestCLI.option(:beta, short: "-b BETA") 159 | TestCLI.option(:zeta, short: "-z ZETA") 160 | @cli = TestCLI.new 161 | output_lines = @cli.opt_parser.to_s.split("\n") 162 | expect(output_lines[1]).to match(/-a ALPHA/) 163 | expect(output_lines[2]).to match(/-b BETA/) 164 | expect(output_lines[3]).to match(/-z ZETA/) 165 | end 166 | 167 | end 168 | 169 | describe "parse_options" do 170 | it "sets the corresponding config value for non-boolean arguments" do 171 | TestCLI.option(:config_file, short: "-c CONFIG") 172 | @cli = TestCLI.new 173 | @cli.parse_options([ "-c", "foo.rb" ]) 174 | expect(@cli.config[:config_file]).to eql("foo.rb") 175 | end 176 | 177 | it "sets the corresponding config value according to a supplied proc" do 178 | TestCLI.option(:number, 179 | short: "-n NUMBER", 180 | proc: Proc.new { |config| config.to_i + 2 }) 181 | @cli = TestCLI.new 182 | @cli.parse_options([ "-n", "2" ]) 183 | expect(@cli.config[:number]).to eql(4) 184 | end 185 | 186 | it "passes the existing value to two-argument procs" do 187 | TestCLI.option(:number, 188 | short: "-n NUMBER", 189 | proc: Proc.new { |value, existing| existing ||= []; existing << value; existing }) 190 | @cli = TestCLI.new 191 | @cli.parse_options([ "-n", "2", "-n", "3" ]) 192 | expect(@cli.config[:number]).to eql(%w{2 3}) 193 | end 194 | 195 | it "sets the corresponding config value to true for boolean arguments" do 196 | TestCLI.option(:i_am_boolean, short: "-i", boolean: true) 197 | @cli = TestCLI.new 198 | @cli.parse_options([ "-i" ]) 199 | expect(@cli.config[:i_am_boolean]).to be true 200 | end 201 | 202 | it "sets the corresponding config value to false when a boolean is prefixed with --no" do 203 | TestCLI.option(:i_am_boolean, long: "--[no-]bool", boolean: true) 204 | @cli = TestCLI.new 205 | @cli.parse_options([ "--no-bool" ]) 206 | expect(@cli.config[:i_am_boolean]).to be false 207 | end 208 | 209 | it "exits if a config option has :exit set" do 210 | TestCLI.option(:i_am_exit, short: "-x", boolean: true, exit: 0) 211 | @cli = TestCLI.new 212 | expect(lambda { @cli.parse_options(["-x"]) }).to raise_error(SystemExit) 213 | end 214 | 215 | it "exits if a required option is missing" do 216 | TestCLI.option(:require_me, short: "-r", boolean: true, required: true) 217 | @cli = TestCLI.new 218 | expect(lambda { @cli.parse_options([]) }).to raise_error(SystemExit) 219 | end 220 | 221 | it "exits if option is not included in the list and required" do 222 | TestCLI.option(:inclusion, short: "-i val", in: %w{one two}, required: true) 223 | @cli = TestCLI.new 224 | expect(lambda { @cli.parse_options(["-i", "three"]) }).to raise_error(SystemExit) 225 | end 226 | 227 | it "exits if option is not included in the list and not required" do 228 | TestCLI.option(:inclusion, short: "-i val", in: %w{one two}, required: false) 229 | @cli = TestCLI.new 230 | expect(lambda { @cli.parse_options(["-i", "three"]) }).to raise_error(SystemExit) 231 | end 232 | 233 | it "doesn't exit if option is nil and not required" do 234 | TestCLI.option(:inclusion, short: "-i val", in: %w{one two}, required: false) 235 | @cli = TestCLI.new 236 | expect do 237 | expect(@cli.parse_options([])).to eql [] 238 | end.to_not raise_error 239 | end 240 | 241 | it "exit if option is nil and required" do 242 | TestCLI.option(:inclusion, short: "-i val", in: %w{one two}, required: true) 243 | @cli = TestCLI.new 244 | expect(lambda { @cli.parse_options([]) }).to raise_error(SystemExit) 245 | end 246 | 247 | it "raises ArgumentError if options key :in is not an array" do 248 | TestCLI.option(:inclusion, short: "-i val", in: "foo", required: true) 249 | @cli = TestCLI.new 250 | expect(lambda { @cli.parse_options(["-i", "three"]) }).to raise_error(ArgumentError) 251 | end 252 | 253 | it "doesn't exit if option is included in the list" do 254 | TestCLI.option(:inclusion, short: "-i val", in: %w{one two}, required: true) 255 | @cli = TestCLI.new 256 | @cli.parse_options(["-i", "one"]) 257 | expect(@cli.config[:inclusion]).to eql("one") 258 | end 259 | 260 | it "changes description if :in key is specified with a single value" do 261 | TestCLI.option(:inclusion, short: "-i val", in: %w{one}, description: "desc", required: false) 262 | @cli = TestCLI.new 263 | @cli.parse_options(["-i", "one"]) 264 | expect(@cli.options[:inclusion][:description]).to eql("desc (valid options: 'one')") 265 | end 266 | 267 | it "changes description if :in key is specified with 2 values" do 268 | TestCLI.option(:inclusion, short: "-i val", in: %w{one two}, description: "desc", required: false) 269 | @cli = TestCLI.new 270 | @cli.parse_options(["-i", "one"]) 271 | expect(@cli.options[:inclusion][:description]).to eql("desc (valid options: 'one' or 'two')") 272 | end 273 | 274 | it "changes description if :in key is specified with 3 values" do 275 | TestCLI.option(:inclusion, short: "-i val", in: %w{one two three}, description: "desc", required: false) 276 | @cli = TestCLI.new 277 | @cli.parse_options(["-i", "one"]) 278 | expect(@cli.options[:inclusion][:description]).to eql("desc (valid options: 'one', 'two', or 'three')") 279 | end 280 | 281 | it "doesn't exit if a required option is specified" do 282 | TestCLI.option(:require_me, short: "-r", boolean: true, required: true) 283 | @cli = TestCLI.new 284 | @cli.parse_options(["-r"]) 285 | expect(@cli.config[:require_me]).to be true 286 | end 287 | 288 | it "doesn't exit if a required boolean option is specified and false" do 289 | TestCLI.option(:require_me, long: "--[no-]req", boolean: true, required: true) 290 | @cli = TestCLI.new 291 | @cli.parse_options(["--no-req"]) 292 | expect(@cli.config[:require_me]).to be false 293 | end 294 | 295 | it "doesn't exit if a required option is specified and empty" do 296 | TestCLI.option(:require_me, short: "-r VALUE", required: true) 297 | @cli = TestCLI.new 298 | @cli.parse_options(["-r", ""]) 299 | expect(@cli.config[:require_me]).to eql("") 300 | end 301 | 302 | it "preserves all of the command line arguments, ARGV" do 303 | TestCLI.option(:config_file, short: "-c CONFIG") 304 | @cli = TestCLI.new 305 | argv_old = ARGV.dup 306 | ARGV.replace ["-c", "foo.rb"] 307 | @cli.parse_options 308 | expect(ARGV).to eql(["-c", "foo.rb"]) 309 | ARGV.replace argv_old 310 | end 311 | 312 | it "preserves and return any un-parsed elements" do 313 | TestCLI.option(:party, short: "-p LOCATION") 314 | @cli = TestCLI.new 315 | expect(@cli.parse_options([ "easy", "-p", "opscode", "hard" ])).to eql(%w{easy hard}) 316 | expect(@cli.cli_arguments).to eql(%w{easy hard}) 317 | end 318 | 319 | describe "with non-deprecated and deprecated options" do 320 | let(:cli) { TestCLI.new } 321 | before do 322 | TestCLI.option(:option_a, long: "--[no-]option-a", boolean: true) 323 | TestCLI.option(:option_b, short: "-b ARG", in: %w{a b c}) 324 | TestCLI.option(:option_c, short: "-c ARG") 325 | end 326 | 327 | context "when someone injects an unexpected value into 'config'" do 328 | before do 329 | cli.config[:surprise] = true 330 | end 331 | it "parses and preserves both known and unknown config values" do 332 | cli.parse_options(%w{--option-a}) 333 | expect(cli.config[:surprise]).to eql true 334 | expect(cli.config[:option_a]).to eql true 335 | end 336 | 337 | end 338 | 339 | context "when the deprecated option has a replacement" do 340 | 341 | context "and a value_mapper is provided" do 342 | before do 343 | TestCLI.deprecated_option(:option_x, 344 | long: "--option-x ARG", 345 | replacement: :option_b, 346 | value_mapper: Proc.new { |val| val == "valid" ? "a" : "xxx" } ) 347 | end 348 | 349 | it "still checks the replacement's 'in' validation list" do 350 | expect { cli.parse_options(%w{--option-x invalid}) }.to raise_error SystemExit 351 | end 352 | 353 | it "sets the mapped value in the replacement option and the deprecated value in the deprecated option" do 354 | cli.parse_options(%w{--option-x valid}) 355 | expect(cli.config[:option_x]).to eql("valid") 356 | expect(cli.config[:option_b]).to eql("a") 357 | end 358 | end 359 | 360 | context "and a value_mapper is not provided" do 361 | context "and keep is set to false in the deprecated option" do 362 | before do 363 | TestCLI.deprecated_option(:option_x, 364 | long: "--option-x ARG", 365 | replacement: :option_c, 366 | keep: false) 367 | end 368 | it "captures the replacement value, but does not set the deprecated value" do 369 | cli.parse_options %w{--option-x blah} 370 | expect(cli.config.key?(:option_x)).to eql false 371 | expect(cli.config[:option_c]).to eql "blah" 372 | end 373 | end 374 | 375 | context "and the replacement and deprecated are both boolean" do 376 | before do 377 | TestCLI.deprecated_option(:option_x, boolean: true, 378 | long: "--[no-]option-x", 379 | replacement: :option_a) 380 | end 381 | it "sets original and replacement to true when the deprecated flag is used" do 382 | cli.parse_options(%w{--option-x}) 383 | expect(cli.config[:option_x]).to eql true 384 | expect(cli.config[:option_a]).to eql true 385 | end 386 | it "sets the original and replacement to false when the negative deprecated flag is used" do 387 | cli.parse_options(%w{--no-option-x}) 388 | expect(cli.config[:option_x]).to eql false 389 | expect(cli.config[:option_a]).to eql false 390 | end 391 | end 392 | 393 | context "when the replacement does not accept a value" do 394 | before do 395 | TestCLI.deprecated_option(:option_x, 396 | long: "--option-x ARG", 397 | replacement: :option_c) 398 | end 399 | 400 | it "will still set the value because you haven't given a custom value mapper to set a true/false value" do 401 | cli.parse_options(%w{--option-x BLAH}) 402 | expect(cli.config[:option_c]).to eql("BLAH") 403 | end 404 | end 405 | end 406 | end 407 | 408 | context "when the deprecated option does not have a replacement" do 409 | before do 410 | TestCLI.deprecated_option(:option_x, short: "-x") 411 | end 412 | it "warns about the deprecated option being removed" do 413 | expect { TestCLI.new.parse_options(%w{-x}) }.to output(/removed in a future release/).to_stdout 414 | end 415 | end 416 | end 417 | end 418 | end 419 | 420 | context "when configured to separate default options" do 421 | before do 422 | TestCLI.use_separate_default_options true 423 | TestCLI.option(:defaulter, short: "-D SOMETHING", default: "this is the default") 424 | @cli = TestCLI.new 425 | end 426 | 427 | it "sets default values on the `default` hash" do 428 | @cli.parse_options([]) 429 | expect(@cli.default_config[:defaulter]).to eql("this is the default") 430 | expect(@cli.config[:defaulter]).to be_nil 431 | end 432 | 433 | it "sets parsed values on the `config` hash" do 434 | @cli.parse_options(%w{-D not-default}) 435 | expect(@cli.default_config[:defaulter]).to eql("this is the default") 436 | expect(@cli.config[:defaulter]).to eql("not-default") 437 | end 438 | 439 | end 440 | 441 | context "when subclassed" do 442 | before do 443 | TestCLI.options = { arg1: { boolean: true } } 444 | end 445 | 446 | it "retains previously defined options from parent" do 447 | class T1 < TestCLI 448 | option :arg2, boolean: true 449 | end 450 | expect(T1.options[:arg1]).to be_a(Hash) 451 | expect(T1.options[:arg2]).to be_a(Hash) 452 | expect(TestCLI.options[:arg2]).to be_nil 453 | end 454 | 455 | it "isn't able to modify parent classes options" do 456 | class T2 < TestCLI 457 | option :arg2, boolean: true 458 | end 459 | T2.options[:arg1][:boolean] = false 460 | expect(T2.options[:arg1][:boolean]).to be false 461 | expect(TestCLI.options[:arg1][:boolean]).to be true 462 | end 463 | 464 | it "passes its options onto child" do 465 | class T3 < TestCLI 466 | option :arg2, boolean: true 467 | end 468 | 469 | class T4 < T3 470 | option :arg3, boolean: true 471 | end 472 | 3.times do |i| 473 | expect(T4.options["arg#{i + 1}".to_sym]).to be_a(Hash) 474 | end 475 | end 476 | 477 | it "also works with an option that's an array" do 478 | class T5 < TestCLI 479 | option :arg2, default: [] 480 | end 481 | 482 | class T6 < T5 483 | end 484 | 485 | expect(T6.options[:arg2]).to be_a(Hash) 486 | end 487 | 488 | end 489 | 490 | end 491 | 492 | # option :config_file, 493 | # :short => "-c CONFIG", 494 | # :long => "--config CONFIG", 495 | # :default => 'config.rb', 496 | # :description => "The configuration file to use" 497 | # 498 | # option :log_level, 499 | # :short => "-l LEVEL", 500 | # :long => "--log_level LEVEL", 501 | # :description => "Set the log level (debug, info, warn, error, fatal)", 502 | # :required => true, 503 | # :proc => nil 504 | # 505 | # option :help, 506 | # :short => "-h", 507 | # :long => "--help", 508 | # :description => "Show this message", 509 | # :on => :tail, 510 | # :boolean => true, 511 | # :show_options => true, 512 | # :exit => 0 513 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $TESTING = true 2 | 3 | require "mixlib/cli" 4 | 5 | RSpec.configure do |config| 6 | # Use documentation format 7 | config.formatter = "doc" 8 | 9 | # Use color in STDOUT 10 | config.color = true 11 | 12 | # Use color not only in STDOUT but also in pagers and files 13 | config.tty = true 14 | 15 | # run the examples in random order 16 | config.order = :rand 17 | 18 | config.filter_run focus: true 19 | config.run_all_when_everything_filtered = true 20 | config.warnings = true 21 | 22 | config.before(:each) do 23 | # create a fresh TestCLI class on every example, so that examples never 24 | # pollute global variables and create ordering issues 25 | Object.send(:remove_const, "TestCLI") if Object.const_defined?("TestCLI") 26 | TestCLI = Class.new 27 | TestCLI.send(:include, Mixlib::CLI) 28 | end 29 | end 30 | --------------------------------------------------------------------------------