milpaize the shit out of this before splitting stuff into its own package
This commit is contained in:
parent
6e663eacc8
commit
01a85f6f02
96
README.md
96
README.md
@ -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
|
||||
|
172
cmd/get.go
172
cmd/get.go
@ -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)
|
||||
cfg, err = loadExisting(path, remote)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err = config.ConfigFromOP(item)
|
||||
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()
|
||||
|
103
cmd/root.go
103
cmd/root.go
@ -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
|
||||
}
|
96
cmd/set.go
96
cmd/set.go
@ -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 ﹅--flush﹅ing 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
72
cmd/util.go
Normal 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
23
go.mod
@ -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
77
go.sum
@ -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=
|
||||
|
286
internal/command/arguments.go
Normal file
286
internal/command/arguments.go
Normal 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
|
||||
}
|
422
internal/command/arguments_test.go
Normal file
422
internal/command/arguments_test.go
Normal 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
134
internal/command/command.go
Normal 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
88
internal/command/help.go
Normal 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
234
internal/command/options.go
Normal 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
43
internal/command/root.go
Normal 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",
|
||||
},
|
||||
},
|
||||
}
|
54
internal/command/validation.go
Normal file
54
internal/command/validation.go
Normal 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
235
internal/command/value.go
Normal 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
|
||||
}
|
164
internal/command/value_test.go
Normal file
164
internal/command/value_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
94
internal/constants/constants.go
Normal file
94
internal/constants/constants.go
Normal 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))
|
70
internal/constants/help.md
Normal file
70
internal/constants/help.md
Normal 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
70
internal/errors/errors.go
Normal 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)
|
||||
}
|
76
internal/errors/handler.go
Normal file
76
internal/errors/handler.go
Normal 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
65
internal/exec/exec.go
Normal 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
108
internal/exec/exec_test.go
Normal 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)
|
||||
}
|
||||
}
|
78
internal/registry/cobra.go
Normal file
78
internal/registry/cobra.go
Normal 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
|
||||
}
|
265
internal/registry/registry.go
Normal file
265
internal/registry/registry.go
Normal 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
70
internal/render/render.go
Normal 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)
|
||||
}
|
87
internal/render/render_test.go
Normal file
87
internal/render/render_test.go
Normal 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
133
internal/runtime/runtime.go
Normal 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
|
||||
}
|
195
internal/runtime/runtime_test.go
Normal file
195
internal/runtime/runtime_test.go
Normal 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)
|
||||
}
|
||||
}
|
9
main.go
9
main.go
@ -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)
|
||||
}
|
||||
|
@ -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,22 +32,11 @@ type Config struct {
|
||||
}
|
||||
|
||||
var redactOutput = false
|
||||
|
||||
func (cfg *Config) ToMap() map[string]interface{} {
|
||||
ret := map[string]interface{}{}
|
||||
for _, child := range cfg.Tree.Children {
|
||||
ret[child.Key] = child.AsMap()
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (cfg *Config) ToOP() *op.Item {
|
||||
annotationsSection := &op.ItemSection{
|
||||
var annotationsSection = &op.ItemSection{
|
||||
ID: "~annotations",
|
||||
Label: "~annotations",
|
||||
}
|
||||
sections := []*op.ItemSection{annotationsSection}
|
||||
fields := []*op.ItemField{
|
||||
}
|
||||
var defaultItemFields = []*op.ItemField{
|
||||
{
|
||||
ID: "password",
|
||||
Type: "CONCEALED",
|
||||
@ -58,25 +50,35 @@ func (cfg *Config) ToOP() *op.Item {
|
||||
Label: "notesPlain",
|
||||
Value: "flushed by joao",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
for key, leaf := range cfg.Tree.Children {
|
||||
if len(leaf.Children) == 0 {
|
||||
fields = append(fields, leaf.ToOP(annotationsSection)...)
|
||||
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 {
|
||||
sections := []*op.ItemSection{annotationsSection}
|
||||
fields := append([]*op.ItemField{}, defaultItemFields...)
|
||||
|
||||
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
|
||||
}
|
||||
|
@ -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
|
||||
Value string
|
||||
Kind yaml.Kind
|
||||
Tag string
|
||||
Path []string
|
||||
Value interface{}
|
||||
Children map[string]*Entry
|
||||
isSecret bool
|
||||
isSequence bool
|
||||
node *yaml.Node
|
||||
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
|
||||
CopyFromNode(e, node)
|
||||
|
||||
switch node.Kind {
|
||||
case yaml.DocumentNode, yaml.MappingNode:
|
||||
if e.Children == nil {
|
||||
e.Children = map[string]*Entry{}
|
||||
case yaml.SequenceNode, yaml.ScalarNode:
|
||||
for _, n := range node.Content {
|
||||
sub := &Entry{}
|
||||
CopyFromNode(sub, n)
|
||||
if err := n.Decode(&sub); err != nil {
|
||||
return err
|
||||
}
|
||||
err := node.Decode(&e.Children)
|
||||
if err != nil {
|
||||
sub.SetPath(e.Path, n.Value)
|
||||
e.Content = append(e.Content, sub)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
case yaml.SequenceNode:
|
||||
list := []*Entry{}
|
||||
err := node.Decode(&list)
|
||||
if err != nil {
|
||||
if err := valueNode.Decode(value); err != nil {
|
||||
logrus.Errorf("decode map key: %s", keyNode.Value)
|
||||
return err
|
||||
}
|
||||
if e.Children == nil {
|
||||
e.Children = map[string]*Entry{}
|
||||
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 {
|
||||
case "bool":
|
||||
value = valueStr == "true"
|
||||
case "int":
|
||||
var value any
|
||||
var err error
|
||||
var style yaml.Style
|
||||
var tag string
|
||||
|
||||
switch annotations[label] {
|
||||
case "bool":
|
||||
value, err = strconv.ParseBool(valueStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case "int":
|
||||
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,
|
||||
container.Content = append(container.Content, &Entry{
|
||||
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
|
||||
})
|
||||
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 {
|
||||
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: "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",
|
||||
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
31
pkg/config/lookup.go
Normal 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
|
||||
}
|
Loading…
Reference in New Issue
Block a user