From b6277fc97847db500988274be9a680b4523cb661 Mon Sep 17 00:00:00 2001 From: Kristoffer Ahl Date: Fri, 10 Sep 2021 18:12:43 +0200 Subject: [PATCH] Adding support for required options. --- README.md | 51 +++++++++++----- cmd/centry/options.go | 26 +++++---- cmd/centry/runtime_test.go | 14 +++++ docs/index.md | 97 +++++++++++++++++++++---------- examples/centry/commands/get.sh | 7 +++ internal/pkg/cmd/option.go | 1 + internal/pkg/config/manifest.go | 1 + internal/pkg/config/schema.go | 8 +-- internal/pkg/shell/bash.go | 5 ++ schemas/manifest.json | 3 + test/data/commands/option_test.sh | 7 +++ 11 files changed, 159 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 8dacf24..84264a4 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,13 @@ Declarative **command line builder** for teams and one man bands ## Use cases + - Build a feature rich CLI from scratch using only scripts and yaml - Bundle existing scripts and tools as a single CLI - Encode best practices and team conventions for using scripts and dev/ops tools ## Feature highlights + - **Declarative**: Add new commands and options and run them at once. No need to re-compile. - **Unified syntax**: Provides a standard for using commands and options. - **Supports multi level commands**: `mycli get status` and `mycli get files` @@ -18,16 +20,19 @@ Declarative **command line builder** for teams and one man bands - **Easy setup**: Download centry, create a manifest file and you are good to go ## Full documentation + - v1 - [./docs/index.md](./docs/index.md) ## Install ### Mac + ```bash curl -L https://github.com/kristofferahl/go-centry/releases/download/v1.0.0/go-centry_1.0.0_Darwin_x86_64.tar.gz | tar -xzv -C /usr/local/bin/ ``` ### Linux + ```bash curl -L https://github.com/kristofferahl/go-centry/releases/download/v1.0.0/go-centry_1.0.0_Linux_x86_64.tar.gz | tar -xzv -C /usr/local/bin/ ``` @@ -35,34 +40,39 @@ curl -L https://github.com/kristofferahl/go-centry/releases/download/v1.0.0/go-c ## Getting started **The documentation and examples below assumes that** + 1. You are running `bash` version 3.2 or later 1. You have "installed" the `go-centry_*` binary for your OS and made it available in your path as `mycli` (by renaming the file) 1. You have created an empty directory to hold your commands and manifest file ## Setup + 1. Create the manifest file for the CLI and name it `centry.yaml` by running the following command in your shell. - ```bash - echo "commands: [] - config: - name: mycli" > centry.yaml - ``` + ```bash + echo "commands: [] + config: + name: mycli" > centry.yaml + ``` 2. Verify that it's working by running - ``` - mycli --help - ``` + ``` + mycli --help + ``` This should display the contextual help for the cli and the name **mycli** at the top. ## The manifest file + This is where you define root level commands and options, do configuration overrides and import scripts to be available for all your commands. By default, `centry` will look for a `centry.yaml` file in the **current directory**. You may change the location and name of the manifest file but this requires you to let centry know where to find it. This can be done by setting the environment variable `CENTRY_FILE` or by way of passing `--centry-file ` as the **first** argument. ## Commands + In `centry`, commands are simple shell scripts with a matching function name in it. Let's start by creating a file called `hello.sh` with the following content. -*`// file: hello.sh`* +_`// file: hello.sh`_ + ``` #!/usr/bin/env bash @@ -73,7 +83,8 @@ hello() { Before you can use the `hello` function as a command, you need to tell `centry` where to find it. Open `centry.yaml` in an editor of choise and modify it to look like this: -*`// file: centry.yaml`* +_`// file: centry.yaml`_ + ```yaml commands: - name: hello @@ -85,17 +96,20 @@ config: ``` You should now be able to able to run the command. + ```bash mycli hello ↵ Hello centry ``` ## Options + In `centry`, options are flags you use to pass named arguments to your command functions. This enables easier discovery of your cli and less friction for users. They may be specified in long (`--option`) or short (`-o`) form. Let's add a `--name` option to the hello command. This is done by adding `annotations` in your script. Edit `hello.sh` to look like this. -*`// file: hello.sh`* +_`// file: hello.sh`_ + ``` #!/usr/bin/env bash @@ -106,12 +120,14 @@ hello() { ``` Running the `hello` command again would look like this + ```bash mycli hello ↵ Hello ``` To pass a name to be echoed back to you, call the command with the `--name` option. + ```bash mycli hello --name William ↵ Hello William @@ -119,7 +135,8 @@ Hello William If you want to add a description for the `--name` option you should add an additional annotation to the `hello.sh` file. -*`// file: hello.sh`* +_`// file: hello.sh`_ + ``` #!/usr/bin/env bash @@ -129,7 +146,9 @@ hello() { echo "Hello ${NAME}" } ``` + Displaying the contextual help (using the `--help` option) should now look something like this. + ```bash mycli hello --help ↵ NAME: @@ -144,9 +163,11 @@ OPTIONS: ``` ## Arguments + A command may also accept any number of arguments. All arguments not matching an option of a command will be passed on to the function. -*`// file: hello.sh`* +_`// file: hello.sh`_ + ``` #!/usr/bin/env bash @@ -159,6 +180,7 @@ hello() { ``` NOTE: Arguments must always be passed after the last option. + ```bash mycli hello --name William arg1 arg2 ↵ Hello William @@ -170,14 +192,15 @@ Arguments (2): arg1 arg2 **NOTE: Only available for Bash** To make discovery of `mycli` easier, we may want to enable bash completions. Follow the steps below to set it up. + ```bash curl -o bash_autocomplete https://raw.githubusercontent.com/kristofferahl/go-centry/master/bash_autocomplete PROG=mycli source bash_autocomplete ``` Now, let try it out by typing `mycli` followed by a space and then hit `tab`. This will display any command available at the root level. If there is only one, the command name will be autocompleted. It works for options too. + ```bash mycli -- ➡ --centry-config-log-level --centry-quiet --help ``` - diff --git a/cmd/centry/options.go b/cmd/centry/options.go index cdb421d..22d92a6 100644 --- a/cmd/centry/options.go +++ b/cmd/centry/options.go @@ -72,6 +72,7 @@ func createGlobalOptions(runtime *Runtime) *cmd.OptionsSet { Description: o.Description, EnvName: o.EnvName, Default: def, + Required: o.Required, Hidden: o.Hidden, }) @@ -101,7 +102,8 @@ func optionsSetToFlags(options *cmd.OptionsSet) []cli.Flag { Aliases: short, Usage: o.Description, Value: def, - Hidden: o.Hidden, + // Required: o.Required, // NOTE: We currently do not support specifying select options as required + Hidden: o.Hidden, }) case cmd.BoolOption: def := false @@ -109,11 +111,12 @@ func optionsSetToFlags(options *cmd.OptionsSet) []cli.Flag { def = o.Default.(bool) } flags = append(flags, &cli.BoolFlag{ - Name: o.Name, - Aliases: short, - Usage: o.Description, - Value: def, - Hidden: o.Hidden, + Name: o.Name, + Aliases: short, + Usage: o.Description, + Value: def, + Required: o.Required, + Hidden: o.Hidden, }) case cmd.StringOption: def := "" @@ -121,11 +124,12 @@ func optionsSetToFlags(options *cmd.OptionsSet) []cli.Flag { def = o.Default.(string) } flags = append(flags, &cli.StringFlag{ - Name: o.Name, - Aliases: short, - Usage: o.Description, - Value: def, - Hidden: o.Hidden, + Name: o.Name, + Aliases: short, + Usage: o.Description, + Value: def, + Required: o.Required, + Hidden: o.Hidden, }) } } diff --git a/cmd/centry/runtime_test.go b/cmd/centry/runtime_test.go index d0a6218..e375ead 100644 --- a/cmd/centry/runtime_test.go +++ b/cmd/centry/runtime_test.go @@ -247,6 +247,20 @@ func TestMain(t *testing.T) { test.AssertNoError(g, out.Error) }) }) + + g.Describe("invoke without required option", func() { + g.It("should fail with error message", func() { + out := execCentry("optiontest required", false, "test/data/runtime_test.yaml") + test.AssertStringContains(g, out.Stderr, "level=error msg=\"Required flag \\\"abc\\\" not set\"") + }) + }) + + g.Describe("invoke with required option", func() { + g.It("should pass", func() { + out := execCentry("optiontest required --abc=foo", false, "test/data/runtime_test.yaml") + test.AssertStringHasKeyValue(g, out.Stdout, "ABC", "foo") + }) + }) }) g.Describe("global options", func() { diff --git a/docs/index.md b/docs/index.md index 711de5a..51b75ce 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,16 +32,18 @@ To define a command, two `properties` are required. The `name` is what you will Here's how you would define a root level command called `get`: -*`// file: centry.yaml`* +_`// file: centry.yaml`_ + ```yaml commands: - - name: get - path: ./get.sh + - name: get + path: ./get.sh ``` In the script file, create a function matching the `name` property. -*`// file: get.sh`* +_`// file: get.sh`_ + ```bash #!/usr/bin/env bash @@ -58,14 +60,16 @@ What strategy you choose is entirely up to you but the root level commands must Sub commands are exclusively defined in scripts. Creating a sub command is as easy as including the special character colon (`:`) in a script function name. Let's say you have already defined a root level command called `get` but wanted to define two commands that have `get` as their parent. Simply create two functions named `get:` and suffix it with the desired name of the sub command. -*`// file: centry.yaml`* +_`// file: centry.yaml`_ + ```yaml commands: - - name: get - path: ./get.sh + - name: get + path: ./get.sh ``` -*`// file: get.sh`* +_`// file: get.sh`_ + ```bash #!/usr/bin/env bash @@ -85,9 +89,10 @@ mycli get data mycli get time ``` -Adding annotations for sub commands works in the same way as for root level commands. Here's an example adding a description for the two commands created above. *Note that the full function name must be used in the annotation.* +Adding annotations for sub commands works in the same way as for root level commands. Here's an example adding a description for the two commands created above. _Note that the full function name must be used in the annotation._ + +_`// file: get.sh`_ -*`// file: get.sh`* ```bash #!/usr/bin/env bash @@ -105,36 +110,39 @@ get:time() { ### Command properties | Property | Description | YAML key | Type | Required | -|-------------|------------------------------------------------------|---------------|---------|----------| +| ----------- | ---------------------------------------------------- | ------------- | ------- | -------- | | Name | The name of the command | `name` | string | true | | Path | Relative path to the script containing the command | `path` | string | true | | Description | Description of the command, displayed in help output | `description` | string | false | | Help | Usage example for the command | `help` | string | false | | Hidden | When true, hides the command from help output | `hidden` | boolean | false | - ### Command annotations -Command annotations are used to associate metadata with a command. Annotations are defined using regular comments in bash (*a line starting with `#`*). They may be placed anywhere inside the script file and in any order you want. It is however recommended that you keep it close to your functions to act as documentation when changing your commands. +Command annotations are used to associate metadata with a command. Annotations are defined using regular comments in bash (_a line starting with `#`_). They may be placed anywhere inside the script file and in any order you want. It is however recommended that you keep it close to your functions to act as documentation when changing your commands. | Property | Format | -|-------------|-----------------------------------------------| +| ----------- | --------------------------------------------- | | Description | `# centry.cmd[]/description=` | | Help | `# centry.cmd[]/help=` | | Hidden | `# centry.cmd[]/hidden=` | ## Options (flags) + Options (aka flags) are used to pass named arguments to commands. When used, `centry` will export a variable for you with the value of the option set. ### Accessing option values + Option values are made available to your commands as environment variables. Given an option named `filter`, centry sets the environment variable `FILTER` to the value provided by the option or to it's default value. The environment variable name that is used for an option can be changed by setting the `EnvName` property (see Option properties). ### Global options + Global options are made available for all commands. They are often used to to provide context for the commands you are executing. Global options are defined in the `options` section of the manifest file (`centry.yaml`). To define a global option, two properties are required. The name of the option and it's type. In general you should only specify a global option if it makes sense in the context of all commands provided by your cli. Here's how you would define the global option `--verbose`: -*`// file: centry.yaml`* +_`// file: centry.yaml`_ + ```yaml options: - name: verbose @@ -150,11 +158,13 @@ mycli --verbose command2 ``` ### Command options + Command options are, as the name suggests, scoped to commands. Therefore there is no way to define these in the manifest file. Instead you will be using `annotations` to define command options. For a full list of available annotations, see Option annotations. Here's an example defining a `filter` option for the `get files` command: -*`// file: get.sh`* +_`// file: get.sh`_ + ```bash #!/usr/bin/env bash @@ -170,14 +180,17 @@ get:files() { ``` ### Option types + Options have a `type` property that defines it's behavior and possible values. The currently supported option types are: #### String option + String options are the most common type to use. It has a `name` and it's `type` set to `string`. In addition to the required properties, it is quite common to use the `default` property to set a default value for the option. See Option properties for the full list of available properties. **Example** -*`// file: centry.yaml`* +_`// file: centry.yaml`_ + ```yaml options: - name: filter @@ -188,11 +201,13 @@ options: **Usage**: `-- ` or `--=` #### Bool option + Boolean options can be used to provide a switch for behaviors in a command. As an example it could be used to turning debug logging on or off. A bool option have a value of `false` by default (this can be changed but it is not recommended). Using the default value of `false`, providing the option to your cli will tell centry to toggle that value to `true`. **Example** -*`// file: centry.yaml`* +_`// file: centry.yaml`_ + ```yaml options: - name: verbose @@ -200,7 +215,8 @@ options: description: Turn on verbose logging ``` -*`// file: get.sh`* +_`// file: get.sh`_ + ```bash #!/usr/bin/env bash @@ -217,11 +233,13 @@ get:data() { **Usage**: `--` or `--=` #### Select option + Select options are a bit different. It is commonly used to have the user select one value from an array of predefined values. The user selects a value by using the matching option. Let's dive into an example where we want the user to be able to select one of three AWS regions (eu-central-1, eu-west-1 and us-east-1). Here's how we would define that in our manifest. -*`// file: centry.yaml`* +_`// file: centry.yaml`_ + ```yaml options: - name: eu-central-1 @@ -240,7 +258,8 @@ options: On it's own, a select option provides no real value. The magic happens when we override the environment variable name that will have it's value set when a select option is provided. This essentially creates an array of valid values scoped to the specified environment variable name. -*`// file: get.sh`* +_`// file: get.sh`_ + ```bash #!/usr/bin/env bash @@ -258,13 +277,14 @@ mycli get lambdas --us-east-1 ``` **NOTE**: + - As no default value can be specified for select options, it's name is instead used as it's value. - If multiple select options with the same environment variable name is specified, the last one wins. ### Option properties | Property | Description | YAML | Type | Required | -|-------------|-----------------------------------------------------|---------------|---------------------------------|----------| +| ----------- | --------------------------------------------------- | ------------- | ------------------------------- | -------- | | Type | Type of option | `type` | OptionType (string/bool/select) | true | | Name | Name of the option | `name` | string | true | | Short | Short name of the option | `short` | string | false | @@ -272,19 +292,21 @@ mycli get lambdas --us-east-1 | Default | Default value of the option | `default` | string | false | | Description | Description of the option, displayed in help output | `description` | string | false | | Hidden | When true, hides the option from help output | `hidden` | boolean | false | +| Required | When true, marks the option as required | `required` | boolean | false | ### Option annotations Option annotations are used to define options for a command. Annotations are defined using regular comments in bash (a line starting with #). They may be placed anywhere inside the script file and in any order you want. It is however recommended that you keep it close to your functions to double as documentation for the command/option. | Property | Format | -|-------------|----------------------------------------------------------------| +| ----------- | -------------------------------------------------------------- | | Type | `# centry.cmd[].option[