milpaize the shit out of this before splitting stuff into its own package

This commit is contained in:
Roberto Hidalgo 2022-11-22 00:16:54 -06:00
parent 6e663eacc8
commit 01a85f6f02
32 changed files with 3836 additions and 378 deletions

View File

@ -1,18 +1,60 @@
# joao
# `joao`
a very wip configuration manager. keep config in the filesystem, back it up to 1password. Make it available to services via vault + 1password connect.
a very wip configuration manager. keep config as YAML in the filesystem, backs it up to 1password. Makes it available to services via 1Password Connect + vault.
## Why
So I wanted to operate on my configuration mess...
- With a workflow something like [SOPS](https://github.com/mozilla/sops)',
- but that talks UNIX like [go-config-yourself](https://github.com/unRob/go-config-yourself) (plus it's later `bash` + `jq` + `yq` [re-implementation](https://github.com/unRob/nidito/tree/0812e0caf6d81dd06b740701c3e95a2aeabd86de/.milpa/commands/nidito/config)'s multi-storage improvements),
- [git-crypt](https://github.com/AGWA/git-crypt)'s sweet git filters,
- compatibility with [1Password's neat ecosystem](https://developer.1password.com/), and finally
- a way to make it all available through Hashicorp's [Vault](https://vaultproject.io/) without touching git
So I set to write me, yet again, some configuration toolchain that:
- Allows the _structure_ of config trees to live happily in the filesystem: that is, I like to structure the configuration values I operate with as nested trees, and want a tool that understands these.
- Keeps secrets off remote repositories: I really dig `git-crypt`'s filters, not quite sure about how to safely operate them yet...
- Makes it easy to edit locally, as well as on web and native apps: I mean, it's YAML locally, and 1Password's tools are pretty great for quick edits.
- Operates on configuration trees, wether from a single file or a set of them, with ease: my home+cloud DC needs a lot of configuration that feels weird to keep in a single file; my one-off services don't really need the whole folder structure. I don't wanna use two tools.
- Is capable of bootstrapping other secret mangement processes: A single binary can talk to `op`'s CLI (hello, touch ID on macos!), to a 1password-connect server, and to vault as a plugin.
## Configuration
Schema for configuration and non-secret values live along the code, and are pushed to remote origins. Secrets can optionally and temporally be flushed to disk for editing or other sorts of operations. Git filters are available to prevent secrets from being pushed to remotes. Secrets are grouped into files, and every file gets its own 1Password item.
Secret values are specified using the `!!secret` YAML tag.
The ideal workflow is:
1. configs are written to disk, temporarily
2. `joao flush --redact`es them to 1password, and removes secrets from disk
3. configuration values, secret or not, are read from:
- `joao get` as needed by local processes. Mostly thinking of the human in the loop here, where `op` and suitable auth (i.e. touchid) workflows are available.
- from 1Password Connect, for when vault is not configured or available (think during provisioning)
- from Hashicorp Vault, for any automated process, after provisioning is complete.
---
`joao` operates on two modes, **repo** and **single-file**. Repo mode is useful when keeping all configurations in a single folder and expecting their filenames to map to their item names. Single-file mode is useful when a single file contains all of the desired configuration, and its 1Password details are better kept in that same file.
### Repo mode
Basically, configs are kept in a directory and their relative path maps to their 1Password item name. A `.joao.yaml` file must exist at the root configuration directory, specifying the 1Password vault to use, and optionally a prefix to prepend ot every item name
```yaml
# config/.joao.yaml
# the 1password vault to use as storage
vault: nidito
# the prefix to prepend to all configs from this directory
prefix: example
# think about single config or config in files
vault: infra
# the optional prefix to prepend to all configs from this directory
# without it, config/host/juazeiro.yaml turns into host:juazeiro
# with `bahianos` specified, name would be bahianos:host:juazeiro
# prefix: bahianos
```
```yaml
# config/host/juazeiro.yaml
# config/host/juazeiro.yaml => infra/host:juazeiro
address: 142.42.42.42
dc: bah0
mac: !!secret 00:11:22:33:44:55
@ -29,6 +71,25 @@ token:
bootstrap: !!secret 01234567-89ab-cdfe-0123-456789abcdef
```
### Single file mode
In single file mode, `joao` expects every file to have a `_joao: !!config` key with a vault name, and a name for the 1Password item.
```yaml
# src/git/config.yaml
_config: !!joao
vault: bahianos
name: service:git
smtp:
server: smtp.example.org
username: git@example.org
password: !!secret quatro-paredes
port: 587
```
## Usage
```sh
# NAME can be either a filesystem path or a colon delimited item name
# for example: config/host/juazeiro.yaml or host:juazeiro
@ -36,14 +97,23 @@ token:
# DOT_DELIMITED_PATH is
# for example: tls.cert, roles.0, dc
joao get NAME [--output|-o=(raw|json|yaml)] [--remote|--local] [jq expr]
joao set NAME [--from=/path/to/input] [--secret] [--flush] DOT_DELIMITED_PATH [<<<"value"]
joao flush NAME [--dry-run]
# get a single value/tree from a single item/file
joao get NAME [--output|-o=(raw|json|yaml|op)] [--remote] [jq expr]
# set/update a single value in a single item/file
joao set NAME DOT_DELIMITED_PATH [--secret] [--flush] [--input=/path/to/input|<<<"value"]
# sync local changes upstream
joao flush NAME [--dry-run] [--redact]
# sync remote secrets to filesystem
joao fetch NAME [--dry-run]
# PREFIX is a prefix to search for keys at, i.e. NAME[.DOT_DELIMITED_PATH]
joao list PREFIX
# check for differences between local and remote items
joao diff NAME [--cache]
joao repo init
# initialize a new joao repo
joao repo init [PATH]
# list the item names within prefix
joao repo list [PREFIX]
# print the repo config root
joao repo root
#
joao repo status
joao repo filter clean FILE
joao repo filter diff PATH OLD_FILE OLD_SHA OLD_MODE NEW_FILE NEW_SHA NEW_MODE

View File

@ -18,81 +18,133 @@ import (
"io/ioutil"
"strings"
opClient "git.rob.mx/nidito/joao/internal/op-client"
"git.rob.mx/nidito/joao/internal/command"
"git.rob.mx/nidito/joao/internal/registry"
"git.rob.mx/nidito/joao/pkg/config"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
func init() {
getCommand.Flags().StringP("output", "o", "raw", "the format to output in")
getCommand.Flags().Bool("remote", false, "query 1password instead of the filesystem")
getCommand.Flags().Bool("redacted", false, "do not print secrets")
registry.Register(gCommand)
}
var getCommand = &cobra.Command{
Use: "get CONFIG [--output|-o=(raw|json|yaml)] [--remote] [--redacted] [jq expr]",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
path := args[0]
query := ""
if len(args) > 1 {
query = args[1]
}
func keyFinder(cmd *command.Command, currentValue string) ([]string, cobra.ShellCompDirective, error) {
flag := cobra.ShellCompDirectiveError
file := cmd.Arguments[0].ToString()
buf, err := ioutil.ReadFile(file)
if err != nil {
return nil, flag, fmt.Errorf("could not read file %s", file)
}
keys, err := config.KeysFromYAML(buf)
if err != nil {
return nil, flag, err
}
return keys, cobra.ShellCompDirectiveDefault, nil
}
var gCommand = (&command.Command{
Path: []string{"get"},
Summary: "retrieves configuration",
Description: `
looks at the filesystem or remotely, using 1password (over the CLI if available, or 1password-connect, if configured).
## output formats
- **raw**:
- when querying for scalar values this will return a non-quoted version of the values
- when querying for trees or lists, this will output JSON
- **yaml**: formats the value at the given path as YAML
- **json**: formats the value at the given path as JSON
- **op**: formats the whole configuration as a 1Password item`,
Arguments: command.Arguments{
{
Name: "config",
Description: "The configuration to get from",
Required: true,
Values: &command.ValueSource{
Files: &[]string{"yaml", "yml"},
},
},
{
Name: "path",
Default: ".",
Description: "A dot-delimited path to extract from CONFIG",
Values: &command.ValueSource{
Func: func(cmd *command.Command, currentValue string) (values []string, flag cobra.ShellCompDirective, err error) {
opts := map[string]bool{".": true}
options, flag, err := keyFinder(cmd, currentValue)
for _, opt := range options {
parts := strings.Split(opt, ".")
sub := []string{parts[0]}
for idx, p := range parts {
key := strings.Join(sub, ".")
opts[key] = true
if idx > 0 && idx < len(parts)-1 {
sub = append(sub, p)
}
}
}
for k := range opts {
options = append(options, k)
}
return options, flag, err
},
},
},
},
Options: command.Options{
"output": {
ShortName: "o",
Description: "the format to use for rendering output",
Default: "raw",
Values: &command.ValueSource{
Static: &[]string{"raw", "json", "yaml", "op"},
},
},
"redacted": {
Description: "Do not print secret values",
Type: "bool",
},
"remote": {
Description: "Get values from 1password",
Type: "bool",
},
},
Action: func(cmd *command.Command) error {
path := cmd.Arguments[0].ToValue().(string)
query := cmd.Arguments[1].ToValue().(string)
var cfg *config.Config
remote, _ := cmd.Flags().GetBool("remote")
var err error
remote := cmd.Options["remote"].ToValue().(bool)
format := cmd.Options["output"].ToValue().(string)
redacted := cmd.Options["redacted"].ToValue().(bool)
isYaml := strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml")
if !remote && isYaml {
buf, err := ioutil.ReadFile(path)
if err != nil {
return fmt.Errorf("could not read file %s", path)
}
if len(buf) == 0 {
buf = []byte("{}")
}
cfg, err = config.ConfigFromYAML(buf)
if err != nil {
return err
}
} else {
name := path
if isYaml {
comps := strings.Split(path, "config/")
name = strings.ReplaceAll(strings.Replace(comps[len(comps)-1], ".yaml", "", 1), "/", ":")
}
item, err := opClient.Get("nidito-admin", name)
if err != nil {
return err
}
cfg, err = config.ConfigFromOP(item)
if err != nil {
return err
}
cfg, err = loadExisting(path, remote)
if err != nil {
return err
}
format, _ := cmd.Flags().GetString("output")
redacted, _ := cmd.Flags().GetBool("redacted")
if query == "" {
if query == "" || query == "." {
switch format {
case "yaml", "raw":
bytes, err := cfg.AsYAML(redacted)
if err != nil {
return err
}
_, err = cmd.OutOrStdout().Write(bytes)
_, err = cmd.Cobra.OutOrStdout().Write(bytes)
return err
case "json", "json-op":
bytes, err := cfg.AsJSON(redacted, format == "json-op")
case "json", "op":
bytes, err := cfg.AsJSON(redacted, format == "op")
if err != nil {
return err
}
_, err = cmd.OutOrStdout().Write(bytes)
_, err = cmd.Cobra.OutOrStdout().Write(bytes)
return err
}
return fmt.Errorf("unknown format %s", format)
@ -102,18 +154,17 @@ var getCommand = &cobra.Command{
entry := cfg.Tree
for _, part := range parts {
entry = entry.Children[part]
entry = entry.ChildNamed(part)
if entry == nil {
return fmt.Errorf("value not found at %s of %s", part, query)
}
}
var bytes []byte
var err error
if len(entry.Children) > 0 {
if len(entry.Content) > 0 {
val := entry.AsMap()
if format == "yaml" {
enc := yaml.NewEncoder(cmd.OutOrStdout())
enc := yaml.NewEncoder(cmd.Cobra.OutOrStdout())
enc.SetIndent(2)
return enc.Encode(val)
}
@ -123,17 +174,10 @@ var getCommand = &cobra.Command{
return err
}
} else {
if valString, ok := entry.Value.(string); ok {
bytes = []byte(valString)
} else {
bytes, err = json.Marshal(entry.Value)
if err != nil {
return err
}
}
bytes = []byte(entry.String())
}
_, err = cmd.OutOrStdout().Write(bytes)
_, err = cmd.Cobra.OutOrStdout().Write(bytes)
return err
},
}
}).SetBindings()

View File

@ -1,103 +0,0 @@
// Copyright © 2022 Roberto Hidalgo <joao@un.rob.mx>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cmd
import (
"fmt"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
var Root = &cobra.Command{
Use: "joao [--silent|-v|--verbose] [--[no-]color] [-h|--help] [--version]",
Short: "does config",
Long: `does config with 1password and stuff`,
// DisableAutoGenTag: true,
// SilenceUsage: true,
// SilenceErrors: true,
ValidArgs: []string{""},
Annotations: map[string]string{},
Args: func(cmd *cobra.Command, args []string) error {
err := cobra.OnlyValidArgs(cmd, args)
if err != nil {
suggestions := []string{}
bold := color.New(color.Bold)
for _, l := range cmd.SuggestionsFor(args[len(args)-1]) {
suggestions = append(suggestions, bold.Sprint(l))
}
errMessage := fmt.Sprintf("Unknown subcommand %s", bold.Sprint(strings.Join(args, " ")))
if len(suggestions) > 0 {
errMessage += ". Perhaps you meant " + strings.Join(suggestions, ", ") + "?"
}
return fmt.Errorf("command not found")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
if ok, err := cmd.Flags().GetBool("version"); err == nil && ok {
vc, _, err := cmd.Root().Find([]string{"__version"})
if err != nil {
return err
}
return vc.RunE(vc, []string{})
}
return fmt.Errorf("no command provided")
}
return nil
},
}
func RootCommand(version string) *cobra.Command {
Root.Annotations["version"] = version
rootFlagset := pflag.NewFlagSet("joao", pflag.ContinueOnError)
// for name, opt := range Root.Options {
// def, ok := opt.Default.(bool)
// if !ok {
// def = false
// }
// if opt.ShortName != "" {
// rootFlagset.BoolP(name, opt.ShortName, def, opt.Description)
// } else {
// rootFlagset.Bool(name, def, opt.Description)
// }
// }
rootFlagset.Usage = func() {}
rootFlagset.SortFlags = false
Root.PersistentFlags().AddFlagSet(rootFlagset)
Root.Flags().Bool("version", false, "Display the version")
// Root.CompletionOptions.DisableDefaultCmd = true
Root.AddCommand(getCommand)
// Root.AddCommand(completionCommand)
// Root.AddCommand(generateDocumentationCommand)
// Root.AddCommand(doctorCommand)
// Root.SetHelpCommand(helpCommand)
// helpCommand.AddCommand(docsCommand)
// docsCommand.SetHelpFunc(docs.HelpRenderer(Root.Options))
// Root.SetHelpFunc(Root.HelpRenderer(Root.Options))
return Root
}

View File

@ -11,3 +11,99 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package cmd
import (
"os"
"strings"
"git.rob.mx/nidito/joao/internal/command"
"git.rob.mx/nidito/joao/internal/registry"
"git.rob.mx/nidito/joao/pkg/config"
)
func init() {
registry.Register(setCommand)
}
var setCommand = (&command.Command{
Path: []string{"set"},
Summary: "updates configuration values",
Description: `
looks at the filesystem or remotely, using 1password (over the CLI if available, or 1password-connect, if configured).
Will read from stdin (or --from a file) and store it at the PATH
of CONFIG, optionally --flushing to 1Password.`,
Arguments: command.Arguments{
{
Name: "config",
Description: "The configuration file to modify",
Required: true,
Values: &command.ValueSource{
Files: &[]string{"yaml"},
},
},
{
Name: "path",
Required: true,
Description: "A dot-delimited path to set in CONFIG",
Values: &command.ValueSource{
SuggestRaw: true,
Suggestion: true,
Func: keyFinder,
},
},
},
Options: command.Options{
"input": {
ShortName: "i",
Description: "the file to read input from",
Default: "/dev/stdin",
Values: &command.ValueSource{
Files: &[]string{},
},
},
"secret": {
Description: "Store value as a secret string",
Type: "bool",
},
"json": {
Description: "Treat input as JSON-encoded",
Type: "bool",
},
},
Action: func(cmd *command.Command) error {
path := cmd.Arguments[0].ToValue().(string)
query := cmd.Arguments[1].ToValue().(string)
var cfg *config.Config
var err error
secret := cmd.Options["secret"].ToValue().(bool)
input := cmd.Options["input"].ToValue().(string)
parseJSON := cmd.Options["json"].ToValue().(bool)
cfg, err = loadExisting(path, false)
if err != nil {
return err
}
parts := strings.Split(query, ".")
valueBytes, err := os.ReadFile(input)
if err != nil {
return err
}
if err := cfg.Set(parts, valueBytes, secret, parseJSON); err != nil {
return err
}
// b, err := cfg.AsJSON(false, true)
b, err := cfg.AsYAML(false)
if err != nil {
return err
}
_, err = cmd.Cobra.OutOrStdout().Write(b)
return err
},
}).SetBindings()

72
cmd/util.go Normal file
View File

@ -0,0 +1,72 @@
// Copyright © 2022 Roberto Hidalgo <joao@un.rob.mx>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cmd
import (
"fmt"
"io/ioutil"
"strings"
opClient "git.rob.mx/nidito/joao/internal/op-client"
"git.rob.mx/nidito/joao/pkg/config"
)
func argIsYAMLFile(path string) bool {
return strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml")
}
func pathToName(path string) string {
comps := strings.Split(path, "config/")
return strings.ReplaceAll(strings.Replace(comps[len(comps)-1], ".yaml", "", 1), "/", ":")
}
func nameToPath(name string) string {
return "config/" + strings.ReplaceAll(name, ":", "/") + ".yaml"
}
func loadExisting(ref string, preferRemote bool) (*config.Config, error) {
isYaml := argIsYAMLFile(ref)
if preferRemote {
name := ref
if isYaml {
name = pathToName(ref)
}
item, err := opClient.Get("nidito-admin", name)
if err != nil {
return nil, err
}
return config.ConfigFromOP(item)
}
path := ref
var name string
if !isYaml {
path = nameToPath(ref)
name = ref
} else {
name = pathToName(ref)
}
buf, err := ioutil.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("could not read file %s", ref)
}
if len(buf) == 0 {
buf = []byte("{}")
}
return config.ConfigFromYAML(buf, name)
}

23
go.mod
View File

@ -4,21 +4,44 @@ go 1.18
require (
github.com/1Password/connect-sdk-go v1.5.0
github.com/charmbracelet/glamour v0.6.0
github.com/fatih/color v1.13.0
github.com/go-playground/validator/v10 v10.11.1
github.com/sirupsen/logrus v1.9.0
github.com/spf13/cobra v1.6.1
github.com/spf13/pflag v1.0.5
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/alecthomas/chroma v0.10.0 // indirect
github.com/aymanbagabas/go-osc52 v1.0.3 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/dlclark/regexp2 v1.4.0 // indirect
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.9 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/microcosm-cc/bluemonday v1.0.21 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.13.0 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/uber/jaeger-client-go v2.30.0+incompatible // indirect
github.com/uber/jaeger-lib v2.4.1+incompatible // indirect
github.com/yuin/goldmark v1.5.2 // indirect
github.com/yuin/goldmark-emoji v1.0.1 // indirect
go.uber.org/atomic v1.9.0 // indirect
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect
golang.org/x/net v0.0.0-20221002022538-bcab6841153b // indirect
golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 // indirect
golang.org/x/text v0.3.7 // indirect
)

77
go.sum
View File

@ -5,40 +5,86 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym
github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob0t8PQPMybUNFM=
github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo=
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg=
github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc=
github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-playground/validator/v10 v10.11.1 h1:prmOlTVv+YjZjmRmNSF3VmspqJIxJWXmqUsHwfTRRkQ=
github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg=
github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.13.0 h1:wK20DRpJdDX8b7Ek2QfhvqhRQFZ237RGRO0RQ/Iqdy0=
github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
@ -49,6 +95,7 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@ -56,10 +103,17 @@ github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaO
github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk=
github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg=
github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU=
github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@ -72,19 +126,34 @@ golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCc
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20221002022538-bcab6841153b h1:6e93nYa3hNqAvLr0pD4PN1fFS+gKzp2zAXqrnTCstqU=
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 h1:v6hYoSR9T5oet+pMXwUWkbiVqx/63mlHjefrHmxwfeY=
golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -95,9 +164,13 @@ gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0=
gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@ -0,0 +1,286 @@
// Copyright © 2022 Roberto Hidalgo <joao@un.rob.mx>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package command
import (
"fmt"
"strings"
"git.rob.mx/nidito/joao/internal/errors"
"github.com/spf13/cobra"
)
func contains(haystack []string, needle string) bool {
for _, validValue := range haystack {
if needle == validValue {
return true
}
}
return false
}
// Arguments is an ordered list of Argument.
type Arguments []*Argument
func (args *Arguments) AllKnown() map[string]any {
col := map[string]any{}
for _, arg := range *args {
col[arg.Name] = arg.ToValue()
}
return col
}
func (args *Arguments) AllKnownStr() map[string]string {
col := map[string]string{}
for _, arg := range *args {
col[arg.Name] = arg.ToString()
}
return col
}
func (args *Arguments) Parse(supplied []string) {
for idx, arg := range *args {
argumentProvided := idx < len(supplied)
if !argumentProvided {
if arg.Default != nil {
if arg.Variadic {
defaultSlice := []string{}
for _, valI := range arg.Default.([]any) {
defaultSlice = append(defaultSlice, valI.(string))
}
arg.provided = &defaultSlice
} else {
defaultString := arg.Default.(string)
if defaultString != "" {
arg.provided = &[]string{defaultString}
}
}
}
continue
}
if arg.Variadic {
values := append([]string{}, supplied[idx:]...)
arg.SetValue(values)
} else {
arg.SetValue([]string{supplied[idx]})
}
}
}
func (args *Arguments) AreValid() error {
for _, arg := range *args {
if err := arg.Validate(); err != nil {
return err
}
}
return nil
}
// CompletionFunction is called by cobra when asked to complete arguments.
func (args *Arguments) CompletionFunction(cc *cobra.Command, provided []string, toComplete string) ([]string, cobra.ShellCompDirective) {
expectedArgLen := len(*args)
values := []string{}
directive := cobra.ShellCompDirectiveError
if expectedArgLen > 0 {
argsCompleted := len(provided)
lastArg := (*args)[len(*args)-1]
hasVariadicArg := expectedArgLen > 0 && lastArg.Variadic
lastArg.Command.Options.Parse(cc.Flags())
args.Parse(provided)
directive = cobra.ShellCompDirectiveDefault
if argsCompleted < expectedArgLen || hasVariadicArg {
var arg *Argument
if hasVariadicArg && argsCompleted >= expectedArgLen {
// completing a variadic argument
arg = lastArg
} else {
// completing regular argument (maybe variadic!)
arg = (*args)[argsCompleted]
}
if arg.Values != nil {
var err error
arg.Values.command = lastArg.Command
arg.Command = lastArg.Command
values, directive, err = arg.Resolve(toComplete)
if err != nil {
return []string{err.Error()}, cobra.ShellCompDirectiveDefault
}
} else {
directive = cobra.ShellCompDirectiveError
}
values = cobra.AppendActiveHelp(values, arg.Description)
}
if toComplete != "" {
filtered := []string{}
for _, value := range values {
if strings.HasPrefix(value, toComplete) {
filtered = append(filtered, value)
}
}
values = filtered
}
}
return values, directive
}
// Argument represents a single command-line argument.
type Argument struct {
// Name is how this variable will be exposed to the underlying command.
Name string `json:"name" yaml:"name" validate:"required,excludesall=!$\\/%^@#?:'\""`
// Description is what this argument is for.
Description string `json:"description" yaml:"description" validate:"required"`
// Default is the default value for this argument if none is provided.
Default any `json:"default,omitempty" yaml:"default,omitempty" validate:"excluded_with=Required"`
// Variadic makes an argument a list of all values from this one on.
Variadic bool `json:"variadic" yaml:"variadic"`
// Required raises an error if an argument is not provided.
Required bool `json:"required" yaml:"required" validate:"excluded_with=Default"`
// Values describes autocompletion and validation for an argument
Values *ValueSource `json:"values,omitempty" yaml:"values" validate:"omitempty"`
Command *Command `json:"-" yaml:"-" validate:"-"`
provided *[]string
}
func (arg *Argument) EnvName() string {
return strings.ToUpper(strings.ReplaceAll(arg.Name, "-", "_"))
}
func (arg *Argument) SetValue(value []string) {
arg.provided = &value
}
func (arg *Argument) IsKnown() bool {
return arg.provided != nil && len(*arg.provided) > 0
}
func (arg *Argument) ToString() string {
val := arg.ToValue()
if val == nil {
return ""
}
if arg.Variadic {
val := val.([]string)
return strings.Join(val, "")
}
return val.(string)
}
func (arg *Argument) ToValue() any {
var value any
if arg.IsKnown() {
if arg.Variadic {
value = *arg.provided
} else {
vals := *arg.provided
value = vals[0]
}
} else {
if arg.Default != nil {
if arg.Variadic {
defaultSlice := []string{}
for _, valI := range arg.Default.([]any) {
valStr := valI.(string)
defaultSlice = append(defaultSlice, valStr)
}
value = defaultSlice
} else {
value = arg.Default.(string)
}
} else {
if arg.Variadic {
value = []string{}
} else {
value = ""
}
}
}
return value
}
func (arg *Argument) Validate() error {
if !arg.IsKnown() {
if arg.Required {
return errors.BadArguments{Msg: fmt.Sprintf("Missing argument for %s", strings.ToUpper(arg.Name))}
}
return nil
}
if !arg.Validates() {
return nil
}
validValues, _, err := arg.Resolve(strings.Join(*arg.provided, " "))
if err != nil {
return err
}
if arg.Variadic {
for _, current := range *arg.provided {
if !contains(validValues, current) {
return errors.BadArguments{Msg: fmt.Sprintf("%s is not a valid value for argument <%s>. Valid options are: %s", current, arg.Name, strings.Join(validValues, ", "))}
}
}
} else {
current := arg.ToValue().(string)
if !contains(validValues, current) {
return errors.BadArguments{Msg: fmt.Sprintf("%s is not a valid value for argument <%s>. Valid options are: %s", current, arg.Name, strings.Join(validValues, ", "))}
}
}
return nil
}
// Validates tells if the user-supplied value needs validation.
func (arg *Argument) Validates() bool {
return arg.Values != nil && arg.Values.Validates()
}
// ToDesc prints out the description of an argument for help and docs.
func (arg *Argument) ToDesc() string {
spec := arg.EnvName()
if arg.Variadic {
spec = fmt.Sprintf("%s...", spec)
}
if !arg.Required {
spec = fmt.Sprintf("[%s]", spec)
}
return spec
}
// Resolve returns autocomplete values for an argument.
func (arg *Argument) Resolve(current string) (values []string, flag cobra.ShellCompDirective, err error) {
if arg.Values != nil {
values, flag, err = arg.Values.Resolve(current)
if err != nil {
flag = cobra.ShellCompDirectiveError
return
}
}
return
}

View File

@ -0,0 +1,422 @@
// Copyright © 2022 Roberto Hidalgo <joao@un.rob.mx>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package command_test
import (
"strings"
"testing"
. "git.rob.mx/nidito/joao/internal/command"
)
func testCommand() *Command {
return (&Command{
Arguments: []*Argument{
{
Name: "first",
Default: "default",
},
{
Name: "variadic",
Default: []any{"defaultVariadic0", "defaultVariadic1"},
Variadic: true,
},
},
Options: Options{
"option": {
Default: "default",
Type: "string",
},
"bool": {
Type: "bool",
Default: false,
},
},
}).SetBindings()
}
func TestParse(t *testing.T) {
cmd := testCommand()
cmd.Arguments.Parse([]string{"asdf", "one", "two", "three"})
known := cmd.Arguments.AllKnown()
if !cmd.Arguments[0].IsKnown() {
t.Fatalf("first argument isn't known")
}
val, exists := known["first"]
if !exists {
t.Fatalf("first argument isn't on AllKnown map: %v", known)
}
if val != "asdf" {
t.Fatalf("first argument does not match. expected: %s, got %s", "asdf", val)
}
if !cmd.Arguments[1].IsKnown() {
t.Fatalf("variadic argument isn't known")
}
val, exists = known["variadic"]
if !exists {
t.Fatalf("variadic argument isn't on AllKnown map: %v", known)
}
if val != "one two three" {
t.Fatalf("Known argument does not match. expected: %s, got %s", "one two three", val)
}
cmd = testCommand()
cmd.Arguments.Parse([]string{"asdf"})
known = cmd.Arguments.AllKnown()
if !cmd.Arguments[0].IsKnown() {
t.Fatalf("first argument is not known")
}
val, exists = known["first"]
if !exists {
t.Fatalf("first argument isn't on AllKnown map: %v", known)
}
if val != "asdf" {
t.Fatalf("first argument does not match. expected: %s, got %s", "asdf", val)
}
val, exists = known["variadic"]
if !exists {
t.Fatalf("variadic argument isn't on AllKnown map: %v", known)
}
if val != "defaultVariadic0 defaultVariadic1" {
t.Fatalf("variadic argument does not match. expected: %s, got %s", "defaultVariadic0 defaultVariadic1", val)
}
}
func TestBeforeParse(t *testing.T) {
cmd := testCommand()
known := cmd.Arguments.AllKnown()
if cmd.Arguments[0].IsKnown() {
t.Fatalf("first argument is known")
}
val, exists := known["first"]
if !exists {
t.Fatalf("first argument isn't on AllKnown map: %v", known)
}
if val != "default" {
t.Fatalf("first argument does not match. expected: %s, got %s", "asdf", val)
}
val, exists = known["variadic"]
if !exists {
t.Fatalf("variadic argument isn't on AllKnown map: %v", known)
}
if val != "defaultVariadic0 defaultVariadic1" {
t.Fatalf("variadic argument does not match. expected: %s, got %s", "defaultVariadic0 defaultVariadic1", val)
}
}
func TestArgumentsValidate(t *testing.T) {
staticArgument := func(name string, def string, values []string, variadic bool) *Argument {
return &Argument{
Name: name,
Default: def,
Variadic: variadic,
Required: def == "",
Values: &ValueSource{
Static: &values,
},
}
}
cases := []struct {
Command *Command
Args []string
ErrorSuffix string
Env []string
}{
{
Command: (&Command{
// Name: []string{"test", "required", "failure"},
Arguments: []*Argument{
{
Name: "first",
Required: true,
},
},
}).SetBindings(),
ErrorSuffix: "Missing argument for FIRST",
},
{
Args: []string{"bad"},
ErrorSuffix: "bad is not a valid value for argument <first>. Valid options are: good, default",
Command: (&Command{
// Name: []string{"test", "script", "bad"},
Arguments: []*Argument{
{
Name: "first",
Default: "default",
Values: &ValueSource{
Script: "echo good; echo default",
},
},
},
}).SetBindings(),
},
{
Args: []string{"bad"},
ErrorSuffix: "bad is not a valid value for argument <first>. Valid options are: default, good",
Command: (&Command{
// Name: []string{"test", "static", "errors"},
Arguments: []*Argument{staticArgument("first", "default", []string{"default", "good"}, false)},
}).SetBindings(),
},
{
Args: []string{"default", "good", "bad"},
ErrorSuffix: "bad is not a valid value for argument <first>. Valid options are: default, good",
Command: (&Command{
// Name: []string{"test", "static", "errors"},
Arguments: []*Argument{staticArgument("first", "default", []string{"default", "good"}, true)},
}).SetBindings(),
},
{
Args: []string{"good"},
ErrorSuffix: "could not validate argument for command test script bad-exit, ran",
Command: (&Command{
// Name: []string{"test", "script", "bad-exit"},
Arguments: []*Argument{
{
Name: "first",
Default: "default",
Values: &ValueSource{
Script: "echo good; echo default; exit 2",
},
},
},
}).SetBindings(),
},
}
t.Run("good command is good", func(t *testing.T) {
cmd := testCommand()
cmd.Arguments[0] = staticArgument("first", "default", []string{"default", "good"}, false)
cmd.Arguments[1] = staticArgument("second", "", []string{"one", "two", "three"}, true)
cmd.SetBindings()
cmd.Arguments.Parse([]string{"first", "one", "three", "two"})
err := cmd.Arguments.AreValid()
if err == nil {
t.Fatalf("Unexpected failure validating: %s", err)
}
})
for _, c := range cases {
t.Run(c.Command.FullName(), func(t *testing.T) {
c.Command.Arguments.Parse(c.Args)
err := c.Command.Arguments.AreValid()
if err == nil {
t.Fatalf("Expected failure but got none")
}
if !strings.HasPrefix(err.Error(), c.ErrorSuffix) {
t.Fatalf("Could not find error <%s> got <%s>", c.ErrorSuffix, err)
}
})
}
}
// func TestArgumentsToEnv(t *testing.T) {
// cases := []struct {
// Command *Command
// Args []string
// Expect []string
// Env []string
// }{
// {
// Args: []string{"something"},
// Expect: []string{"export MILPA_ARG_FIRST=something"},
// Command: &Command{
// // Name: []string{"test", "required", "present"},
// Arguments: []*Argument{
// {
// Name: "first",
// Required: true,
// },
// },
// },
// },
// {
// Args: []string{},
// Expect: []string{"export MILPA_ARG_FIRST=default"},
// Command: &Command{
// // Name: []string{"test", "default", "present"},
// Arguments: []*Argument{
// {
// Name: "first",
// Default: "default",
// },
// },
// },
// },
// {
// Args: []string{"zero", "one", "two", "three"},
// Expect: []string{
// "export MILPA_ARG_FIRST=zero",
// "declare -a MILPA_ARG_VARIADIC='( one two three )'",
// },
// Command: &Command{
// // Name: []string{"test", "variadic"},
// Arguments: []*Argument{
// {
// Name: "first",
// Default: "default",
// },
// {
// Name: "variadic",
// Variadic: true,
// },
// },
// },
// },
// {
// Args: []string{},
// Expect: []string{"export MILPA_ARG_FIRST=default"},
// Command: &Command{
// // Name: []string{"test", "static", "default"},
// Arguments: []*Argument{
// {
// Name: "first",
// Default: "default",
// Values: &ValueSource{
// Static: &[]string{
// "default",
// "good",
// },
// },
// },
// },
// },
// },
// {
// Args: []string{"good"},
// Expect: []string{"export MILPA_ARG_FIRST=good"},
// Command: &Command{
// // Name: []string{"test", "static", "good"},
// Arguments: []*Argument{
// {
// Name: "first",
// Default: "default",
// Values: &ValueSource{
// Static: &[]string{
// "default",
// "good",
// },
// },
// },
// },
// },
// },
// {
// Args: []string{"good"},
// Expect: []string{"export MILPA_ARG_FIRST=good"},
// Command: &Command{
// // Name: []string{"test", "script", "good"},
// Arguments: []*Argument{
// {
// Name: "first",
// Default: "default",
// Values: &ValueSource{
// Script: "echo good; echo default",
// },
// },
// },
// },
// },
// }
// for _, c := range cases {
// t.Run(c.Command.FullName(), func(t *testing.T) {
// dst := []string{}
// c.Command.SetBindings()
// c.Command.Arguments.Parse(c.Args)
// c.Command.Arguments.ToEnv(c.Command, &dst, "export ")
// err := c.Command.Arguments.AreValid()
// if err != nil {
// t.Fatalf("Unexpected failure validating: %s", err)
// }
// for _, expected := range c.Expect {
// found := false
// for _, actual := range dst {
// if strings.HasPrefix(actual, expected) {
// found = true
// break
// }
// }
// if !found {
// t.Fatalf("Expected line %v not found in %v", expected, dst)
// }
// }
// })
// }
// }
func TestArgumentToDesc(t *testing.T) {
cases := []struct {
Arg *Argument
Spec string
}{
{
Arg: &Argument{
Name: "regular",
},
Spec: "[REGULAR]",
},
{
Arg: &Argument{
Name: "required",
Required: true,
},
Spec: "REQUIRED",
},
{
Arg: &Argument{
Name: "variadic-regular",
Variadic: true,
},
Spec: "[VARIADIC_REGULAR...]",
},
{
Arg: &Argument{
Name: "variadic-required",
Variadic: true,
Required: true,
},
Spec: "VARIADIC_REQUIRED...",
},
}
for _, c := range cases {
t.Run(c.Arg.Name, func(t *testing.T) {
res := c.Arg.ToDesc()
if res != c.Spec {
t.Fatalf("Expected %s got %s", c.Spec, res)
}
})
}
}

134
internal/command/command.go Normal file
View File

@ -0,0 +1,134 @@
// Copyright © 2022 Roberto Hidalgo <joao@un.rob.mx>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package command
import (
"fmt"
"strings"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
type CommandHelpFunc func(printLinks bool) string
type CommandAction func(cmd *Command) error
type Command struct {
Path []string
// Summary is a short description of a command, on supported shells this is part of the autocomplete prompt
Summary string `json:"summary" yaml:"summary" validate:"required"`
// Description is a long form explanation of how a command works its magic. Markdown is supported
Description string `json:"description" yaml:"description" validate:"required"`
// A list of arguments for a command
Arguments Arguments `json:"arguments" yaml:"arguments" validate:"dive"`
// A map of option names to option definitions
Options Options `json:"options" yaml:"options" validate:"dive"`
HelpFunc CommandHelpFunc `json:"-" yaml:"-"`
// The action to take upon running
Action CommandAction
runtimeFlags *pflag.FlagSet
Cobra *cobra.Command
}
func (cmd *Command) SetBindings() *Command {
ptr := cmd
for _, opt := range cmd.Options {
opt.Command = ptr
if opt.Validates() {
opt.Values.command = ptr
}
}
for _, arg := range cmd.Arguments {
arg.Command = ptr
if arg.Validates() {
arg.Values.command = ptr
}
}
return ptr
}
func (cmd *Command) Name() string {
return cmd.Path[len(cmd.Path)-1]
}
func (cmd *Command) FullName() string {
return strings.Join(cmd.Path, " ")
}
func (cmd *Command) FlagSet() *pflag.FlagSet {
if cmd.runtimeFlags == nil {
fs := pflag.NewFlagSet(strings.Join(cmd.Path, " "), pflag.ContinueOnError)
fs.SortFlags = false
fs.Usage = func() {}
for name, opt := range cmd.Options {
switch opt.Type {
case ValueTypeBoolean:
def := false
if opt.Default != nil {
def = opt.Default.(bool)
}
fs.Bool(name, def, opt.Description)
case ValueTypeDefault, ValueTypeString:
opt.Type = ValueTypeString
def := ""
if opt.Default != nil {
def = fmt.Sprintf("%s", opt.Default)
}
fs.String(name, def, opt.Description)
default:
// ignore flag
logrus.Warnf("Ignoring unknown option type <%s> for option <%s>", opt.Type, name)
continue
}
}
cmd.runtimeFlags = fs
}
return cmd.runtimeFlags
}
func (cmd *Command) ParseInput(cc *cobra.Command, args []string) error {
cmd.Arguments.Parse(args)
skipValidation, _ := cc.Flags().GetBool("skip-validation")
cmd.Options.Parse(cc.Flags())
if !skipValidation {
logrus.Debug("Validating arguments")
if err := cmd.Arguments.AreValid(); err != nil {
return err
}
logrus.Debug("Validating flags")
if err := cmd.Options.AreValid(); err != nil {
return err
}
}
return nil
}
func (cmd *Command) Run(cc *cobra.Command, args []string) error {
logrus.Debugf("running command %s", cmd.FullName())
if err := cmd.ParseInput(cc, args); err != nil {
return err
}
return cmd.Action(cmd)
}
func (cmd *Command) SetCobra(cc *cobra.Command) {
cmd.Cobra = cc
}

88
internal/command/help.go Normal file
View File

@ -0,0 +1,88 @@
// Copyright © 2022 Roberto Hidalgo <joao@un.rob.mx>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package command
import (
"bytes"
_c "git.rob.mx/nidito/joao/internal/constants"
"git.rob.mx/nidito/joao/internal/render"
"git.rob.mx/nidito/joao/internal/runtime"
"github.com/spf13/cobra"
)
type combinedCommand struct {
Spec *Command
Command *cobra.Command
GlobalOptions Options
HTMLOutput bool
}
func (cmd *Command) HasAdditionalHelp() bool {
return cmd.HelpFunc != nil
}
func (cmd *Command) AdditionalHelp(printLinks bool) *string {
if cmd.HelpFunc != nil {
str := cmd.HelpFunc(printLinks)
return &str
}
return nil
}
func (cmd *Command) HelpRenderer(globalOptions Options) func(cc *cobra.Command, args []string) {
return func(cc *cobra.Command, args []string) {
// some commands don't have a binding until help is rendered
// like virtual ones (sub command groups)
cmd.SetCobra(cc)
content, err := cmd.ShowHelp(globalOptions, args)
if err != nil {
panic(err)
}
_, err = cc.OutOrStderr().Write(content)
if err != nil {
panic(err)
}
}
}
func (cmd *Command) ShowHelp(globalOptions Options, args []string) ([]byte, error) {
var buf bytes.Buffer
c := &combinedCommand{
Spec: cmd,
Command: cmd.Cobra,
GlobalOptions: globalOptions,
HTMLOutput: runtime.UnstyledHelpEnabled(),
}
err := _c.TemplateCommandHelp.Execute(&buf, c)
if err != nil {
return nil, err
}
colorEnabled := runtime.ColorEnabled()
flags := cmd.Cobra.Flags()
ncf := cmd.Cobra.Flag("no-color") // nolint:ifshort
cf := cmd.Cobra.Flag("color") // nolint:ifshort
if noColorFlag, err := flags.GetBool("no-color"); err == nil && ncf.Changed {
colorEnabled = !noColorFlag
} else if colorFlag, err := flags.GetBool("color"); err == nil && cf.Changed {
colorEnabled = colorFlag
}
content, err := render.Markdown(buf.Bytes(), colorEnabled)
if err != nil {
return nil, err
}
return content, nil
}

234
internal/command/options.go Normal file
View File

@ -0,0 +1,234 @@
// Copyright © 2022 Roberto Hidalgo <joao@un.rob.mx>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package command
import (
"fmt"
"strconv"
"strings"
"git.rob.mx/nidito/joao/internal/errors"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// Options is a map of name to Option.
type Options map[string]*Option
func (opts *Options) AllKnown() map[string]any {
col := map[string]any{}
for name, opt := range *opts {
col[name] = opt.ToValue()
}
return col
}
func (opts *Options) AllKnownStr() map[string]string {
col := map[string]string{}
for name, opt := range *opts {
col[name] = opt.ToString()
}
return col
}
// func envValue(opts Options, f *pflag.Flag) (*string, *string) {
// name := f.Name
// if name == _c.HelpCommandName {
// return nil, nil
// }
// envName := ""
// value := f.Value.String()
// if cname, ok := _c.EnvFlagNames[name]; ok {
// if value == "false" {
// return nil, nil
// }
// envName = cname
// } else {
// envName = fmt.Sprintf("%s%s", _c.OutputPrefixOpt, strings.ToUpper(strings.ReplaceAll(name, "-", "_")))
// opt := opts[name]
// if opt != nil {
// value = opt.ToString(true)
// }
// if value == "false" && opt.Type == ValueTypeBoolean {
// // makes dealing with false flags in shell easier
// value = ""
// }
// }
// return &envName, &value
// }
// // ToEnv writes shell variables to dst.
// func (opts *Options) ToEnv(command *Command, dst *[]string, prefix string) {
// command.cc.Flags().VisitAll(func(f *pflag.Flag) {
// envName, value := envValue(*opts, f)
// if envName != nil && value != nil {
// *dst = append(*dst, fmt.Sprintf("%s%s=%s", prefix, *envName, *value))
// }
// })
// }
// func (opts *Options) EnvMap(command *Command, dst *map[string]string) {
// command.cc.Flags().VisitAll(func(f *pflag.Flag) {
// envName, value := envValue(*opts, f)
// if envName != nil && value != nil {
// (*dst)[*envName] = *value
// }
// })
// }
func (opts *Options) Parse(supplied *pflag.FlagSet) {
// logrus.Debugf("Parsing supplied flags, %v", supplied)
for name, opt := range *opts {
switch opt.Type {
case ValueTypeBoolean:
if val, err := supplied.GetBool(name); err == nil {
opt.provided = val
continue
}
default:
opt.Type = ValueTypeString
if val, err := supplied.GetString(name); err == nil {
opt.provided = val
continue
}
}
}
}
func (opts *Options) AreValid() error {
for name, opt := range *opts {
if err := opt.Validate(name); err != nil {
return err
}
}
return nil
}
// Option represents a command line flag.
type Option struct {
ShortName string `json:"short-name,omitempty" yaml:"short-name,omitempty"` // nolint:tagliatelle
Type ValueType `json:"type" yaml:"type" validate:"omitempty,oneof=string bool"`
Description string `json:"description" yaml:"description" validate:"required"`
Default any `json:"default,omitempty" yaml:"default,omitempty"`
Values *ValueSource `json:"values,omitempty" yaml:"values,omitempty" validate:"omitempty"`
Repeated bool `json:"repeated" yaml:"repeated" validate:"omitempty"`
Command *Command `json:"-" yaml:"-" validate:"-"`
provided any
}
func (opt *Option) IsKnown() bool {
return opt.provided != nil
}
func (opt *Option) ToValue() any {
if opt.IsKnown() {
return opt.provided
}
return opt.Default
}
func (opt *Option) ToString() string {
value := opt.ToValue()
stringValue := ""
if opt.Type == "bool" {
if value == nil {
stringValue = ""
} else {
stringValue = strconv.FormatBool(value.(bool))
}
} else {
if value != nil {
stringValue = value.(string)
}
}
return stringValue
}
func (opt *Option) Validate(name string) error {
if !opt.Validates() {
return nil
}
current := opt.ToString() // nolint:ifshort
if current == "" {
return nil
}
validValues, _, err := opt.Resolve(current)
if err != nil {
return err
}
if !contains(validValues, current) {
return errors.BadArguments{Msg: fmt.Sprintf("%s is not a valid value for option <%s>. Valid options are: %s", current, name, strings.Join(validValues, ", "))}
}
return nil
}
// Validates tells if the user-supplied value needs validation.
func (opt *Option) Validates() bool {
return opt.Values != nil && opt.Values.Validates()
}
// providesAutocomplete tells if this option provides autocomplete values.
func (opt *Option) providesAutocomplete() bool {
return opt.Values != nil
}
// Resolve returns autocomplete values for an option.
func (opt *Option) Resolve(currentValue string) (values []string, flag cobra.ShellCompDirective, err error) {
if opt.Values != nil {
if opt.Values.command == nil {
opt.Values.command = opt.Command
}
return opt.Values.Resolve(currentValue)
}
return
}
// CompletionFunction is called by cobra when asked to complete an option.
func (opt *Option) CompletionFunction(cmd *cobra.Command, args []string, toComplete string) (values []string, flag cobra.ShellCompDirective) {
if !opt.providesAutocomplete() {
flag = cobra.ShellCompDirectiveNoFileComp
return
}
opt.Command.Arguments.Parse(args)
opt.Command.Options.Parse(cmd.Flags())
var err error
values, flag, err = opt.Resolve(toComplete)
if err != nil {
return values, cobra.ShellCompDirectiveError
}
if toComplete != "" {
filtered := []string{}
for _, value := range values {
if strings.HasPrefix(value, toComplete) {
filtered = append(filtered, value)
}
}
values = filtered
}
return cobra.AppendActiveHelp(values, opt.Description), flag
}

43
internal/command/root.go Normal file
View File

@ -0,0 +1,43 @@
package command
import (
_c "git.rob.mx/nidito/joao/internal/constants"
"git.rob.mx/nidito/joao/internal/runtime"
)
var Root = &Command{
Summary: "Helps organize config for roberto",
Description: `﹅joao﹅ makes yaml, json, 1password and vault play along nicely.`,
Path: []string{"joao"},
Options: Options{
_c.HelpCommandName: &Option{
ShortName: "h",
Type: "bool",
Description: "Display help for any command",
},
"verbose": &Option{
ShortName: "v",
Type: "bool",
Default: runtime.VerboseEnabled(),
Description: "Log verbose output to stderr",
},
"no-color": &Option{
Type: "bool",
Description: "Disable printing of colors to stderr",
Default: !runtime.ColorEnabled(),
},
"color": &Option{
Type: "bool",
Description: "Always print colors to stderr",
Default: runtime.ColorEnabled(),
},
"silent": &Option{
Type: "bool",
Description: "Silence non-error logging",
},
"skip-validation": &Option{
Type: "bool",
Description: "Do not validate any arguments or options",
},
},
}

View File

@ -0,0 +1,54 @@
// Copyright © 2022 Roberto Hidalgo <joao@un.rob.mx>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package command
import (
"fmt"
"strings"
"github.com/go-playground/validator/v10"
)
type varSearchMap struct {
Status int
Name string
Usage string
}
func (cmd *Command) Validate() (report map[string]int) {
report = map[string]int{}
validate := validator.New()
if err := validate.Struct(cmd); err != nil {
verrs := err.(validator.ValidationErrors)
for _, issue := range verrs {
// todo: output better errors, see validator.FieldError
report[fmt.Sprint(issue)] = 1
}
}
vars := map[string]map[string]*varSearchMap{
"argument": {},
"option": {},
}
for _, arg := range cmd.Arguments {
vars["argument"][strings.ToUpper(strings.ReplaceAll(arg.Name, "-", "_"))] = &varSearchMap{2, arg.Name, ""}
}
for name := range cmd.Options {
vars["option"][strings.ToUpper(strings.ReplaceAll(name, "-", "_"))] = &varSearchMap{2, name, ""}
}
return report
}

235
internal/command/value.go Normal file
View File

@ -0,0 +1,235 @@
// Copyright © 2022 Roberto Hidalgo <joao@un.rob.mx>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package command
import (
"bytes"
"context"
"fmt"
"os"
"strings"
"text/template"
"time"
_c "git.rob.mx/nidito/joao/internal/constants"
"git.rob.mx/nidito/joao/internal/exec"
"github.com/spf13/cobra"
)
// ValueType represent the kinds of or option.
type ValueType string
const (
// ValueTypeDefault is the empty string, maps to ValueTypeString.
ValueTypeDefault ValueType = ""
// ValueTypeString a value treated like a string.
ValueTypeString ValueType = "string"
// ValueTypeBoolean is a value treated like a boolean.
ValueTypeBoolean ValueType = "bool"
)
type SourceCommand struct {
Path []string
Args string
}
type CompletionFunc func(cmd *Command, currentValue string) (values []string, flag cobra.ShellCompDirective, err error)
// ValueSource represents the source for an auto-completed and/or validated option/argument.
type ValueSource struct {
// Directories prompts for directories with the given prefix.
Directories *string `json:"dirs,omitempty" yaml:"dirs,omitempty" validate:"omitempty,excluded_with=Command Files Func Script Static"`
// Files prompts for files with the given extensions
Files *[]string `json:"files,omitempty" yaml:"files,omitempty" validate:"omitempty,excluded_with=Command Func Directories Script Static"`
// Script runs the provided command with `bash -c "$script"` and returns an option for every line of stdout.
Script string `json:"script,omitempty" yaml:"script,omitempty" validate:"omitempty,excluded_with=Command Directories Files Func Static"`
// Static returns the given list.
Static *[]string `json:"static,omitempty" yaml:"static,omitempty" validate:"omitempty,excluded_with=Command Directories Files Func Script"`
// Command runs a subcommand and returns an option for every line of stdout.
Command *SourceCommand `json:"command,omitempty" yaml:"command,omitempty" validate:"omitempty,excluded_with=Directories Files Func Script Static"`
// Func runs a function
Func CompletionFunc `json:"func,omitempty" yaml:"func,omitempty" validate:"omitempty,excluded_with=Command Directories Files Script Static"`
// Timeout is the maximum amount of time we will wait for a Script, Command, or Func before giving up on completions/validations.
Timeout int `json:"timeout,omitempty" yaml:"timeout,omitempty" validate:"omitempty,excluded_with=Directories Files Static"`
// Suggestion if provided will only suggest autocomplete values but will not perform validation of a given value
Suggestion bool `json:"suggest-only" yaml:"suggest-only" validate:"omitempty"` // nolint:tagliatelle
// SuggestRaw if provided the shell will not add a space after autocompleting
SuggestRaw bool `json:"suggest-raw" yaml:"suggest-raw" validate:"omitempty"` // nolint:tagliatelle
command *Command `json:"-" yaml:"-" validate:"-"`
computed *[]string
flag cobra.ShellCompDirective
}
// Validates tells if a value needs to be validated.
func (vs *ValueSource) Validates() bool {
if vs.Directories != nil || vs.Files != nil {
return false
}
return !vs.Suggestion
}
// Resolve returns the values for autocomplete and validation.
func (vs *ValueSource) Resolve(currentValue string) (values []string, flag cobra.ShellCompDirective, err error) {
if vs.computed != nil {
return *vs.computed, vs.flag, nil
}
if vs.Timeout == 0 {
vs.Timeout = 5
}
flag = cobra.ShellCompDirectiveDefault
timeout := time.Duration(vs.Timeout)
switch {
case vs.Static != nil:
values = *vs.Static
case vs.Files != nil:
flag = cobra.ShellCompDirectiveFilterFileExt
values = *vs.Files
case vs.Directories != nil:
flag = cobra.ShellCompDirectiveFilterDirs
values = []string{*vs.Directories}
case vs.Func != nil:
ctx, cancel := context.WithTimeout(context.Background(), timeout*time.Second)
defer cancel()
done := make(chan error, 1)
panicChan := make(chan any, 1)
go func() {
defer func() {
if p := recover(); p != nil {
panicChan <- p
}
}()
values, flag, err = vs.Func(vs.command, currentValue)
done <- err
}()
select {
case err = <-done:
return
case p := <-panicChan:
panic(p)
case <-ctx.Done():
flag = cobra.ShellCompDirectiveError
err = ctx.Err()
return
}
case vs.Command != nil:
if vs.command == nil {
return nil, cobra.ShellCompDirectiveError, fmt.Errorf("bug: command is nil")
}
argString, err := vs.command.ResolveTemplate(vs.Command.Args, currentValue)
if err != nil {
return nil, cobra.ShellCompDirectiveError, err
}
args := strings.Split(argString, " ")
sub, _, err := vs.command.Cobra.Root().Find(vs.Command.Path)
if err != nil {
return nil, cobra.ShellCompDirectiveError, fmt.Errorf("could not find a command named %s", vs.Command.Path)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout*time.Second)
defer cancel() // The cancel should be deferred so resources are cleaned up
sub.SetArgs(args)
var stdout bytes.Buffer
sub.SetOut(&stdout)
var stderr bytes.Buffer
sub.SetErr(&stderr)
err = sub.ExecuteContext(ctx)
if err != nil {
return nil, cobra.ShellCompDirectiveError, err
}
values = strings.Split(stdout.String(), "\n")
flag = cobra.ShellCompDirectiveDefault
err = nil
case vs.Script != "":
if vs.command == nil {
return nil, cobra.ShellCompDirectiveError, fmt.Errorf("bug: command is nil")
}
cmd, err := vs.command.ResolveTemplate(vs.Script, currentValue)
if err != nil {
return nil, cobra.ShellCompDirectiveError, err
}
args := append([]string{"/bin/bash", "-c"}, cmd)
values, flag, err = exec.Exec(vs.command.FullName(), args, os.Environ(), timeout*time.Second)
if err != nil {
return nil, flag, err
}
}
vs.computed = &values
if vs.SuggestRaw {
flag = flag | cobra.ShellCompDirectiveNoSpace
}
vs.flag = flag
return values, flag, err
}
type AutocompleteTemplate struct {
Args map[string]string
Opts map[string]string
}
func (tpl *AutocompleteTemplate) Opt(name string) string {
if val, ok := tpl.Opts[name]; ok {
return fmt.Sprintf("--%s %s", name, val)
}
return ""
}
func (tpl *AutocompleteTemplate) Arg(name string) string {
return tpl.Args[name]
}
func (cmd *Command) ResolveTemplate(templateString string, currentValue string) (string, error) {
var buf bytes.Buffer
tplData := &AutocompleteTemplate{
Args: cmd.Arguments.AllKnownStr(),
Opts: cmd.Options.AllKnownStr(),
}
fnMap := template.FuncMap{
"Opt": tplData.Opt,
"Arg": tplData.Arg,
"Current": func() string { return currentValue },
}
for k, v := range _c.TemplateFuncs {
fnMap[k] = v
}
tpl, err := template.New("subcommand").Funcs(fnMap).Parse(templateString)
if err != nil {
return "", err
}
err = tpl.Execute(&buf, tplData)
if err != nil {
return "", err
}
return buf.String(), nil
}

View File

@ -0,0 +1,164 @@
// Copyright © 2022 Roberto Hidalgo <joao@un.rob.mx>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package command_test
import (
"testing"
. "git.rob.mx/nidito/joao/internal/command"
"github.com/spf13/pflag"
)
func TestResolveTemplate(t *testing.T) {
overrideFlags := &pflag.FlagSet{}
overrideFlags.String("option", "override", "stuff")
overrideFlags.Bool("bool", false, "stuff")
overrideFlags.Bool("help", false, "stuff")
overrideFlags.Bool("no-color", false, "stuff")
overrideFlags.Bool("skip-validation", false, "stuff")
err := overrideFlags.Parse([]string{"--option", "override", "--bool", "--help", "--no-color", "--skip-validation"})
if err != nil {
t.Fatalf("Could not parse test flags")
}
cases := []struct {
Tpl string
Expected string
Args []string
Flags *pflag.FlagSet
Errors bool
}{
{
Tpl: "adds nothing to nothing",
Expected: "adds nothing to nothing",
Errors: false,
Args: []string{},
Flags: &pflag.FlagSet{},
},
{
Tpl: `prints default option as {{ Opt "option" }}`,
Expected: "prints default option as --option default",
Errors: false,
Args: []string{},
Flags: &pflag.FlagSet{},
},
{
Tpl: `prints default option value as {{ .Opts.option }}`,
Expected: "prints default option value as default",
Errors: false,
Args: []string{},
Flags: &pflag.FlagSet{},
},
{
Tpl: `prints default argument as {{ Arg "argument_0" }}`,
Expected: "prints default argument as default",
Errors: false,
Args: []string{},
Flags: &pflag.FlagSet{},
},
{
Tpl: `prints default argument value as {{ .Args.argument_0 }}`,
Expected: "prints default argument value as default",
Errors: false,
Args: []string{},
Flags: &pflag.FlagSet{},
},
{
Tpl: `overrides default option as {{ Opt "option" }}`,
Expected: "overrides default option as --option override",
Errors: false,
Args: []string{},
Flags: overrideFlags,
},
{
Tpl: `overrides default argument as {{ Arg "argument_0" }}`,
Expected: "overrides default argument as override",
Errors: false,
Args: []string{"override"},
Flags: &pflag.FlagSet{},
},
{
Tpl: `combines defaults as {{ Opt "option" }} {{ Opt "bool"}} {{ Arg "argument_0" }}`,
Expected: "combines defaults as --option default --bool false default",
Errors: false,
Args: []string{},
Flags: &pflag.FlagSet{},
},
{
Tpl: `combines overrides as {{ Opt "option" }} {{ Opt "bool" }} {{ Arg "argument_0" }}`,
Expected: "combines overrides as --option override --bool true twice",
Errors: false,
Args: []string{"twice"},
Flags: overrideFlags,
},
{
Tpl: `prints variadic as {{ Arg "argument_0" }} {{ Arg "argument_n" }}`,
Expected: "prints variadic as override a b",
Errors: false,
Args: []string{"override", "a", "b"},
Flags: &pflag.FlagSet{},
},
{
Tpl: `doesn't error on bad names {{ Opt "bad-option" }} {{ Arg "bad-argument" }}`,
Expected: "doesn't error on bad names ",
Errors: false,
Args: []string{},
Flags: &pflag.FlagSet{},
},
{
Tpl: `errors on bad templates {{ BadFunc }}`,
Args: []string{},
Flags: &pflag.FlagSet{},
Errors: true,
},
}
for _, test := range cases {
test := test
t.Run(test.Expected, func(t *testing.T) {
cmd := (&Command{
Arguments: []*Argument{
{
Name: "argument_0",
Default: "default",
},
{
Name: "argument_n",
Variadic: true,
},
},
Options: Options{
"option": {
Default: "default",
Type: "string",
},
"bool": {
Type: "bool",
Default: false,
},
},
}).SetBindings()
cmd.Arguments.Parse(test.Args)
cmd.Options.Parse(test.Flags)
res, err := cmd.ResolveTemplate(test.Tpl, "")
if err != nil && !test.Errors {
t.Fatalf("good template failed: %s", err)
}
if res != test.Expected {
t.Fatalf("expected '%s' got '%s'", test.Expected, res)
}
})
}
}

View File

@ -0,0 +1,94 @@
// Copyright © 2022 Roberto Hidalgo <joao@un.rob.mx>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package constants
import (
"strings"
"text/template"
// Embed requires an import so the compiler knows what's up. Golint requires a comment. Gotta please em both.
_ "embed"
)
const HelpCommandName = "help"
// Environment Variables.
const EnvVarHelpUnstyled = "MILPA_PLAIN_HELP"
const EnvVarHelpStyle = "MILPA_HELP_STYLE"
const EnvVarMilpaRoot = "MILPA_ROOT"
const EnvVarMilpaPath = "MILPA_PATH"
const EnvVarMilpaPathParsed = "MILPA_PATH_PARSED"
const EnvVarMilpaVerbose = "MILPA_VERBOSE"
const EnvVarMilpaSilent = "MILPA_SILENT"
const EnvVarMilpaUnstyled = "NO_COLOR"
const EnvVarMilpaForceColor = "COLOR"
const EnvVarValidationDisabled = "MILPA_SKIP_VALIDATION"
const EnvVarCompaOut = "COMPA_OUT"
const EnvVarDebug = "DEBUG"
const EnvVarLookupGitDisabled = "MILPA_DISABLE_GIT"
const EnvVarLookupUserReposDisabled = "MILPA_DISABLE_USER_REPOS"
const EnvVarLookupGlobalReposDisabled = "MILPA_DISABLE_GLOBAL_REPOS"
// EnvFlagNames are flags also available as environment variables.
var EnvFlagNames = map[string]string{
"no-color": EnvVarMilpaUnstyled,
"color": EnvVarMilpaForceColor,
"silent": EnvVarMilpaSilent,
"verbose": EnvVarMilpaVerbose,
"skip-validation": EnvVarValidationDisabled,
}
// Exit statuses
// see man sysexits || grep "#define EX" /usr/include/sysexits.h
// and https://tldp.org/LDP/abs/html/exitcodes.html
// 0 means everything is fine.
const ExitStatusOk = 0
// 42 provides answers to life, the universe and everything; also, renders help.
const ExitStatusRenderHelp = 42
// 64 bad arguments
// EX_USAGE The command was used incorrectly, e.g., with the wrong number of arguments, a bad flag, a bad syntax in a parameter, or whatever.
const ExitStatusUsage = 64
// EX_SOFTWARE An internal software error has been detected. This should be limited to non-operating system related errors as possible.
const ExitStatusProgrammerError = 70
// EX_CONFIG Something was found in an unconfigured or misconfigured state.
const ExitStatusConfigError = 78
// 127 command not found.
const ExitStatusNotFound = 127
// ContextKeyRuntimeIndex is the string key used to store context in a cobra Command.
const ContextKeyRuntimeIndex = "x-joao-runtime-index"
//go:embed help.md
var helpTemplateText string
// TemplateFuncs is a FuncMap with aliases to the strings package.
var TemplateFuncs = template.FuncMap{
"contains": strings.Contains,
"hasSuffix": strings.HasSuffix,
"hasPrefix": strings.HasPrefix,
"replace": strings.ReplaceAll,
"toUpper": strings.ToUpper,
"toLower": strings.ToLower,
"trim": strings.TrimSpace,
"trimSuffix": strings.TrimSuffix,
"trimPrefix": strings.TrimPrefix,
}
// TemplateCommandHelp holds a template for rendering command help.
var TemplateCommandHelp = template.Must(template.New("help").Funcs(TemplateFuncs).Parse(helpTemplateText))

View File

@ -0,0 +1,70 @@
{{- if not .HTMLOutput }}
# {{ if and (not (eq .Spec.FullName "joao")) (not (eq .Command.Name "help")) }}joao {{ end }}{{ .Spec.FullName }}{{if eq .Command.Name "help"}} help{{end}}
{{- else }}
---
description: {{ .Command.Short }}
---
{{- end }}
{{ .Command.Short }}
## Usage
﹅{{ replace .Command.UseLine " [flags]" "" }}{{if .Command.HasAvailableSubCommands}} SUBCOMMAND{{end}}﹅
{{ if .Command.HasAvailableSubCommands -}}
## Subcommands
{{ $hh := .HTMLOutput -}}
{{ range .Command.Commands -}}
{{- if (or .IsAvailableCommand (eq .Name "help")) -}}
- {{ if $hh -}}
[﹅{{ .Name }}﹅]({{.Name}})
{{- else -}}
﹅{{ .Name }}﹅
{{- end }} - {{.Short}}
{{ end }}
{{- end -}}
{{- end -}}
{{- if .Spec.Arguments -}}
## Arguments
{{ range .Spec.Arguments -}}
- ﹅{{ .Name | toUpper }}{{ if .Variadic}}...{{ end }}﹅{{ if .Required }} _required_{{ end }} - {{ .Description }}
{{ end -}}
{{- end -}}
{{ if and (eq .Spec.FullName "joao") (not (eq .Command.Name "help")) }}
## Description
{{ .Spec.Description }}
{{ end -}}
{{- if .Spec.HasAdditionalHelp }}
{{ .Spec.AdditionalHelp .HTMLOutput }}
{{ end -}}
{{- if .Command.HasAvailableLocalFlags}}
## Options
{{ range $name, $opt := .Spec.Options -}}
- ﹅--{{ $name }}﹅ (_{{$opt.Type}}_): {{ trimSuffix $opt.Description "."}}.{{ if $opt.Default }} Default: _{{ $opt.Default }}_.{{ end }}
{{ end -}}
{{- end -}}
{{- if not (eq .Spec.FullName "joao") }}
## Description
{{ if not (eq .Command.Long "") }}{{ .Command.Long }}{{ else }}{{ .Spec.Description }}{{end}}
{{ end }}
{{- if .Command.HasAvailableInheritedFlags }}
## Global Options
{{ range $name, $opt := .GlobalOptions -}}
- ﹅--{{ $name }}﹅ (_{{$opt.Type}}_): {{$opt.Description}}.{{ if $opt.Default }} Default: _{{ $opt.Default }}_.{{ end }}
{{ end -}}
{{end}}

70
internal/errors/errors.go Normal file
View File

@ -0,0 +1,70 @@
// Copyright © 2022 Roberto Hidalgo <joao@un.rob.mx>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package errors
import "fmt"
type NotFound struct {
Msg string
Group []string
}
type BadArguments struct {
Msg string
}
type NotExecutable struct {
Msg string
}
type ConfigError struct {
Err error
Config string
}
type EnvironmentError struct {
Err error
}
type SubCommandExit struct {
Err error
ExitCode int
}
func (err NotFound) Error() string {
return err.Msg
}
func (err BadArguments) Error() string {
return err.Msg
}
func (err NotExecutable) Error() string {
return err.Msg
}
func (err SubCommandExit) Error() string {
if err.Err != nil {
return err.Err.Error()
}
return ""
}
func (err ConfigError) Error() string {
return fmt.Sprintf("Invalid configuration %s: %v", err.Config, err.Err)
}
func (err EnvironmentError) Error() string {
return fmt.Sprintf("Invalid MILPA_ environment: %v", err.Err)
}

View File

@ -0,0 +1,76 @@
// Copyright © 2022 Roberto Hidalgo <joao@un.rob.mx>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package errors
import (
"os"
"strings"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
_c "git.rob.mx/nidito/joao/internal/constants"
)
func showHelp(cmd *cobra.Command) {
if cmd.Name() != _c.HelpCommandName {
err := cmd.Help()
if err != nil {
os.Exit(_c.ExitStatusProgrammerError)
}
}
}
func HandleCobraExit(cmd *cobra.Command, err error) {
if err == nil {
ok, err := cmd.Flags().GetBool(_c.HelpCommandName)
if cmd.Name() == _c.HelpCommandName || err == nil && ok {
os.Exit(_c.ExitStatusRenderHelp)
}
os.Exit(_c.ExitStatusOk)
}
switch tErr := err.(type) {
case SubCommandExit:
logrus.Debugf("Sub-command failed with: %s", err.Error())
os.Exit(tErr.ExitCode)
case BadArguments:
showHelp(cmd)
logrus.Error(err)
os.Exit(_c.ExitStatusUsage)
case NotFound:
showHelp(cmd)
logrus.Error(err)
os.Exit(_c.ExitStatusNotFound)
case ConfigError:
showHelp(cmd)
logrus.Error(err)
os.Exit(_c.ExitStatusConfigError)
case EnvironmentError:
logrus.Error(err)
os.Exit(_c.ExitStatusConfigError)
default:
if strings.HasPrefix(err.Error(), "unknown command") {
showHelp(cmd)
os.Exit(_c.ExitStatusNotFound)
} else if strings.HasPrefix(err.Error(), "unknown flag") || strings.HasPrefix(err.Error(), "unknown shorthand flag") {
showHelp(cmd)
logrus.Error(err)
os.Exit(_c.ExitStatusUsage)
}
}
logrus.Errorf("Unknown error: %s", err)
os.Exit(2)
}

65
internal/exec/exec.go Normal file
View File

@ -0,0 +1,65 @@
// Copyright © 2022 Roberto Hidalgo <joao@un.rob.mx>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package exec
import (
"bytes"
"context"
"fmt"
os_exec "os/exec"
"strings"
"time"
"git.rob.mx/nidito/joao/internal/errors"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
// ExecFunc is replaced in tests.
var ExecFunc = WithSubshell
func WithSubshell(ctx context.Context, env []string, executable string, args ...string) (bytes.Buffer, bytes.Buffer, error) {
cmd := os_exec.CommandContext(ctx, executable, args...) // #nosec G204
var stdout bytes.Buffer
cmd.Stdout = &stdout
var stderr bytes.Buffer
cmd.Stderr = &stderr
cmd.Env = env
return stdout, stderr, cmd.Run()
}
// Exec runs a subprocess and returns a list of lines from stdout.
func Exec(name string, args []string, env []string, timeout time.Duration) ([]string, cobra.ShellCompDirective, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel() // The cancel should be deferred so resources are cleaned up
logrus.Debugf("executing %s", args)
executable := args[0]
args = args[1:]
stdout, _, err := ExecFunc(ctx, env, executable, args...)
if ctx.Err() == context.DeadlineExceeded {
fmt.Println("Sub-command timed out")
logrus.Debugf("timeout running %s %s: %s", executable, args, stdout.String())
return []string{}, cobra.ShellCompDirectiveError, fmt.Errorf("timed out resolving %s %s", executable, args)
}
if err != nil {
logrus.Debugf("error running %s %s: %s", executable, args, err)
return []string{}, cobra.ShellCompDirectiveError, errors.BadArguments{Msg: fmt.Sprintf("could not validate argument for command %s, ran <%s %s> failed: %s", name, executable, strings.Join(args, " "), err)}
}
logrus.Debugf("done running %s %s: %s", executable, args, stdout.String())
return strings.Split(strings.TrimSuffix(stdout.String(), "\n"), "\n"), cobra.ShellCompDirectiveDefault, nil
}

108
internal/exec/exec_test.go Normal file
View File

@ -0,0 +1,108 @@
// Copyright © 2022 Roberto Hidalgo <joao@un.rob.mx>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package exec_test
import (
"bytes"
"context"
"fmt"
"strings"
"testing"
"time"
. "git.rob.mx/nidito/joao/internal/exec"
"github.com/spf13/cobra"
)
func TestSubshellExec(t *testing.T) {
ExecFunc = WithSubshell
stdout, directive, err := Exec("test-command", []string{"bash", "-c", `echo "stdout"; echo "stderr" >&2;`}, []string{}, 1*time.Second)
if err != nil {
t.Fatalf("good subshell errored: %v", err)
}
if len(stdout) != 1 && stdout[0] == "stdout" {
t.Fatalf("good subshell returned wrong stdout: %v", stdout)
}
if directive != cobra.ShellCompDirectiveDefault {
t.Fatalf("good subshell returned wrong directive: %v", directive)
}
stdout, directive, err = Exec("test-command", []string{"bash", "-c", `echo "stdout"; echo "stderr" >&2; exit 2`}, []string{}, 1*time.Second)
if err == nil {
t.Fatalf("bad subshell did not error; stdout: %v", stdout)
}
if len(stdout) != 0 {
t.Fatalf("bad subshell returned non-empty stdout: %v", stdout)
}
if directive != cobra.ShellCompDirectiveError {
t.Fatalf("bad subshell returned wrong directive: %v", directive)
}
}
func TestExecTimesOut(t *testing.T) {
ExecFunc = func(ctx context.Context, env []string, executable string, args ...string) (bytes.Buffer, bytes.Buffer, error) {
time.Sleep(100 * time.Nanosecond)
return bytes.Buffer{}, bytes.Buffer{}, context.DeadlineExceeded
}
_, _, err := Exec("test-command", []string{"bash", "-c", "sleep", "2"}, []string{}, 10*time.Nanosecond)
if err == nil {
t.Fatalf("timeout didn't happen after 10ms: %v", err)
}
}
func TestExecWorksFine(t *testing.T) {
ExecFunc = func(ctx context.Context, env []string, executable string, args ...string) (bytes.Buffer, bytes.Buffer, error) {
var out bytes.Buffer
fmt.Fprint(&out, strings.Join([]string{
"a",
"b",
"c",
}, "\n"))
return out, bytes.Buffer{}, nil
}
args := []string{"a", "b", "c"}
res, directive, err := Exec("test-command", append([]string{"bash", "-c", "echo"}, args...), []string{}, 1*time.Second)
if err != nil {
t.Fatalf("good command failed: %v", err)
}
if directive != 0 {
t.Fatalf("good command resulted in wrong directive, expected %d, got %d", 0, directive)
}
if strings.Join(args, "-") != strings.Join(res, "-") {
t.Fatalf("good command resulted in wrong results, expected %v, got %v", res, args)
}
}
func TestExecErrors(t *testing.T) {
ExecFunc = func(ctx context.Context, env []string, executable string, args ...string) (bytes.Buffer, bytes.Buffer, error) {
return bytes.Buffer{}, bytes.Buffer{}, fmt.Errorf("bad command is bad")
}
res, directive, err := Exec("test-command", []string{"bash", "-c", "bad-command"}, []string{}, 1*time.Second)
if err == fmt.Errorf("bad command is bad") {
t.Fatalf("bad command didn't fail: %v", res)
}
if directive != cobra.ShellCompDirectiveError {
t.Fatalf("bad command resulted in wrong directive, expected %d, got %d", cobra.ShellCompDirectiveError, directive)
}
if len(res) > 0 {
t.Fatalf("bad command returned values, got %v", res)
}
}

View File

@ -0,0 +1,78 @@
// Copyright © 2022 Roberto Hidalgo <joao@un.rob.mx>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package registry
import (
"strings"
"git.rob.mx/nidito/joao/internal/command"
_c "git.rob.mx/nidito/joao/internal/constants"
"git.rob.mx/nidito/joao/internal/errors"
"git.rob.mx/nidito/joao/internal/runtime"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
func toCobra(cmd *command.Command, globalOptions command.Options) *cobra.Command {
localName := cmd.Name()
useSpec := []string{localName, "[options]"}
for _, arg := range cmd.Arguments {
useSpec = append(useSpec, arg.ToDesc())
}
cc := &cobra.Command{
Use: strings.Join(useSpec, " "),
Short: cmd.Summary,
DisableAutoGenTag: true,
SilenceUsage: true,
SilenceErrors: true,
Annotations: map[string]string{
_c.ContextKeyRuntimeIndex: cmd.FullName(),
},
Args: func(cc *cobra.Command, supplied []string) error {
skipValidation, _ := cc.Flags().GetBool("skip-validation")
if !skipValidation && runtime.ValidationEnabled() {
cmd.Arguments.Parse(supplied)
return cmd.Arguments.AreValid()
}
return nil
},
RunE: cmd.Run,
}
cc.SetFlagErrorFunc(func(c *cobra.Command, e error) error {
return errors.BadArguments{Msg: e.Error()}
})
cc.ValidArgsFunction = cmd.Arguments.CompletionFunction
cc.Flags().AddFlagSet(cmd.FlagSet())
for name, opt := range cmd.Options {
if err := cc.RegisterFlagCompletionFunc(name, opt.CompletionFunction); err != nil {
logrus.Errorf("Failed setting up autocompletion for option <%s> of command <%s>", name, cmd.FullName())
}
}
cc.SetHelpFunc(cmd.HelpRenderer(globalOptions))
cmd.SetCobra(cc)
return cc
}
func fromCobra(cc *cobra.Command) *command.Command {
rtidx, hasAnnotation := cc.Annotations[_c.ContextKeyRuntimeIndex]
if hasAnnotation {
return Get(rtidx)
}
return nil
}

View File

@ -0,0 +1,265 @@
// Copyright © 2022 Roberto Hidalgo <joao@un.rob.mx>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package registry
import (
"fmt"
"os"
"sort"
"strings"
"git.rob.mx/nidito/joao/internal/command"
_c "git.rob.mx/nidito/joao/internal/constants"
"git.rob.mx/nidito/joao/internal/errors"
"github.com/fatih/color"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
var registry = &CommandRegistry{
kv: map[string]*command.Command{},
}
type ByPath []*command.Command
func (cmds ByPath) Len() int { return len(cmds) }
func (cmds ByPath) Swap(i, j int) { cmds[i], cmds[j] = cmds[j], cmds[i] }
func (cmds ByPath) Less(i, j int) bool { return cmds[i].FullName() < cmds[j].FullName() }
type CommandTree struct {
Command *command.Command `json:"command"`
Children []*CommandTree `json:"children"`
}
func (t *CommandTree) Traverse(fn func(cmd *command.Command) error) error {
for _, child := range t.Children {
if err := fn(child.Command); err != nil {
return err
}
if err := child.Traverse(fn); err != nil {
return err
}
}
return nil
}
type CommandRegistry struct {
kv map[string]*command.Command
byPath []*command.Command
tree *CommandTree
}
func Register(cmd *command.Command) {
logrus.Debugf("Registering %s", cmd.FullName())
registry.kv[cmd.FullName()] = cmd
}
func Get(id string) *command.Command {
return registry.kv[id]
}
func CommandList() []*command.Command {
if len(registry.byPath) == 0 {
list := []*command.Command{}
for _, v := range registry.kv {
list = append(list, v)
}
sort.Sort(ByPath(list))
registry.byPath = list
}
return registry.byPath
}
func BuildTree(cc *cobra.Command, depth int) {
tree := &CommandTree{
Command: fromCobra(cc),
Children: []*CommandTree{},
}
var populateTree func(cmd *cobra.Command, ct *CommandTree, maxDepth int, depth int)
populateTree = func(cmd *cobra.Command, ct *CommandTree, maxDepth int, depth int) {
newDepth := depth + 1
for _, subcc := range cmd.Commands() {
if subcc.Hidden {
continue
}
if cmd := fromCobra(subcc); cmd != nil {
leaf := &CommandTree{Children: []*CommandTree{}}
leaf.Command = cmd
ct.Children = append(ct.Children, leaf)
if newDepth < maxDepth {
populateTree(subcc, leaf, maxDepth, newDepth)
}
}
}
}
populateTree(cc, tree, depth, 0)
registry.tree = tree
}
func SerializeTree(serializationFn func(any) ([]byte, error)) (string, error) {
bytes, err := serializationFn(registry.tree)
if err != nil {
return "", err
}
return string(bytes), nil
}
func ChildrenNames() []string {
if registry.tree == nil {
return []string{}
}
ret := make([]string, len(registry.tree.Children))
for idx, cmd := range registry.tree.Children {
ret[idx] = cmd.Command.Name()
}
return ret
}
func Execute(version string) error {
cmdRoot := command.Root
ccRoot.Short = cmdRoot.Summary
ccRoot.Long = cmdRoot.Description
ccRoot.Annotations["version"] = version
ccRoot.CompletionOptions.DisableDefaultCmd = true
ccRoot.Flags().AddFlagSet(cmdRoot.FlagSet())
for name, opt := range cmdRoot.Options {
if err := ccRoot.RegisterFlagCompletionFunc(name, opt.CompletionFunction); err != nil {
logrus.Errorf("Failed setting up autocompletion for option <%s> of command <%s>", name, cmdRoot.FullName())
}
}
ccRoot.SetHelpFunc(cmdRoot.HelpRenderer(cmdRoot.Options))
for _, cmd := range CommandList() {
cmd := cmd
leaf := toCobra(cmd, cmdRoot.Options)
container := ccRoot
for idx, cp := range cmd.Path {
if idx == len(cmd.Path)-1 {
logrus.Debugf("adding command %s to %s", leaf.Name(), cmd.Path[0:idx])
container.AddCommand(leaf)
break
}
query := []string{cp}
if cc, _, err := container.Find(query); err == nil && cc != container {
container = cc
} else {
groupName := strings.Join(query, " ")
groupPath := append(cmd.Path[0:idx], query...) // nolint:gocritic
cc := &cobra.Command{
Use: cp,
Short: fmt.Sprintf("%s subcommands", groupName),
DisableAutoGenTag: true,
SuggestionsMinimumDistance: 2,
SilenceUsage: true,
SilenceErrors: true,
Annotations: map[string]string{
_c.ContextKeyRuntimeIndex: strings.Join(groupPath, " "),
},
Args: func(cmd *cobra.Command, args []string) error {
if err := cobra.OnlyValidArgs(cmd, args); err == nil {
return nil
}
suggestions := []string{}
bold := color.New(color.Bold)
for _, l := range cmd.SuggestionsFor(args[len(args)-1]) {
suggestions = append(suggestions, bold.Sprint(l))
}
last := len(args) - 1
parent := cmd.CommandPath()
errMessage := fmt.Sprintf("Unknown subcommand %s of known command %s", bold.Sprint(args[last]), bold.Sprint(parent))
if len(suggestions) > 0 {
errMessage += ". Perhaps you meant " + strings.Join(suggestions, ", ") + "?"
}
return errors.NotFound{Msg: errMessage, Group: []string{}}
},
ValidArgs: []string{""},
RunE: func(cc *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.NotFound{Msg: "No subcommand provided", Group: []string{}}
}
os.Exit(_c.ExitStatusNotFound)
return nil
},
}
groupParent := &command.Command{
Path: cmd.Path[0 : len(cmd.Path)-1],
Summary: fmt.Sprintf("%s subcommands", groupName),
Description: fmt.Sprintf("Runs subcommands within %s", groupName),
Arguments: command.Arguments{},
Options: command.Options{},
}
Register(groupParent)
cc.SetHelpFunc(groupParent.HelpRenderer(command.Options{}))
container.AddCommand(cc)
container = cc
}
}
}
cmdRoot.SetCobra(ccRoot)
return ccRoot.Execute()
}
var ccRoot = &cobra.Command{
Use: "joao [--silent|-v|--verbose] [--[no-]color] [-h|--help] [--version]",
Annotations: map[string]string{
_c.ContextKeyRuntimeIndex: "joao",
},
DisableAutoGenTag: true,
SilenceUsage: true,
SilenceErrors: true,
ValidArgs: []string{""},
Args: func(cmd *cobra.Command, args []string) error {
err := cobra.OnlyValidArgs(cmd, args)
if err != nil {
suggestions := []string{}
bold := color.New(color.Bold)
for _, l := range cmd.SuggestionsFor(args[len(args)-1]) {
suggestions = append(suggestions, bold.Sprint(l))
}
errMessage := fmt.Sprintf("Unknown subcommand %s", bold.Sprint(strings.Join(args, " ")))
if len(suggestions) > 0 {
errMessage += ". Perhaps you meant " + strings.Join(suggestions, ", ") + "?"
}
return errors.NotFound{Msg: errMessage, Group: []string{}}
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
// if ok, err := cmd.Flags().GetBool("version"); err == nil && ok {
// vc, _, err := cmd.Root().Find([]string{versionName()})
// if err != nil {
// return err
// }
// return vc.RunE(vc, []string{})
// }
return errors.NotFound{Msg: "No subcommand provided", Group: []string{}}
}
return nil
},
}

70
internal/render/render.go Normal file
View File

@ -0,0 +1,70 @@
// Copyright © 2022 Roberto Hidalgo <joao@un.rob.mx>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package render
import (
"bytes"
"os"
_c "git.rob.mx/nidito/joao/internal/constants"
"git.rob.mx/nidito/joao/internal/runtime"
"github.com/charmbracelet/glamour"
"github.com/sirupsen/logrus"
"golang.org/x/term"
)
func addBackticks(str []byte) []byte {
return bytes.ReplaceAll(str, []byte("﹅"), []byte("`"))
}
func Markdown(content []byte, withColor bool) ([]byte, error) {
content = addBackticks(content)
if runtime.UnstyledHelpEnabled() {
return content, nil
}
width, _, err := term.GetSize(0)
if err != nil {
logrus.Debugf("Could not get terminal width")
width = 80
}
var styleFunc glamour.TermRendererOption
if withColor {
style := os.Getenv(_c.EnvVarHelpStyle)
switch style {
case "dark":
styleFunc = glamour.WithStandardStyle("dark")
case "light":
styleFunc = glamour.WithStandardStyle("light")
default:
styleFunc = glamour.WithStandardStyle("auto")
}
} else {
styleFunc = glamour.WithStandardStyle("notty")
}
renderer, err := glamour.NewTermRenderer(
styleFunc,
glamour.WithEmoji(),
glamour.WithWordWrap(width),
)
if err != nil {
return content, err
}
return renderer.RenderBytes(content)
}

View File

@ -0,0 +1,87 @@
// Copyright © 2022 Roberto Hidalgo <joao@un.rob.mx>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package render_test
import (
"fmt"
"os"
"reflect"
"testing"
_c "git.rob.mx/nidito/joao/internal/constants"
"git.rob.mx/nidito/joao/internal/render"
)
func TestMarkdownUnstyled(t *testing.T) {
content := []byte("# hello")
os.Setenv(_c.EnvVarHelpUnstyled, "true")
res, err := render.Markdown(content, false)
if err != nil {
t.Fatalf("Unexpected error %s", err)
}
expected := []byte("# hello") // nolint:ifshort
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Unexpected response ---\n%s\n---\n wanted:\n---\n%s\n---", res, expected)
}
}
func TestMarkdownNoColor(t *testing.T) {
os.Unsetenv(_c.EnvVarHelpUnstyled)
content := []byte("# hello ﹅world﹅")
res, err := render.Markdown(content, false)
if err != nil {
t.Fatalf("Unexpected error %s", err)
}
// account for 80 character width word wrapping
// our string is 15 characters, there's 2 for padding at the start
spaces := " "
expected := []byte("\n # hello `world`" + spaces + "\n\n") // nolint:ifshort
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Unexpected response ---\n%s\n---\n wanted:\n---\n%s\n---", res, expected)
}
}
var autoStyleTestRender = "\n\x1b[38;5;228;48;5;63;1m\x1b[0m\x1b[38;5;228;48;5;63;1m\x1b[0m \x1b[38;5;228;48;5;63;1m \x1b[0m\x1b[38;5;228;48;5;63;1mhello\x1b[0m\x1b[38;5;228;48;5;63;1m \x1b[0m\x1b[38;5;252m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[38;5;252m \x1b[0m\x1b[0m\n\x1b[0m\n"
const lightStyleTestRender = "\n\x1b[38;5;228;48;5;63;1m\x1b[0m\x1b[38;5;228;48;5;63;1m\x1b[0m \x1b[38;5;228;48;5;63;1m \x1b[0m\x1b[38;5;228;48;5;63;1mhello\x1b[0m\x1b[38;5;228;48;5;63;1m \x1b[0m\x1b[38;5;234m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[38;5;234m \x1b[0m\x1b[0m\n\x1b[0m\n"
func TestMarkdownColor(t *testing.T) {
os.Unsetenv(_c.EnvVarHelpUnstyled)
content := []byte("# hello")
styles := map[string][]byte{
"": []byte(autoStyleTestRender),
"dark": []byte(autoStyleTestRender),
"auto": []byte(autoStyleTestRender),
"light": []byte(lightStyleTestRender),
}
for style, expected := range styles {
t.Run(fmt.Sprintf("style %s", style), func(t *testing.T) {
os.Setenv(_c.EnvVarHelpStyle, style)
res, err := render.Markdown(content, true)
if err != nil {
t.Fatalf("Unexpected error %s", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Unexpected response ---\n%v\n---\n wanted:\n---\n%v\n---", res, expected)
}
})
}
}

133
internal/runtime/runtime.go Normal file
View File

@ -0,0 +1,133 @@
// Copyright © 2022 Roberto Hidalgo <joao@un.rob.mx>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package runtime
import (
"fmt"
"os"
"strconv"
"strings"
_c "git.rob.mx/nidito/joao/internal/constants"
)
var MilpaPath = ParseMilpaPath()
// ParseMilpaPath turns MILPA_PATH into a string slice.
func ParseMilpaPath() []string {
return strings.Split(os.Getenv(_c.EnvVarMilpaPath), ":")
}
var falseIshValues = []string{
"",
"0",
"no",
"false",
"disable",
"disabled",
"off",
"never",
}
var trueIshValues = []string{
"1",
"yes",
"true",
"enable",
"enabled",
"on",
"always",
}
func isFalseIsh(val string) bool {
for _, negative := range falseIshValues {
if val == negative {
return true
}
}
return false
}
func isTrueIsh(val string) bool {
for _, positive := range trueIshValues {
if val == positive {
return true
}
}
return false
}
func DoctorModeEnabled() bool {
count := len(os.Args)
if count < 2 {
return false
}
first := os.Args[1]
return first == "__doctor" || count >= 2 && (first == "itself" && os.Args[2] == "doctor")
}
func DebugEnabled() bool {
return isTrueIsh(os.Getenv(_c.EnvVarDebug))
}
func ValidationEnabled() bool {
return isFalseIsh(os.Getenv(_c.EnvVarValidationDisabled))
}
func VerboseEnabled() bool {
return isTrueIsh(os.Getenv(_c.EnvVarMilpaVerbose))
}
func ColorEnabled() bool {
return isFalseIsh(os.Getenv(_c.EnvVarMilpaUnstyled)) && !UnstyledHelpEnabled()
}
func UnstyledHelpEnabled() bool {
return isTrueIsh(os.Getenv(_c.EnvVarHelpUnstyled))
}
func CheckMilpaPathSet() error {
if len(MilpaPath) == 0 {
return fmt.Errorf("no %s set on the environment", _c.EnvVarMilpaPath)
}
return nil
}
// EnvironmentMap returns the resolved environment map.
func EnvironmentMap() map[string]string {
env := map[string]string{}
env[_c.EnvVarMilpaPath] = strings.Join(MilpaPath, ":")
trueString := strconv.FormatBool(true)
env[_c.EnvVarMilpaPathParsed] = trueString
if !ColorEnabled() {
env[_c.EnvVarMilpaUnstyled] = trueString
} else if isTrueIsh(os.Getenv(_c.EnvVarMilpaForceColor)) {
env[_c.EnvVarMilpaForceColor] = "always"
}
if DebugEnabled() {
env[_c.EnvVarDebug] = trueString
}
if VerboseEnabled() {
env[_c.EnvVarMilpaVerbose] = trueString
} else if isTrueIsh(os.Getenv(_c.EnvVarMilpaSilent)) {
env[_c.EnvVarMilpaSilent] = trueString
}
return env
}

View File

@ -0,0 +1,195 @@
// Copyright © 2022 Roberto Hidalgo <joao@un.rob.mx>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package runtime_test
import (
"fmt"
"os"
"reflect"
"runtime"
"strconv"
"strings"
"testing"
_c "git.rob.mx/nidito/joao/internal/constants"
. "git.rob.mx/nidito/joao/internal/runtime"
)
func TestCheckMilpaPathSet(t *testing.T) {
MilpaPath = []string{"a", "b"}
if err := CheckMilpaPathSet(); err != nil {
t.Fatalf("Got error with set MILPA_PATH: %v", err)
}
MilpaPath = []string{}
if err := CheckMilpaPathSet(); err == nil {
t.Fatalf("Got no error with unset MILPA_PATH")
}
}
func TestEnabled(t *testing.T) {
defer func() { os.Setenv(_c.EnvVarMilpaVerbose, "") }()
cases := []struct {
Name string
Func func() bool
Expects bool
}{
{
Name: _c.EnvVarMilpaVerbose,
Func: VerboseEnabled,
Expects: true,
},
{
Name: _c.EnvVarValidationDisabled,
Func: ValidationEnabled,
},
{
Name: _c.EnvVarMilpaUnstyled,
Func: ColorEnabled,
},
{
Name: _c.EnvVarHelpUnstyled,
Func: ColorEnabled,
},
{
Name: _c.EnvVarDebug,
Func: DebugEnabled,
Expects: true,
},
{
Name: _c.EnvVarHelpUnstyled,
Func: UnstyledHelpEnabled,
Expects: true,
},
}
for _, c := range cases {
fname := runtime.FuncForPC(reflect.ValueOf(c.Func).Pointer()).Name()
name := fmt.Sprintf("%v/%s", fname, c.Name)
enabled := []string{
"yes", "true", "1", "enabled",
}
for _, val := range enabled {
t.Run("enabled-"+val, func(t *testing.T) {
os.Setenv(c.Name, val)
if c.Func() != c.Expects {
t.Fatalf("%s wasn't enabled with a valid value: %s", name, val)
}
})
}
disabled := []string{"", "no", "false", "0", "disabled"}
for _, val := range disabled {
t.Run("disabled-"+val, func(t *testing.T) {
os.Setenv(c.Name, val)
if c.Func() == c.Expects {
t.Fatalf("%s was enabled with falsy value: %s", name, val)
}
})
}
}
}
func TestDoctorMode(t *testing.T) {
cases := []struct {
Args []string
Expects bool
}{
{
Args: []string{},
},
{
Args: []string{""},
},
{
Args: []string{"something", "doctor"},
},
{
Args: []string{"__doctor"},
Expects: true,
},
{
Args: []string{"__doctor", "whatever"},
Expects: true,
},
{
Args: []string{"itself", "doctor"},
Expects: true,
},
}
for _, c := range cases {
t.Run(strings.Join(c.Args, " "), func(t *testing.T) {
os.Args = append([]string{"compa"}, c.Args...)
res := DoctorModeEnabled()
if res != c.Expects {
t.Fatalf("Expected %v for %v and got %v", c.Expects, c.Args, res)
}
})
}
}
func TestEnvironmentMapEnabled(t *testing.T) {
MilpaPath = []string{"something"}
trueString := strconv.FormatBool(true)
os.Setenv(_c.EnvVarMilpaForceColor, trueString)
os.Setenv(_c.EnvVarDebug, trueString)
os.Setenv(_c.EnvVarMilpaVerbose, trueString)
res := EnvironmentMap()
if res == nil {
t.Fatalf("Expected map, got nil")
}
expected := map[string]string{
_c.EnvVarMilpaPath: "something",
_c.EnvVarMilpaForceColor: "always",
_c.EnvVarMilpaPathParsed: trueString,
_c.EnvVarDebug: trueString,
_c.EnvVarMilpaVerbose: trueString,
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Unexpected result from enabled environment. Wanted %v, got %v", res, expected)
}
}
func TestEnvironmentMapDisabled(t *testing.T) {
MilpaPath = []string{"something"}
trueString := strconv.FormatBool(true)
// clear COLOR
os.Unsetenv(_c.EnvVarMilpaForceColor)
// set NO_COLOR
os.Setenv(_c.EnvVarMilpaUnstyled, trueString)
os.Unsetenv(_c.EnvVarDebug)
os.Unsetenv(_c.EnvVarMilpaVerbose)
os.Setenv(_c.EnvVarMilpaSilent, trueString)
res := EnvironmentMap()
if res == nil {
t.Fatalf("Expected map, got nil")
}
expected := map[string]string{
_c.EnvVarMilpaPath: "something",
_c.EnvVarMilpaUnstyled: trueString,
_c.EnvVarMilpaPathParsed: trueString,
_c.EnvVarMilpaSilent: trueString,
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Unexpected result from disabled environment. Wanted %v, got %v", res, expected)
}
}

View File

@ -13,7 +13,9 @@
package main
import (
"git.rob.mx/nidito/joao/cmd"
_ "git.rob.mx/nidito/joao/cmd"
"git.rob.mx/nidito/joao/internal/registry"
"git.rob.mx/nidito/joao/internal/runtime"
"github.com/sirupsen/logrus"
)
@ -23,10 +25,11 @@ func main() {
logrus.SetFormatter(&logrus.TextFormatter{
DisableLevelTruncation: true,
DisableTimestamp: true,
// ForceColors: runtime.ColorEnabled(),
ForceColors: runtime.ColorEnabled(),
})
err := cmd.RootCommand(version).Execute()
err := registry.Execute(version)
if err != nil {
logrus.Fatal(err)
}

View File

@ -16,8 +16,11 @@ import (
"bytes"
"encoding/json"
"fmt"
"sort"
"strings"
op "github.com/1Password/connect-sdk-go/onepassword"
"github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
@ -29,54 +32,53 @@ type Config struct {
}
var redactOutput = false
var annotationsSection = &op.ItemSection{
ID: "~annotations",
Label: "~annotations",
}
var defaultItemFields = []*op.ItemField{
{
ID: "password",
Type: "CONCEALED",
Purpose: "PASSWORD",
Label: "password",
Value: "hash",
}, {
ID: "notesPlain",
Type: "STRING",
Purpose: "NOTES",
Label: "notesPlain",
Value: "flushed by joao",
},
}
func (cfg *Config) ToMap() map[string]interface{} {
ret := map[string]interface{}{}
for _, child := range cfg.Tree.Children {
ret[child.Key] = child.AsMap()
func (cfg *Config) ToMap() map[string]any {
ret := map[string]any{}
for _, child := range cfg.Tree.Content {
ret[child.Name()] = child.AsMap()
}
return ret
}
func (cfg *Config) ToOP() *op.Item {
annotationsSection := &op.ItemSection{
ID: "~annotations",
Label: "~annotations",
}
sections := []*op.ItemSection{annotationsSection}
fields := []*op.ItemField{
{
ID: "password",
Type: "CONCEALED",
Purpose: "PASSWORD",
Label: "password",
Value: "hash",
}, {
ID: "notesPlain",
Type: "STRING",
Purpose: "NOTES",
Label: "notesPlain",
Value: "flushed by joao",
},
}
fields := append([]*op.ItemField{}, defaultItemFields...)
for key, leaf := range cfg.Tree.Children {
if len(leaf.Children) == 0 {
fields = append(fields, leaf.ToOP(annotationsSection)...)
for _, leaf := range cfg.Tree.Content {
if len(leaf.Content) == 0 {
fields = append(fields, leaf.ToOP()...)
continue
}
if !leaf.isSequence {
if leaf.Kind != yaml.SequenceNode {
sections = append(sections, &op.ItemSection{
ID: key,
Label: key,
ID: leaf.Name(),
Label: leaf.Name(),
})
} else {
fmt.Printf("Found sequence for %s", leaf.Key)
}
for _, child := range leaf.Children {
fields = append(fields, child.ToOP(annotationsSection)...)
for _, child := range leaf.Content {
fields = append(fields, child.ToOP()...)
}
}
@ -89,34 +91,78 @@ func (cfg *Config) ToOP() *op.Item {
}
}
func ConfigFromYAML(data []byte) (*Config, error) {
func ConfigFromYAML(data []byte, name string) (*Config, error) {
cfg := &Config{
Vault: "vault",
Name: "title",
Tree: NewEntry("root"),
Name: name,
Tree: NewEntry("root", yaml.MappingNode),
}
yaml.Unmarshal(data, cfg.Tree.Children)
for k, leaf := range cfg.Tree.Children {
leaf.SetKey(k, []string{})
}
yaml.Unmarshal(data, &cfg.Tree)
return cfg, nil
}
func scalarsIn(data map[string]yaml.Node, parents []string) ([]string, error) {
keys := []string{}
for key, leaf := range data {
if key == "_joao" {
continue
}
switch leaf.Kind {
case yaml.ScalarNode:
newKey := strings.Join(append(parents, key), ".")
keys = append(keys, newKey)
case yaml.MappingNode, yaml.DocumentNode, yaml.SequenceNode:
sub := map[string]yaml.Node{}
if leaf.Kind == yaml.SequenceNode {
list := []yaml.Node{}
if err := leaf.Decode(&list); err != nil {
return keys, err
}
for idx, child := range list {
sub[fmt.Sprintf("%d", idx)] = child
}
} else {
if err := leaf.Decode(&sub); err != nil {
return keys, err
}
}
ret, err := scalarsIn(sub, append(parents, key))
if err != nil {
return keys, err
}
keys = append(keys, ret...)
default:
logrus.Fatalf("found unknown %s at %s", leaf.Kind, key)
}
}
sort.Strings(keys)
return keys, nil
}
func KeysFromYAML(data []byte) ([]string, error) {
cfg := map[string]yaml.Node{}
yaml.Unmarshal(data, &cfg)
return scalarsIn(cfg, []string{})
}
func ConfigFromOP(item *op.Item) (*Config, error) {
cfg := &Config{
Vault: item.Vault.ID,
Name: item.Title,
Tree: NewEntry("root"),
Tree: NewEntry("root", yaml.MappingNode),
}
err := cfg.Tree.FromOP(item.Fields)
return cfg, err
}
func (cfg *Config) MarshalYAML() (interface{}, error) {
func (cfg *Config) MarshalYAML() (any, error) {
return cfg.Tree.MarshalYAML()
}
@ -133,7 +179,7 @@ func (cfg *Config) AsYAML(redacted bool) ([]byte, error) {
}
func (cfg *Config) AsJSON(redacted bool, item bool) ([]byte, error) {
var repr interface{}
var repr any
if item {
repr = cfg.ToOP()
} else {
@ -147,3 +193,69 @@ func (cfg *Config) AsJSON(redacted bool, item bool) ([]byte, error) {
}
return bytes, nil
}
func (cfg *Config) Set(path []string, data []byte, isSecret, parseEntry bool) error {
newEntry := NewEntry(path[len(path)-1], yaml.ScalarNode)
newEntry.Path = path
valueStr := string(data)
newEntry.Value = valueStr
if parseEntry {
if err := yaml.Unmarshal(data, newEntry); err != nil {
return err
}
} else {
valueStr = strings.Trim(valueStr, "\n")
if isSecret {
newEntry.Style = yaml.TaggedStyle
newEntry.Tag = "!!secret"
}
newEntry.Kind = yaml.ScalarNode
newEntry.Value = valueStr
if !strings.Contains(valueStr, "\n") {
newEntry.Style &= yaml.LiteralStyle
} else {
newEntry.Style &= yaml.FlowStyle
}
}
entry := cfg.Tree
for idx, key := range path {
if len(path)-1 == idx {
dst := entry.ChildNamed(key)
if dst == nil {
if entry.Kind == yaml.MappingNode {
key := NewEntry(key, yaml.ScalarNode)
entry.Content = append(entry.Content, key, newEntry)
} else {
entry.Content = append(entry.Content, newEntry)
}
} else {
logrus.Infof("setting %v", newEntry.Path)
dst.Value = newEntry.Value
dst.Tag = newEntry.Tag
dst.Style = newEntry.Style
}
break
}
if child := entry.ChildNamed(key); child != nil {
logrus.Infof("found child named %s, with len %v", key, len(child.Content))
entry = child
continue
}
logrus.Infof("no child named %s found in %s", key, entry.Name())
kind := yaml.MappingNode
if isNumeric(key) {
kind = yaml.SequenceNode
}
sub := NewEntry(key, kind)
sub.Path = append(entry.Path, key)
entry.Content = append(entry.Content, sub)
entry = sub
}
return nil
}

View File

@ -18,6 +18,7 @@ import (
"strings"
op "github.com/1Password/connect-sdk-go/onepassword"
"github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
@ -30,75 +31,158 @@ func isNumeric(s string) bool {
return true
}
type secretValue string
type Entry struct {
Key string
Path []string
Value interface{}
Children map[string]*Entry
isSecret bool
isSequence bool
node *yaml.Node
Value string
Kind yaml.Kind
Tag string
Path []string
Content []*Entry
Style yaml.Style
FootComment string
LineComment string
HeadComment string
Line int
Column int
Type string
}
func NewEntry(name string) *Entry {
return &Entry{Key: name, Children: map[string]*Entry{}}
func NewEntry(name string, kind yaml.Kind) *Entry {
return &Entry{
Content: []*Entry{},
Value: name,
Kind: kind,
}
}
func (e *Entry) SetKey(key string, parent []string) {
e.Path = append(parent, key)
e.Key = key
for k, child := range e.Children {
child.SetKey(k, e.Path)
func CopyFromNode(e *Entry, n *yaml.Node) *Entry {
if e.Content == nil {
e.Content = []*Entry{}
}
e.Kind = n.Kind
e.Value = n.Value
e.Tag = n.Tag
e.Style = n.Style
e.HeadComment = n.HeadComment
e.LineComment = n.LineComment
e.FootComment = n.FootComment
e.Line = n.Line
e.Column = n.Column
e.Type = n.ShortTag()
return e
}
func (e *Entry) String() string {
return e.Value
}
func (e *Entry) ChildNamed(name string) *Entry {
for _, child := range e.Content {
if child.Name() == name {
return child
}
}
return nil
}
func (e *Entry) SetPath(parent []string, current string) {
e.Path = append(parent, current)
switch e.Kind {
case yaml.MappingNode, yaml.DocumentNode:
for idx := 0; idx < len(e.Content); idx += 2 {
key := e.Content[idx]
child := e.Content[idx+1]
child.SetPath(e.Path, key.Value)
}
case yaml.SequenceNode:
for idx, child := range e.Content {
child.Path = append(e.Path, fmt.Sprintf("%d", idx))
}
}
}
func (e *Entry) UnmarshalYAML(node *yaml.Node) error {
e.node = node
switch node.Kind {
case yaml.DocumentNode, yaml.MappingNode:
if e.Children == nil {
e.Children = map[string]*Entry{}
}
err := node.Decode(&e.Children)
if err != nil {
return err
}
CopyFromNode(e, node)
case yaml.SequenceNode:
list := []*Entry{}
err := node.Decode(&list)
if err != nil {
return err
switch node.Kind {
case yaml.SequenceNode, yaml.ScalarNode:
for _, n := range node.Content {
sub := &Entry{}
CopyFromNode(sub, n)
if err := n.Decode(&sub); err != nil {
return err
}
sub.SetPath(e.Path, n.Value)
e.Content = append(e.Content, sub)
}
if e.Children == nil {
e.Children = map[string]*Entry{}
case yaml.DocumentNode, yaml.MappingNode:
for idx := 0; idx < len(node.Content); idx += 2 {
keyNode := node.Content[idx]
valueNode := node.Content[idx+1]
key := NewEntry("", keyNode.Kind)
value := NewEntry(keyNode.Value, keyNode.Kind)
if err := keyNode.Decode(key); err != nil {
logrus.Errorf("decode map key: %s", keyNode.Value)
return err
}
if err := valueNode.Decode(value); err != nil {
logrus.Errorf("decode map key: %s", keyNode.Value)
return err
}
value.SetPath(e.Path, key.Value)
e.Content = append(e.Content, key, value)
}
for idx, child := range list {
child.Key = fmt.Sprintf("%d", idx)
e.Children[child.Key] = child
}
e.isSequence = true
case yaml.ScalarNode:
var val interface{}
err := node.Decode(&val)
if err != nil {
return err
}
e.Value = val
e.isSecret = node.Tag == "!!secret"
default:
return fmt.Errorf("unknown yaml type: %v", node.Kind)
}
return nil
}
func (e *Entry) MarshalYAML() (interface{}, error) {
if len(e.Children) == 0 {
if redactOutput && e.isSecret {
n := e.node
func (e *Entry) IsSecret() bool {
return e.Tag == "!!secret"
}
func (e *Entry) TypeStr() string {
if e.IsSecret() {
return "secret"
}
switch e.Type {
case "!!bool":
return "bool"
case "!!int":
return "int"
case "!!float":
return "float"
}
return ""
}
func (e *Entry) asNode() *yaml.Node {
return &yaml.Node{
Kind: e.Kind,
Style: e.Style,
Tag: e.Tag,
Value: e.Value,
HeadComment: e.HeadComment,
LineComment: e.LineComment,
FootComment: e.FootComment,
Line: e.Line,
Column: e.Column,
Content: []*yaml.Node{},
}
}
func (e *Entry) MarshalYAML() (*yaml.Node, error) {
n := e.asNode()
if n.Kind == yaml.ScalarNode {
if redactOutput && e.IsSecret() {
return &yaml.Node{
Kind: n.Kind,
Style: yaml.TaggedStyle,
Style: yaml.TaggedStyle & n.Style,
Tag: n.Tag,
Value: "",
HeadComment: n.HeadComment,
@ -108,23 +192,17 @@ func (e *Entry) MarshalYAML() (interface{}, error) {
Column: n.Column,
}, nil
}
return e.node, nil
return n, nil
}
if e.isSequence {
ret := make([]*Entry, len(e.Children))
for k, child := range e.Children {
idx, _ := strconv.Atoi(k)
ret[idx] = child
for _, v := range e.Content {
node, err := v.MarshalYAML()
if err != nil {
return nil, err
}
return ret, nil
n.Content = append(n.Content, node)
}
ret := map[string]*Entry{}
for k, child := range e.Children {
ret[k] = child
}
return ret, nil
return n, nil
}
func (e *Entry) FromOP(fields []*op.ItemField) error {
@ -149,13 +227,18 @@ func (e *Entry) FromOP(fields []*op.ItemField) error {
}
for label, valueStr := range data {
var value interface{}
typeString := annotations[label]
switch typeString {
var value any
var err error
var style yaml.Style
var tag string
switch annotations[label] {
case "bool":
value = valueStr == "true"
value, err = strconv.ParseBool(valueStr)
if err != nil {
return err
}
case "int":
var err error
value, err = strconv.ParseInt(valueStr, 10, 64)
if err != nil {
return err
@ -166,48 +249,41 @@ func (e *Entry) FromOP(fields []*op.ItemField) error {
if err != nil {
return err
}
case "secret":
value = secretValue(value.(string))
style = yaml.TaggedStyle
tag = "!!secret"
default:
// either no annotation or an unknown value
value = valueStr
}
// logrus.Warnf("processing: %s, %b", label, value)
path := strings.Split(label, ".")
container := e
for idx, key := range path {
if idx == len(path)-1 {
isSecret := annotations[label] == "secret"
var style yaml.Style
var tag string
if isSecret {
style = yaml.TaggedStyle
tag = "!!secret"
}
child := &Entry{
Key: key,
Path: path,
Value: value,
isSecret: isSecret,
node: &yaml.Node{
Kind: yaml.ScalarNode,
Value: valueStr,
Style: style,
Tag: tag,
},
}
container.isSequence = isNumeric(key)
container.Children[key] = child
continue
container.Content = append(container.Content, &Entry{
Path: path,
Kind: yaml.ScalarNode,
Value: valueStr,
Style: style,
Tag: tag,
})
break
}
subContainer, exists := container.Children[key]
if exists {
subContainer := container.ChildNamed(key)
if subContainer != nil {
container = subContainer
} else {
child := NewEntry(key)
kind := yaml.MappingNode
if isNumeric(key) {
kind = yaml.SequenceNode
}
child := NewEntry(key, kind)
child.Path = append(container.Path, key)
container.Children[key] = child
container.Content = append(container.Content, child)
container = child
}
}
@ -216,86 +292,76 @@ func (e *Entry) FromOP(fields []*op.ItemField) error {
return nil
}
func (e *Entry) ToOP(annotationsSection *op.ItemSection) []*op.ItemField {
func (e *Entry) ToOP() []*op.ItemField {
ret := []*op.ItemField{}
var section *op.ItemSection
name := e.Key
name := e.Path[len(e.Path)-1]
if len(e.Path) > 1 {
section = &op.ItemSection{ID: e.Path[0]}
name = strings.Join(e.Path[1:], ".")
}
if e.isSecret {
ret = append(ret, &op.ItemField{
ID: "~annotations." + strings.Join(e.Path, "."),
Section: annotationsSection,
Label: name,
Type: "STRING",
Value: "secret",
})
} else if _, ok := e.Value.(bool); ok {
ret = append(ret, &op.ItemField{
ID: "~annotations." + strings.Join(e.Path, "."),
Section: annotationsSection,
Label: name,
Type: "STRING",
Value: "bool",
})
} else if _, ok := e.Value.(int); ok {
ret = append(ret, &op.ItemField{
ID: "~annotations." + strings.Join(e.Path, "."),
Section: annotationsSection,
Label: name,
Type: "STRING",
Value: "int",
})
} else if _, ok := e.Value.(float64); ok {
ret = append(ret, &op.ItemField{
ID: "~annotations." + strings.Join(e.Path, "."),
Section: annotationsSection,
Label: name,
Type: "STRING",
Value: "float",
})
}
if len(e.Content) == 0 {
fieldType := "STRING"
if e.IsSecret() {
fieldType = "CONCEALED"
} else {
if annotationType := e.TypeStr(); annotationType != "" {
ret = append(ret, &op.ItemField{
ID: "~annotations." + strings.Join(e.Path, "."),
Section: annotationsSection,
Label: name,
Type: "STRING",
Value: annotationType,
})
}
}
if len(e.Children) == 0 {
ret = append(ret, &op.ItemField{
ID: strings.Join(e.Path, "."),
Section: section,
Label: name,
Type: "STRING",
Value: fmt.Sprintf("%s", e.Value),
Type: fieldType,
Value: e.Value,
})
} else {
for _, child := range e.Children {
ret = append(ret, child.ToOP(annotationsSection)...)
for _, child := range e.Content {
ret = append(ret, child.ToOP()...)
}
}
return ret
}
func (e *Entry) AsMap() interface{} {
if len(e.Children) == 0 {
if redactOutput && e.isSecret {
func (e *Entry) Name() string {
if e.Path == nil || len(e.Path) == 0 {
return ""
}
return e.Path[len(e.Path)-1]
}
func (e *Entry) AsMap() any {
if len(e.Content) == 0 {
if redactOutput && e.IsSecret() {
return ""
}
return e.Value
}
if e.isSequence {
ret := make([]interface{}, len(e.Children))
for key, child := range e.Children {
idx, _ := strconv.Atoi(key)
ret[idx] = child.AsMap()
if e.Kind == yaml.SequenceNode {
ret := []any{}
for _, child := range e.Content {
ret = append(ret, child.AsMap())
}
return ret
}
ret := map[string]interface{}{}
for key, child := range e.Children {
ret[key] = child.AsMap()
ret := map[string]any{}
for idx, child := range e.Content {
if idx%2 == 0 {
continue
}
ret[child.Name()] = child.AsMap()
}
return ret
}

31
pkg/config/lookup.go Normal file
View File

@ -0,0 +1,31 @@
// Copyright © 2022 Roberto Hidalgo <joao@un.rob.mx>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package config
import "fmt"
func (c *Config) Lookup(query []string) (*Entry, error) {
if len(query) == 0 || len(query) == 1 && query[0] == "." {
return c.Tree, nil
}
entry := c.Tree
for _, part := range query {
entry = entry.ChildNamed(part)
if entry == nil {
return nil, fmt.Errorf("value not found at %s of %s", part, query)
}
}
return entry, nil
}